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:
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user