test(picloud): authz coverage for app members CRUD

Adds 16 integration tests against a real Postgres covering the new
/api/v1/admin/apps/{id_or_slug}/members surface:

- list / add / patch / remove against an explicit member row
- 409 on duplicate, 422 on inactive target, 422 on owner/admin target
- 404 on PATCH without an existing row; 204 idempotent DELETE
- viewer-as-bob receives 403 on every mutating verb
- both slug and UUID paths resolve to the same body
- bob-with-app_admin can manage the member list, including removing
  himself (load-bearing for the no-last-app-admin-guard decision)
- granting a `member` user a viewer membership makes the app appear
  in their `GET /admin/apps` list (was empty before)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 21:33:59 +02:00
parent 85bbabcbdf
commit 248571dcde

View File

@@ -160,6 +160,76 @@ async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_te
.await .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 // 1. Bootstrap admin → owner
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -700,3 +770,307 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
"member with viewer row reports 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::<Vec<Value>>();
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::<Value>();
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::<Vec<Value>>();
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::<Value>()["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::<Value>()["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::<Value>()["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::<Value>()["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::<Value>()["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::<Vec<Value>>();
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::<Value>(),
by_id.json::<Value>(),
"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::<Vec<Value>>().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::<Vec<Value>>();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0]["slug"], "default");
}