use cm_api::AppState; use cm_auth::AuthService; use cm_domain::{ AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId, }; use serde_json::{json, Value}; struct TestServer { base: String, client: reqwest::Client, } /// Boots the real axum server on an ephemeral port over real TCP. fn test_runtime(pool: sqlx::PgPool) -> cm_runtime::Runtime { cm_runtime::Runtime::new( pool, std::sync::Arc::new(cm_llm::ScriptedProvider::from_toml("").unwrap()), cm_runtime::RuntimeConfig::basic("scripted", 1024), ) } async fn serve(pool: sqlx::PgPool) -> TestServer { let state = AppState::new(pool.clone(), test_runtime(pool)); let app = cm_api::router(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); TestServer { base: format!("http://{addr}"), client: reqwest::Client::new(), } } async fn seed(pool: &sqlx::PgPool) -> (Workspace, User, Agent) { let ws = Workspace { id: WorkspaceId::new(), name: "Acme".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(pool, &ws).await.unwrap(); let owner = User { id: UserId::new(), workspace_id: ws.id, email: "owner@acme.test".into(), role: Role::Owner, display_name: "Owner".into(), created_at: time::OffsetDateTime::UNIX_EPOCH, }; cm_db::repo::users::insert(pool, &owner).await.unwrap(); let agent = Agent { id: AgentId::new(), workspace_id: ws.id, name: "Scout".into(), job_title: "Research Analyst".into(), system_prompt: String::new(), avatar: "scout-1".into(), accent: "#f96565".into(), wallpaper: "dunes".into(), managed_by: owner.id, status: AgentStatus::Online, }; cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default()) .await .unwrap(); AuthService::new(pool.clone()) .set_password(owner.id, "pw") .await .unwrap(); (ws, owner, agent) } async fn login(server: &TestServer) -> String { let res = server .client .post(format!("{}/api/auth/login", server.base)) .json(&json!({"email": "owner@acme.test", "password": "pw"})) .send() .await .unwrap(); assert_eq!(res.status(), 200); res.json::().await.unwrap()["token"] .as_str() .unwrap() .to_owned() } #[tokio::test] async fn healthz_needs_no_auth() { let pool = cm_testkit::test_pool().await; let server = serve(pool).await; let res = server .client .get(format!("{}/healthz", server.base)) .send() .await .unwrap(); assert_eq!(res.status(), 200); } #[tokio::test] async fn protected_routes_reject_missing_or_bad_tokens() { let pool = cm_testkit::test_pool().await; let server = serve(pool).await; for path in ["/api/user/me", "/api/team/claws", "/api/team/credits"] { let bare = server .client .get(format!("{}{path}", server.base)) .send() .await .unwrap(); assert_eq!(bare.status(), 401, "{path} without token"); let bad = server .client .get(format!("{}{path}", server.base)) .bearer_auth("forged-token") .send() .await .unwrap(); assert_eq!(bad.status(), 401, "{path} with forged token"); } } #[tokio::test] async fn login_rejects_wrong_password() { let pool = cm_testkit::test_pool().await; seed(&pool).await; let server = serve(pool).await; let res = server .client .post(format!("{}/api/auth/login", server.base)) .json(&json!({"email": "owner@acme.test", "password": "nope"})) .send() .await .unwrap(); assert_eq!(res.status(), 401); } #[tokio::test] async fn user_me_returns_the_authenticated_user() { let pool = cm_testkit::test_pool().await; let (ws, owner, _) = seed(&pool).await; let server = serve(pool).await; let token = login(&server).await; let me: Value = server .client .get(format!("{}/api/user/me", server.base)) .bearer_auth(&token) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(me["id"], owner.id.to_string()); assert_eq!(me["workspace_id"], ws.id.to_string()); assert_eq!(me["email"], "owner@acme.test"); assert_eq!(me["role"], "owner"); } #[tokio::test] async fn team_claws_lists_the_roster() { let pool = cm_testkit::test_pool().await; let (_, _, agent) = seed(&pool).await; let server = serve(pool).await; let token = login(&server).await; let claws: Value = server .client .get(format!("{}/api/team/claws", server.base)) .bearer_auth(&token) .send() .await .unwrap() .json() .await .unwrap(); let list = claws.as_array().unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0]["id"], agent.id.to_string()); assert_eq!(list[0]["name"], "Scout"); assert_eq!(list[0]["status"], "online"); } #[tokio::test] async fn team_credits_returns_available_balance() { let pool = cm_testkit::test_pool().await; let (ws, _, _) = seed(&pool).await; cm_db::repo::credits::add_lot(&pool, ws.id, 500, "purchase") .await .unwrap(); let server = serve(pool).await; let token = login(&server).await; let credits: Value = server .client .get(format!("{}/api/team/credits", server.base)) .bearer_auth(&token) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(credits["available"], 500); } #[tokio::test] async fn team_permissions_reflect_role() { let pool = cm_testkit::test_pool().await; seed(&pool).await; let server = serve(pool).await; let token = login(&server).await; let perms: Value = server .client .get(format!("{}/api/team/permissions", server.base)) .bearer_auth(&token) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(perms["role"], "owner"); assert_eq!(perms["can_manage_team"], true); assert_eq!(perms["can_manage_billing"], true); } #[tokio::test] async fn logout_revokes_the_session() { let pool = cm_testkit::test_pool().await; seed(&pool).await; let server = serve(pool).await; let token = login(&server).await; let res = server .client .post(format!("{}/api/auth/logout", server.base)) .bearer_auth(&token) .send() .await .unwrap(); assert_eq!(res.status(), 204); let me = server .client .get(format!("{}/api/user/me", server.base)) .bearer_auth(&token) .send() .await .unwrap(); assert_eq!(me.status(), 401); } #[tokio::test] async fn team_members_lists_workspace_users() { let pool = cm_testkit::test_pool().await; let (_, owner, _) = seed(&pool).await; let server = serve(pool).await; let token = login(&server).await; let members: Value = server .client .get(format!("{}/api/team/members", server.base)) .bearer_auth(&token) .send() .await .unwrap() .json() .await .unwrap(); let list = members.as_array().unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0]["id"], owner.id.to_string()); assert_eq!(list[0]["role"], "owner"); assert_eq!(list[0]["display_name"], "Owner"); } #[tokio::test] async fn topology_catalog_lists_all_kinds() { let pool = cm_testkit::test_pool().await; seed(&pool).await; let server = serve(pool).await; let token = login(&server).await; let catalog: Value = server .client .get(format!("{}/api/topologies", server.base)) .bearer_auth(&token) .send() .await .unwrap() .json() .await .unwrap(); let kinds = catalog.as_array().unwrap(); assert_eq!(kinds.len(), 12); assert!(kinds.iter().any(|k| k["kind"] == "hierarchical")); assert!(!kinds[0]["role_distribution"].as_array().unwrap().is_empty()); } #[tokio::test] async fn topology_build_returns_a_canonical_graph() { let pool = cm_testkit::test_pool().await; seed(&pool).await; let server = serve(pool).await; let token = login(&server).await; let graph: Value = server .client .post(format!("{}/api/topologies/build", server.base)) .bearer_auth(&token) .json(&json!({ "kind": "pipeline", "roles": ["a", "b", "c"] })) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(graph["kind"], "pipeline"); assert_eq!(graph["nodes"].as_array().unwrap().len(), 3); assert_eq!(graph["edges"].as_array().unwrap().len(), 2); // build rejects empty roles with 400. let bad = server .client .post(format!("{}/api/topologies/build", server.base)) .bearer_auth(&token) .json(&json!({ "kind": "pipeline", "roles": [] })) .send() .await .unwrap(); assert_eq!(bad.status(), 400); } #[tokio::test] async fn topology_compare_runs_across_topologies() { let pool = cm_testkit::test_pool().await; seed(&pool).await; let server = serve(pool).await; let token = login(&server).await; let res = server .client .post(format!("{}/api/topologies/compare", server.base)) .bearer_auth(&token) .json(&json!({ "task": "draft a launch plan", "graphs": [ { "kind": "pipeline", "nodes": [{"id": "a", "role": "researcher"}, {"id": "b", "role": "writer"}], "edges": [{"from": "a", "to": "b", "kind": "pipes_to"}] }, { "kind": "swarm", "nodes": [{"id": "x", "role": "writer"}, {"id": "y", "role": "coordinator"}], "edges": [] } ] })) .send() .await .unwrap(); assert_eq!(res.status(), 200); let cmp: Value = res.json().await.unwrap(); assert_eq!(cmp["results"].as_array().unwrap().len(), 2); assert_eq!(cmp["leaderboard"].as_array().unwrap().len(), 2); // The run was persisted and is listable + fetchable. let runs: Value = server .client .get(format!("{}/api/topology-runs", server.base)) .bearer_auth(&token) .send() .await .unwrap() .json() .await .unwrap(); let list = runs.as_array().unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0]["task"], "draft a launch plan"); let id = list[0]["id"].as_str().unwrap(); let detail: Value = server .client .get(format!("{}/api/topology-runs/{id}", server.base)) .bearer_auth(&token) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(detail["comparison"]["results"].as_array().unwrap().len(), 2); } #[tokio::test] async fn topology_endpoints_require_auth() { let pool = cm_testkit::test_pool().await; let server = serve(pool).await; let res = server .client .get(format!("{}/api/topologies", server.base)) .send() .await .unwrap(); assert_eq!(res.status(), 401); }