diff --git a/crates/picloud/tests/authz.rs b/crates/picloud/tests/authz.rs index 40ba321..da6fe14 100644 --- a/crates/picloud/tests/authz.rs +++ b/crates/picloud/tests/authz.rs @@ -160,6 +160,76 @@ async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_te .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 // ---------------------------------------------------------------------------- @@ -700,3 +770,307 @@ async fn my_role_field_matches_caller_role(pool: PgPool) { "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 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" + ); + + // After grant: bob sees the default app. + grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await; + 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"); +}