//! Publish gate is Owner-only. The role check must fire before any DB //! work, so we probe it with a bogus approval id: Members see 403 (role //! guard), Owners see 404 (row missing) — proving ordering + coverage on //! both approve and reject. use std::sync::Arc; use cm_api::AppState; use cm_auth::AuthService; use cm_domain::{Role, User, UserId, Workspace, WorkspaceId}; use cm_llm::ScriptedProvider; use cm_runtime::{Runtime, RuntimeConfig}; use reqwest::StatusCode; use serde_json::{json, Value}; use uuid::Uuid; struct Server { base: String, client: reqwest::Client, } async fn serve(pool: sqlx::PgPool) -> Server { let runtime = Runtime::new( pool.clone(), Arc::new(ScriptedProvider::from_toml("").unwrap()), RuntimeConfig::basic("scripted", 1024), ); let app = cm_api::router(AppState::new(pool, runtime)); 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(); }); Server { base: format!("http://{addr}"), client: reqwest::Client::new(), } } async fn seed_user(pool: &sqlx::PgPool, ws: WorkspaceId, role: Role, email: &str) -> UserId { let user = User { id: UserId::new(), workspace_id: ws, email: email.into(), role, display_name: format!("{role:?}"), created_at: time::OffsetDateTime::UNIX_EPOCH, }; cm_db::repo::users::insert(pool, &user).await.unwrap(); AuthService::new(pool.clone()) .set_password(user.id, "pw") .await .unwrap(); user.id } async fn login(server: &Server, email: &str) -> String { server .client .post(format!("{}/api/auth/login", server.base)) .json(&json!({"email": email, "password": "pw"})) .send() .await .unwrap() .json::() .await .unwrap()["token"] .as_str() .unwrap() .to_owned() } #[tokio::test] async fn publish_decide_is_owner_only() { let pool = cm_testkit::test_pool().await; let server = serve(pool.clone()).await; let ws = Workspace { id: WorkspaceId::new(), name: "Acme".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap(); let owner_email = format!("owner-{}@acme.test", UserId::new()); let member_email = format!("member-{}@acme.test", UserId::new()); seed_user(&pool, ws.id, Role::Owner, &owner_email).await; seed_user(&pool, ws.id, Role::Member, &member_email).await; let owner_tok = login(&server, &owner_email).await; let member_tok = login(&server, &member_email).await; // Bogus id: role guard should fire before the DB lookup. let bogus = Uuid::now_v7(); for path in [ format!("/api/research/publish-approvals/{bogus}/approve"), format!("/api/research/publish-approvals/{bogus}/reject"), ] { let member_resp = server .client .post(format!("{}{path}", server.base)) .bearer_auth(&member_tok) .json(&json!({})) .send() .await .unwrap(); assert_eq!( member_resp.status(), StatusCode::FORBIDDEN, "member should be forbidden on {path}" ); let owner_resp = server .client .post(format!("{}{path}", server.base)) .bearer_auth(&owner_tok) .json(&json!({})) .send() .await .unwrap(); assert_eq!( owner_resp.status(), StatusCode::NOT_FOUND, "owner should reach DB lookup + get 404 on {path}" ); } }