//! Phase 3.5 authorization end-to-end tests. //! //! Covers the 11 scenarios from `lay-foundations-for-snazzy-truffle.md` //! step 9: //! //! 1. Bootstrap admin promotes to owner. //! 2. Owner access matrix on a sample app. //! 3. Admin access matrix. //! 4. Member access matrix. //! 5. Bearer (pic_) + cookie produce the same Principal. //! 6. Scope intersection: a script:read-only key cannot write. //! 7. Bound key cannot escape its app. //! 8. Member listing isolation (apps + scripts). //! 9. Deactivation revokes API keys. //! 10. Mint rejects bound key with `instance:*` scope. //! 11. `list_active_owners` returns the expected set under the seed //! that the startup warning is built from (we don't capture the //! log line itself — the data source is the testable surface). //! //! Same harness as `tests/api.rs`: `#[sqlx::test]` against a real //! Postgres, `TestServer` over the in-process app. We do NOT bake a //! token into the default headers here — each test wires its own //! credential per request to exercise the cookie / Bearer split. #![allow(clippy::needless_pass_by_value)] use std::sync::Arc; use axum_test::TestServer; use picloud_manager_core::{ auth::hash_password, AdminUserRepository, ApiKeyRepository, AppMembersRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppMembersRepository, }; use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole}; use serde_json::{json, Value}; use sqlx::PgPool; // ---------------------------------------------------------------------------- // Harness // ---------------------------------------------------------------------------- struct Seeded { server: TestServer, pool: PgPool, /// Bootstrap admin — Owner, password "owner-pw". owner: AdminUserId, /// Default app id, slug "default" (seeded by 0005 migration). default_app: AppId, } async fn boot(pool: PgPool) -> Seeded { let auth = picloud::AuthDeps::from_pool(pool.clone()); let hash = hash_password("owner-pw").expect("hash"); let owner = auth .users .create("owner", &hash, InstanceRole::Owner, None) .await .expect("seed owner"); let app = picloud::build_app(pool.clone(), auth) .await .expect("build_app"); let server = TestServer::new(app).expect("TestServer"); // Default app id (seeded by migration 0005). let resp = server .post("/api/v1/admin/auth/login") .json(&json!({ "username": "owner", "password": "owner-pw" })) .await; resp.assert_status_ok(); let token = resp.json::()["token"] .as_str() .expect("login token") .to_string(); let app_resp = server .get("/api/v1/admin/apps/default") .add_header("authorization", format!("Bearer {token}")) .await; app_resp.assert_status_ok(); let app_id: uuid::Uuid = app_resp.json::()["id"] .as_str() .expect("app id") .parse() .expect("uuid"); Seeded { server, pool, owner: owner.id, default_app: app_id.into(), } } /// Mint a session for an existing admin via the login endpoint and /// return the raw token. Lets tests build a per-role credential /// without baking it into the default headers. async fn login_token(server: &TestServer, username: &str, password: &str) -> String { let r = server .post("/api/v1/admin/auth/login") .json(&json!({ "username": username, "password": password })) .await; r.assert_status_ok(); r.json::()["token"] .as_str() .expect("token in login response") .to_string() } /// Direct-DB seed (bypassing the API) for users we want to construct /// at arbitrary roles. The API enforces "owners only create owners" /// which is correct production behavior but inconvenient for test /// fixtures. async fn seed_user( pool: &PgPool, username: &str, password: &str, role: InstanceRole, ) -> AdminUserId { let repo = PostgresAdminUserRepository::new(pool.clone()); let hash = hash_password(password).expect("hash"); repo.create(username, &hash, role, None) .await .expect("seed user") .id } async fn grant_membership(pool: &PgPool, user: AdminUserId, app: AppId, role: AppRole) { let repo = PostgresAppMembersRepository::new(pool.clone()); repo.upsert(app, user, role) .await .expect("grant membership"); } async fn create_script_via_api( server: &TestServer, token: &str, app_id: AppId, name: &str, ) -> Value { let r = server .post("/api/v1/admin/scripts") .add_header("authorization", format!("Bearer {token}")) .json(&json!({ "app_id": app_id.to_string(), "name": name, "source": "fn main() { #{ statusCode: 200 } }", })) .await; r.assert_status(axum::http::StatusCode::CREATED); r.json() } /// Mint an API key for the caller — wraps POST /api-keys. async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_test::TestResponse { server .post("/api/v1/admin/api-keys") .add_header("authorization", format!("Bearer {cred_token}")) .json(&body) .await } // --- app members helpers ---------------------------------------------------- async fn list_members( server: &TestServer, token: &str, app_ident: &str, ) -> axum_test::TestResponse { server .get(&format!("/api/v1/admin/apps/{app_ident}/members")) .add_header("authorization", format!("Bearer {token}")) .await } async fn add_member( server: &TestServer, token: &str, app_ident: &str, user_id: AdminUserId, role: AppRole, ) -> axum_test::TestResponse { server .post(&format!("/api/v1/admin/apps/{app_ident}/members")) .add_header("authorization", format!("Bearer {token}")) .json(&json!({ "user_id": user_id, "role": role.as_str() })) .await } async fn patch_member_role( server: &TestServer, token: &str, app_ident: &str, user_id: AdminUserId, role: AppRole, ) -> axum_test::TestResponse { server .patch(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",)) .add_header("authorization", format!("Bearer {token}")) .json(&json!({ "role": role.as_str() })) .await } async fn remove_member( server: &TestServer, token: &str, app_ident: &str, user_id: AdminUserId, ) -> axum_test::TestResponse { server .delete(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",)) .add_header("authorization", format!("Bearer {token}")) .await } /// Direct-DB inactive-user seed — the create-then-deactivate dance /// through the API is more ceremony than the test needs. async fn seed_inactive_user(pool: &PgPool, username: &str, password: &str) -> AdminUserId { let repo = PostgresAdminUserRepository::new(pool.clone()); let hash = hash_password(password).expect("hash"); let row = repo .create(username, &hash, InstanceRole::Member, None) .await .expect("seed user"); repo.set_active(row.id, false).await.expect("deactivate"); row.id } // ---------------------------------------------------------------------------- // 1. Bootstrap admin → owner // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn bootstrap_admin_is_owner(pool: PgPool) { let s = boot(pool).await; let token = login_token(&s.server, "owner", "owner-pw").await; let me = s .server .get("/api/v1/admin/auth/me") .add_header("authorization", format!("Bearer {token}")) .await; me.assert_status_ok(); let listing = s .server .get("/api/v1/admin/admins") .add_header("authorization", format!("Bearer {token}")) .await; listing.assert_status_ok(); let arr: Value = listing.json(); let row = arr .as_array() .and_then(|v| v.iter().find(|u| u["username"] == "owner")) .expect("owner row"); assert_eq!(row["instance_role"], "owner"); } // ---------------------------------------------------------------------------- // 2 / 3 / 4. Role access matrices on a sample app // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn owner_access_matrix(pool: PgPool) { let s = boot(pool.clone()).await; let token = login_token(&s.server, "owner", "owner-pw").await; // Read apps / scripts. s.server .get("/api/v1/admin/apps/default") .add_header("authorization", format!("Bearer {token}")) .await .assert_status_ok(); // Create a script — AppWriteScript. let script = create_script_via_api(&s.server, &token, s.default_app, "owner-test").await; let sid = script["id"].as_str().unwrap(); // Read it back — AppRead. s.server .get(&format!("/api/v1/admin/scripts/{sid}")) .add_header("authorization", format!("Bearer {token}")) .await .assert_status_ok(); // Manage users — InstanceManageUsers. s.server .get("/api/v1/admin/admins") .add_header("authorization", format!("Bearer {token}")) .await .assert_status_ok(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn admin_is_implicit_app_admin_on_every_app(pool: PgPool) { let s = boot(pool.clone()).await; seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await; let token = login_token(&s.server, "alice", "alice-pw").await; // Allowed: list admins (InstanceManageUsers). s.server .get("/api/v1/admin/admins") .add_header("authorization", format!("Bearer {token}")) .await .assert_status_ok(); // Allowed: read default app — admin is implicit app_admin // everywhere (per blueprint §11.6). s.server .get("/api/v1/admin/apps/default") .add_header("authorization", format!("Bearer {token}")) .await .assert_status_ok(); // Allowed: write scripts. let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await; assert!(script["id"].is_string()); // Allowed: list app members (AppAdmin gate). Pre-3.5.x this // 403'd; now it's the same allow as the owner sees. s.server .get("/api/v1/admin/apps/default/members") .add_header("authorization", format!("Bearer {token}")) .await .assert_status_ok(); // Allowed: delete the default app (AppAdmin). ?force=true because // the script we created above pushes us past the soft no-cascade // guard — this test is about the capability, not the cascade. s.server .delete("/api/v1/admin/apps/default?force=true") .add_header("authorization", format!("Bearer {token}")) .await .assert_status(axum::http::StatusCode::NO_CONTENT); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn member_can_only_touch_apps_they_belong_to(pool: PgPool) { let s = boot(pool.clone()).await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await; let token = login_token(&s.server, "bob", "bob-pw").await; // Allowed: read + write inside the default app. s.server .get("/api/v1/admin/apps/default") .add_header("authorization", format!("Bearer {token}")) .await .assert_status_ok(); let script = create_script_via_api(&s.server, &token, s.default_app, "member-write").await; let sid = script["id"].as_str().unwrap(); s.server .get(&format!("/api/v1/admin/scripts/{sid}")) .add_header("authorization", format!("Bearer {token}")) .await .assert_status_ok(); // Denied: create a *new* app (member cannot InstanceCreateApp). let denied = s .server .post("/api/v1/admin/apps") .add_header("authorization", format!("Bearer {token}")) .json(&json!({ "slug": "other", "name": "Other" })) .await; assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); // Denied: manage admins. let denied = s .server .get("/api/v1/admin/admins") .add_header("authorization", format!("Bearer {token}")) .await; assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); } // ---------------------------------------------------------------------------- // 5. Bearer pic_ + cookie produce the same Principal // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn bearer_and_cookie_produce_same_principal(pool: PgPool) { let s = boot(pool).await; let session_token = login_token(&s.server, "owner", "owner-pw").await; // Mint a no-binding owner key covering script:read. let mint = mint_key( &s.server, &session_token, json!({ "name": "owner-readonly", "scopes": ["script:read"], }), ) .await; mint.assert_status(axum::http::StatusCode::CREATED); let raw_token = mint.json::()["raw_token"] .as_str() .expect("raw token") .to_string(); assert!(raw_token.starts_with("pic_")); // /me through the cookie/session path. let via_session = s .server .get("/api/v1/admin/auth/me") .add_header("authorization", format!("Bearer {session_token}")) .await; via_session.assert_status_ok(); // /me through the pic_ path — same user_id. let via_key = s .server .get("/api/v1/admin/auth/me") .add_header("authorization", format!("Bearer {raw_token}")) .await; via_key.assert_status_ok(); assert_eq!( via_session.json::()["id"], via_key.json::()["id"] ); assert_eq!( via_session.json::()["username"], via_key.json::()["username"] ); } // ---------------------------------------------------------------------------- // 6. Scope intersection — read-only key cannot write // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn read_only_key_cannot_write_scripts(pool: PgPool) { let s = boot(pool).await; let session_token = login_token(&s.server, "owner", "owner-pw").await; let mint = mint_key( &s.server, &session_token, json!({ "name": "ro", "scopes": ["script:read"] }), ) .await; mint.assert_status(axum::http::StatusCode::CREATED); let raw = mint.json::()["raw_token"] .as_str() .unwrap() .to_string(); let denied = s .server .post("/api/v1/admin/scripts") .add_header("authorization", format!("Bearer {raw}")) .json(&json!({ "app_id": s.default_app.to_string(), "name": "would-write", "source": "fn main() { #{ statusCode: 200 } }", })) .await; assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); } // ---------------------------------------------------------------------------- // 7. Bound key cannot escape its app // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn bound_key_cannot_escape_its_app(pool: PgPool) { let s = boot(pool.clone()).await; let session_token = login_token(&s.server, "owner", "owner-pw").await; // Create a second app via the API (owner can InstanceCreateApp). let other = s .server .post("/api/v1/admin/apps") .add_header("authorization", format!("Bearer {session_token}")) .json(&json!({ "slug": "other", "name": "Other" })) .await; other.assert_status(axum::http::StatusCode::CREATED); let other_id = other.json::()["id"].as_str().unwrap().to_string(); // Mint a key bound to the default app with script:write. let mint = mint_key( &s.server, &session_token, json!({ "name": "default-only", "scopes": ["script:write"], "app_id": s.default_app.to_string(), }), ) .await; mint.assert_status(axum::http::StatusCode::CREATED); let raw = mint.json::()["raw_token"] .as_str() .unwrap() .to_string(); // Writing into the bound app: allowed. let ok = s .server .post("/api/v1/admin/scripts") .add_header("authorization", format!("Bearer {raw}")) .json(&json!({ "app_id": s.default_app.to_string(), "name": "bound-ok", "source": "fn main() { #{ statusCode: 200 } }", })) .await; ok.assert_status(axum::http::StatusCode::CREATED); // Writing into the *other* app: forbidden. let denied = s .server .post("/api/v1/admin/scripts") .add_header("authorization", format!("Bearer {raw}")) .json(&json!({ "app_id": other_id, "name": "escape-attempt", "source": "fn main() { #{ statusCode: 200 } }", })) .await; assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); } // ---------------------------------------------------------------------------- // 8. Member listing isolation // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn member_list_endpoints_filter_at_sql(pool: PgPool) { let s = boot(pool.clone()).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; // Owner creates a second app + script-in-that-app. let other = s .server .post("/api/v1/admin/apps") .add_header("authorization", format!("Bearer {owner_token}")) .json(&json!({ "slug": "secret", "name": "Secret" })) .await; other.assert_status(axum::http::StatusCode::CREATED); let other_id: uuid::Uuid = other.json::()["id"] .as_str() .unwrap() .parse() .unwrap(); let other_app: AppId = other_id.into(); create_script_via_api(&s.server, &owner_token, other_app, "secret-script").await; create_script_via_api(&s.server, &owner_token, s.default_app, "default-script").await; // Carol is a member of the default app only. let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await; grant_membership(&s.pool, carol, s.default_app, AppRole::Viewer).await; let carol_token = login_token(&s.server, "carol", "carol-pw").await; let apps = s .server .get("/api/v1/admin/apps") .add_header("authorization", format!("Bearer {carol_token}")) .await; apps.assert_status_ok(); let apps_body: Value = apps.json(); let app_slugs: Vec = apps_body .as_array() .unwrap() .iter() .map(|a| a["slug"].as_str().unwrap().to_string()) .collect(); assert_eq!( app_slugs, vec!["default"], "member must see only their apps" ); let scripts = s .server .get("/api/v1/admin/scripts") .add_header("authorization", format!("Bearer {carol_token}")) .await; scripts.assert_status_ok(); let scripts_body: Value = scripts.json(); let names: Vec = scripts_body .as_array() .unwrap() .iter() .map(|s| s["name"].as_str().unwrap().to_string()) .collect(); assert!( names.iter().any(|n| n == "default-script") && !names.iter().any(|n| n == "secret-script"), "member listing leaked another app's script: {names:?}" ); } // ---------------------------------------------------------------------------- // 9. Deactivation revokes API keys // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn deactivating_user_revokes_their_api_keys(pool: PgPool) { let s = boot(pool.clone()).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; // A second user — admin so they can mint a key for themselves. let dave_id = seed_user(&s.pool, "dave", "dave-pw", InstanceRole::Admin).await; let dave_token = login_token(&s.server, "dave", "dave-pw").await; let mint = mint_key( &s.server, &dave_token, json!({ "name": "dave-key", "scopes": ["script:read"] }), ) .await; mint.assert_status(axum::http::StatusCode::CREATED); let raw = mint.json::()["raw_token"] .as_str() .unwrap() .to_string(); // Key works. let before = s .server .get("/api/v1/admin/auth/me") .add_header("authorization", format!("Bearer {raw}")) .await; before.assert_status_ok(); // Owner deactivates Dave. let patch = s .server .patch(&format!("/api/v1/admin/admins/{dave_id}")) .add_header("authorization", format!("Bearer {owner_token}")) .json(&json!({ "is_active": false })) .await; patch.assert_status_ok(); // Key now rejects with 401. let after = s .server .get("/api/v1/admin/auth/me") .add_header("authorization", format!("Bearer {raw}")) .await; assert_eq!(after.status_code(), axum::http::StatusCode::UNAUTHORIZED); // Cross-check via the repo: the row's expires_at is set in the past. let repo = PostgresApiKeyRepository::new(s.pool.clone()); let rows = repo.list_for_user(dave_id).await.expect("list keys"); assert!( rows.iter().all(|r| r.expires_at.is_some()), "every key must have an expiry after deactivation" ); assert!(rows .iter() .all(|r| r.expires_at.unwrap() <= chrono::Utc::now())); } // ---------------------------------------------------------------------------- // 10. Mint rejects bound key + instance scope // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn bound_key_with_instance_scope_is_rejected(pool: PgPool) { let s = boot(pool).await; let token = login_token(&s.server, "owner", "owner-pw").await; let r = s .server .post("/api/v1/admin/api-keys") .add_header("authorization", format!("Bearer {token}")) .json(&json!({ "name": "irreconcilable", "scopes": ["instance:admin"], "app_id": s.default_app.to_string(), })) .await; assert_eq!( r.status_code(), axum::http::StatusCode::UNPROCESSABLE_ENTITY ); let body: Value = r.json(); assert!( body["error"].as_str().unwrap().contains("bound"), "error body should explain the conflict, got {body}" ); } // ---------------------------------------------------------------------------- // 11. Multi-owner detection — data-source for the startup warning // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) { let s = boot(pool.clone()).await; // Seed a second owner directly so we exercise the // multi-owner condition. seed_user(&s.pool, "owner2", "pw", InstanceRole::Owner).await; seed_user(&s.pool, "admin1", "pw", InstanceRole::Admin).await; let users = Arc::new(PostgresAdminUserRepository::new(s.pool.clone())); let owners = users.list_active_owners().await.expect("list owners"); let names: Vec<&str> = owners.iter().map(|o| o.username.as_str()).collect(); assert!(names.contains(&"owner")); assert!(names.contains(&"owner2")); assert!(!names.contains(&"admin1")); assert_eq!( owners.len(), 2, "list_active_owners must filter strictly by instance_role" ); // count_other_active_owners powers the last-owner guard. let remaining = users .count_other_active_owners(s.owner) .await .expect("count"); assert_eq!(remaining, 1, "one other owner should remain (owner2)"); } // ---------------------------------------------------------------------------- // 12. `my_role` on GET /apps/{id_or_slug} reflects the caller's effective role // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn my_role_field_matches_caller_role(pool: PgPool) { let s = boot(pool).await; // Owner → implicit app_admin everywhere. let owner_token = login_token(&s.server, "owner", "owner-pw").await; let r = s .server .get("/api/v1/admin/apps/default") .add_header("authorization", format!("Bearer {owner_token}")) .await; r.assert_status_ok(); assert_eq!( r.json::()["my_role"].as_str(), Some("app_admin"), "owner reports app_admin" ); // Admin → implicit app_admin everywhere (post-§11.6 update). seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await; let admin_token = login_token(&s.server, "alice", "alice-pw").await; let r = s .server .get("/api/v1/admin/apps/default") .add_header("authorization", format!("Bearer {admin_token}")) .await; r.assert_status_ok(); assert_eq!( r.json::()["my_role"].as_str(), Some("app_admin"), "admin reports app_admin" ); // Member with explicit `viewer` membership → viewer. let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await; let bob_token = login_token(&s.server, "bob", "bob-pw").await; let r = s .server .get("/api/v1/admin/apps/default") .add_header("authorization", format!("Bearer {bob_token}")) .await; r.assert_status_ok(); assert_eq!( r.json::()["my_role"].as_str(), Some("viewer"), "member with viewer row reports viewer" ); } // ---------------------------------------------------------------------------- // 13. App members CRUD — `/api/v1/admin/apps/{id_or_slug}/members[/{user_id}]` // ---------------------------------------------------------------------------- #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn list_members_includes_seeded_member(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await; let r = list_members(&s.server, &owner_token, "default").await; r.assert_status_ok(); let rows = r.json::>(); let bob_row = rows .iter() .find(|v| v["username"] == "bob") .expect("bob in list"); assert_eq!(bob_row["role"], "viewer"); assert_eq!(bob_row["instance_role"], "member"); assert_eq!(bob_row["is_active"], true); assert!(bob_row["created_at"].is_string(), "carries created_at"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn list_members_requires_app_admin(pool: PgPool) { let s = boot(pool).await; // Bob has explicit editor on default app — enough to read scripts, // not enough to see the member list. let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await; let bob_token = login_token(&s.server, "bob", "bob-pw").await; let r = list_members(&s.server, &bob_token, "default").await; r.assert_status(axum::http::StatusCode::FORBIDDEN); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn add_member_creates_row(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await; r.assert_status(axum::http::StatusCode::CREATED); let body = r.json::(); assert_eq!(body["username"], "bob"); assert_eq!(body["role"], "viewer"); assert_eq!(body["instance_role"], "member"); // Visible on subsequent list. let rows = list_members(&s.server, &owner_token, "default") .await .json::>(); assert!(rows.iter().any(|v| v["username"] == "bob")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn add_member_duplicate_returns_409(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await; let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Editor).await; r.assert_status(axum::http::StatusCode::CONFLICT); let err = r.json::()["error"] .as_str() .expect("error message") .to_string(); assert!(err.contains("already a member"), "got: {err}"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn add_member_inactive_user_returns_422(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_inactive_user(&s.pool, "bob", "bob-pw").await; let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await; r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); let err = r.json::()["error"] .as_str() .expect("error message") .to_string(); assert!(err.contains("deactivated"), "got: {err}"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn add_member_admin_target_returns_422(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let alice = seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await; let r = add_member(&s.server, &owner_token, "default", alice, AppRole::Viewer).await; r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); let err = r.json::()["error"] .as_str() .expect("error message") .to_string(); assert!(err.contains("implicit access"), "got: {err}"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn add_member_owner_target_returns_422(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let other_owner = seed_user(&s.pool, "owner2", "ow2-pw", InstanceRole::Owner).await; let r = add_member( &s.server, &owner_token, "default", other_owner, AppRole::Viewer, ) .await; r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn patch_member_promotes_role(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await; let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await; r.assert_status_ok(); assert_eq!(r.json::()["role"], "editor"); // Editor can now create a script (capability promotion observable // end-to-end, not just via the role string). let bob_token = login_token(&s.server, "bob", "bob-pw").await; create_script_via_api(&s.server, &bob_token, s.default_app, "bob-script").await; } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn patch_member_without_existing_returns_404(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; // No grant yet — PATCH must 404. let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await; r.assert_status(axum::http::StatusCode::NOT_FOUND); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn patch_member_same_role_is_idempotent(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await; let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Viewer).await; r.assert_status_ok(); assert_eq!(r.json::()["role"], "viewer"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn delete_member_removes_row(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await; let r = remove_member(&s.server, &owner_token, "default", bob).await; r.assert_status(axum::http::StatusCode::NO_CONTENT); let rows = list_members(&s.server, &owner_token, "default") .await .json::>(); assert!(rows.iter().all(|v| v["username"] != "bob")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn delete_member_missing_returns_204(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; // No grant ever happened — delete is idempotent. let r = remove_member(&s.server, &owner_token, "default", bob).await; r.assert_status(axum::http::StatusCode::NO_CONTENT); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn mutating_endpoints_require_app_admin(pool: PgPool) { let s = boot(pool).await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await; let bob_token = login_token(&s.server, "bob", "bob-pw").await; let target = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await; let r = add_member(&s.server, &bob_token, "default", target, AppRole::Viewer).await; r.assert_status(axum::http::StatusCode::FORBIDDEN); let r = patch_member_role(&s.server, &bob_token, "default", bob, AppRole::Editor).await; r.assert_status(axum::http::StatusCode::FORBIDDEN); let r = remove_member(&s.server, &bob_token, "default", bob).await; r.assert_status(axum::http::StatusCode::FORBIDDEN); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn members_endpoint_resolves_by_id_or_slug(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let by_slug = list_members(&s.server, &owner_token, "default").await; by_slug.assert_status_ok(); let by_id = list_members(&s.server, &owner_token, &s.default_app.to_string()).await; by_id.assert_status_ok(); assert_eq!( by_slug.json::(), by_id.json::(), "id and slug return identical bodies", ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn member_app_admin_can_manage_members(pool: PgPool) { let s = boot(pool).await; // Bob is a member with explicit app_admin role on default. let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; grant_membership(&s.pool, bob, s.default_app, AppRole::AppAdmin).await; let bob_token = login_token(&s.server, "bob", "bob-pw").await; // Bob can list members. let r = list_members(&s.server, &bob_token, "default").await; r.assert_status_ok(); // Bob can add carol as viewer. let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await; let r = add_member(&s.server, &bob_token, "default", carol, AppRole::Viewer).await; r.assert_status(axum::http::StatusCode::CREATED); // Bob can promote carol to editor. let r = patch_member_role(&s.server, &bob_token, "default", carol, AppRole::Editor).await; r.assert_status_ok(); // Bob can remove carol. let r = remove_member(&s.server, &bob_token, "default", carol).await; r.assert_status(axum::http::StatusCode::NO_CONTENT); // And bob can even remove himself — owner's implicit AppAdmin // means the app isn't orphaned. This is the load-bearing test for // the no-last-app-admin-guard decision. let r = remove_member(&s.server, &bob_token, "default", bob).await; r.assert_status(axum::http::StatusCode::NO_CONTENT); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn membership_makes_app_appear_in_members_app_list(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; let bob_token = login_token(&s.server, "bob", "bob-pw").await; // Before grant: bob sees no apps. let r = s .server .get("/api/v1/admin/apps") .add_header("authorization", format!("Bearer {bob_token}")) .await; r.assert_status_ok(); assert!( r.json::>().is_empty(), "bob has no memberships → empty apps list" ); // Grant via the public POST endpoint — exercises the full // round-trip the dashboard goes through, not just the repo seam. let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await; r.assert_status(axum::http::StatusCode::CREATED); // After grant: bob sees the default app. let r = s .server .get("/api/v1/admin/apps") .add_header("authorization", format!("Bearer {bob_token}")) .await; r.assert_status_ok(); let apps = r.json::>(); assert_eq!(apps.len(), 1); assert_eq!(apps[0]["slug"], "default"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn add_member_with_missing_user_id_is_rejected(pool: PgPool) { let s = boot(pool).await; let owner_token = login_token(&s.server, "owner", "owner-pw").await; // Body missing `user_id` — Axum's Json extractor produces a 4xx // before our handler runs. Pinning the status to keep the contract // honest if anyone ever swaps the extractor. let r = s .server .post("/api/v1/admin/apps/default/members") .add_header("authorization", format!("Bearer {owner_token}")) .json(&json!({ "role": "viewer" })) .await; let status = r.status_code().as_u16(); assert!( (400..500).contains(&status), "malformed body should produce a 4xx, got {status}" ); }