Espone API JSON e MCP HTTP per far lavorare gli agenti sul CRM.
Gli agenti autenticati con token Bearer possono leggere today/pipeline e annotare attività, con host MCP allineati a quelli di produzione. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::ApiTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@admin_token = ApiToken.issue!(user: users(:admin), name: "test-admin")
|
||||
@marco_token = ApiToken.issue!(user: users(:marco), name: "test-marco")
|
||||
@org = organizations(:acme)
|
||||
@task = tasks(:follow_up)
|
||||
@opportunity = opportunities(:deal)
|
||||
end
|
||||
|
||||
test "rejects missing token" do
|
||||
get "/api/v1/projects", as: :json
|
||||
assert_response :unauthorized
|
||||
assert_equal "Non autenticato", json_body["error"]
|
||||
end
|
||||
|
||||
test "rejects invalid token" do
|
||||
get "/api/v1/projects", headers: bearer("crm_invalid"), as: :json
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "lists accessible projects" do
|
||||
get "/api/v1/projects", headers: bearer(@marco_token.plaintext), as: :json
|
||||
assert_response :success
|
||||
codes = json_body["projects"].map { |p| p["code"] }
|
||||
assert_includes codes, "matchlivetv"
|
||||
assert_not_includes codes, "riskmeter"
|
||||
end
|
||||
|
||||
test "forbids project the user cannot access" do
|
||||
get "/api/v1/p/riskmeter/today", headers: bearer(@marco_token.plaintext), as: :json
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "returns 404 for unknown project" do
|
||||
get "/api/v1/p/sconosciuto/today", headers: bearer(@admin_token.plaintext), as: :json
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "today includes due tasks" do
|
||||
get "/api/v1/p/matchlivetv/today", headers: bearer(@admin_token.plaintext), as: :json
|
||||
assert_response :success
|
||||
ids = json_body["today_tasks"].map { |t| t["id"] }
|
||||
assert_includes ids, @task.id
|
||||
assert_equal "matchlivetv", json_body["project"]["code"]
|
||||
end
|
||||
|
||||
test "search finds organization" do
|
||||
get "/api/v1/p/matchlivetv/search", params: { q: "ASD Test" }, headers: bearer(@admin_token.plaintext), as: :json
|
||||
assert_response :success
|
||||
org_ids = json_body["organizations"].map { |o| o["id"] }
|
||||
assert_includes org_ids, @org.id
|
||||
end
|
||||
|
||||
test "shows organization card" do
|
||||
get "/api/v1/p/matchlivetv/organizations/#{@org.id}", headers: bearer(@admin_token.plaintext), as: :json
|
||||
assert_response :success
|
||||
assert_equal @org.name, json_body["organization"]["name"]
|
||||
assert json_body["contacts"].any?
|
||||
assert json_body["open_opportunities"].any?
|
||||
assert json_body["pending_tasks"].any?
|
||||
end
|
||||
|
||||
test "creates activity" do
|
||||
assert_difference -> { Activity.count }, 1 do
|
||||
post "/api/v1/p/matchlivetv/activities",
|
||||
params: {
|
||||
organization_id: @org.id,
|
||||
activity_type: "note",
|
||||
subject: "Nota agente",
|
||||
description: "Creato via API"
|
||||
},
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
end
|
||||
assert_response :created
|
||||
assert_equal "Nota agente", json_body["activity"]["subject"]
|
||||
end
|
||||
|
||||
test "creates and completes task" do
|
||||
post "/api/v1/p/matchlivetv/tasks",
|
||||
params: {
|
||||
organization_id: @org.id,
|
||||
title: "Richiamare dopo demo",
|
||||
due_at: 1.day.from_now.iso8601,
|
||||
task_type: "call",
|
||||
priority: "high"
|
||||
},
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
assert_response :created
|
||||
task_id = json_body["task"]["id"]
|
||||
|
||||
post "/api/v1/p/matchlivetv/tasks/#{task_id}/complete",
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
assert_response :success
|
||||
assert_equal "completed", json_body["task"]["status"]
|
||||
end
|
||||
|
||||
test "updates opportunity stage" do
|
||||
patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage",
|
||||
params: { pipeline_stage: "demo_trial", notes: "Demo fissata dall'agente" },
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
assert_response :success
|
||||
assert_equal "demo_trial", json_body["opportunity"]["pipeline_stage"]
|
||||
@opportunity.reload
|
||||
assert_equal "demo_trial", @opportunity.pipeline_stage
|
||||
end
|
||||
|
||||
test "rejects lost stage without reason" do
|
||||
patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage",
|
||||
params: { pipeline_stage: "lost" },
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bearer(plaintext)
|
||||
{ "Authorization" => "Bearer #{plaintext}" }
|
||||
end
|
||||
|
||||
def json_body
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
require "test_helper"
|
||||
|
||||
class ApiTokensControllerTest < ActionDispatch::IntegrationTest
|
||||
test "requires login" do
|
||||
get api_tokens_path
|
||||
assert_redirected_to login_path
|
||||
end
|
||||
|
||||
test "user can create and revoke own token" do
|
||||
login_as users(:marco)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
assert_difference -> { users(:marco).api_tokens.active.count }, 1 do
|
||||
post api_tokens_path, params: { name: "Codex" }
|
||||
end
|
||||
assert_redirected_to api_tokens_path
|
||||
follow_redirect!
|
||||
assert_response :success
|
||||
assert_match(/crm_/, response.body)
|
||||
|
||||
token = users(:marco).api_tokens.active.last
|
||||
delete api_token_path(token)
|
||||
assert_redirected_to api_tokens_path
|
||||
assert token.reload.revoked?
|
||||
end
|
||||
|
||||
test "non admin can open token page" do
|
||||
login_as users(:marco)
|
||||
follow_redirect! if response.redirect?
|
||||
get api_tokens_path
|
||||
assert_response :success
|
||||
assert_match(/Token API/, response.body)
|
||||
end
|
||||
end
|
||||
@@ -87,6 +87,7 @@ class AuthenticationTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
assert_match(/Impostazioni piattaforma/, response.body)
|
||||
assert_match(/Utenti/, response.body)
|
||||
assert_match(/Token API/, response.body)
|
||||
end
|
||||
|
||||
test "non admin cannot open platform settings" do
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
require "test_helper"
|
||||
|
||||
class McpControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@token = ApiToken.issue!(user: users(:admin), name: "mcp-test")
|
||||
@marco = ApiToken.issue!(user: users(:marco), name: "mcp-marco")
|
||||
end
|
||||
|
||||
test "rejects missing token" do
|
||||
post "/mcp", params: rpc("initialize"), as: :json, headers: mcp_headers(nil)
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "initialize advertises CRM tools" do
|
||||
post "/mcp",
|
||||
params: rpc("initialize", {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "1.0" }
|
||||
}),
|
||||
as: :json,
|
||||
headers: mcp_headers(@token.plaintext)
|
||||
|
||||
assert_response :success
|
||||
names = Array(json_rpc.dig("result", "capabilities", "tools")).presence
|
||||
listed = tool_names_from_initialize.presence || fetch_tool_names
|
||||
assert_includes listed, "list_projects"
|
||||
assert_includes listed, "today"
|
||||
assert_includes listed, "create_activity"
|
||||
assert names || listed.any?
|
||||
end
|
||||
|
||||
test "list_projects returns accessible projects" do
|
||||
result = call_tool("list_projects", {}, token: @marco.plaintext)
|
||||
codes = result.fetch("projects").map { |p| p["code"] }
|
||||
assert_includes codes, "matchlivetv"
|
||||
assert_not_includes codes, "riskmeter"
|
||||
end
|
||||
|
||||
test "today requires project access" do
|
||||
result = call_tool("today", { project_code: "riskmeter" }, token: @marco.plaintext, expect_error: true)
|
||||
assert_equal "Progetto non accessibile", result["error"]
|
||||
end
|
||||
|
||||
test "today returns tasks for matchlivetv" do
|
||||
result = call_tool("today", { project_code: "matchlivetv" })
|
||||
ids = result.fetch("today_tasks").map { |t| t["id"] }
|
||||
assert_includes ids, tasks(:follow_up).id
|
||||
end
|
||||
|
||||
test "allowed_hosts includes production APP_HOST and RAILS_ALLOWED_HOSTS" do
|
||||
previous_app = ENV["APP_HOST"]
|
||||
previous_allowed = ENV["RAILS_ALLOWED_HOSTS"]
|
||||
ENV["APP_HOST"] = "crm.eminux.it"
|
||||
ENV["RAILS_ALLOWED_HOSTS"] = "crm.eminux.it, 192.168.1.158, localhost"
|
||||
|
||||
hosts = EminuxCrmMcp.allowed_hosts
|
||||
assert_includes hosts, "crm.eminux.it"
|
||||
assert_includes hosts, "192.168.1.158"
|
||||
assert_includes hosts, "localhost"
|
||||
ensure
|
||||
ENV["APP_HOST"] = previous_app
|
||||
ENV["RAILS_ALLOWED_HOSTS"] = previous_allowed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mcp_headers(plaintext)
|
||||
headers = {
|
||||
"Accept" => "application/json, text/event-stream",
|
||||
"MCP-Protocol-Version" => "2025-06-18"
|
||||
}
|
||||
headers["Authorization"] = "Bearer #{plaintext}" if plaintext.present?
|
||||
headers
|
||||
end
|
||||
|
||||
def rpc(method, params = {}, id: 1)
|
||||
{ jsonrpc: "2.0", id: id, method: method, params: params }
|
||||
end
|
||||
|
||||
def json_rpc
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def initialize_mcp!(token)
|
||||
post "/mcp",
|
||||
params: rpc("initialize", {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "1.0" }
|
||||
}),
|
||||
as: :json,
|
||||
headers: mcp_headers(token)
|
||||
assert_response :success, response.body
|
||||
end
|
||||
|
||||
def fetch_tool_names
|
||||
initialize_mcp!(@token.plaintext)
|
||||
post "/mcp", params: rpc("tools/list", {}, id: 2), as: :json, headers: mcp_headers(@token.plaintext)
|
||||
assert_response :success, response.body
|
||||
Array(json_rpc.dig("result", "tools")).map { |t| t["name"] }
|
||||
end
|
||||
|
||||
def tool_names_from_initialize
|
||||
Array(json_rpc.dig("result", "tools")).map { |t| t["name"] }
|
||||
end
|
||||
|
||||
def call_tool(name, arguments, token: @token.plaintext, expect_error: false)
|
||||
initialize_mcp!(token)
|
||||
post "/mcp",
|
||||
params: rpc("tools/call", { name: name, arguments: arguments }, id: 2),
|
||||
as: :json,
|
||||
headers: mcp_headers(token)
|
||||
assert_response :success, response.body
|
||||
payload = json_rpc
|
||||
text = payload.dig("result", "content", 0, "text") || payload.dig("result", "content", 0, :text)
|
||||
refute_nil text, payload.inspect
|
||||
parsed = JSON.parse(text)
|
||||
if expect_error
|
||||
assert payload.dig("result", "isError") || parsed["error"].present?, payload.inspect
|
||||
else
|
||||
assert_nil parsed["error"], payload.inspect
|
||||
end
|
||||
parsed
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user