test(picloud): integration tests for Phase 3.5 authz (11 cases)
Covers the matrix laid out in the plan: * bootstrap admin lands as Owner * owner / admin / member access matrices on the default app * bearer pic_ key and cookie session resolve to the same Principal * read-only key cannot write (scope intersection) * bound key cannot escape its app * member listing isolation at SQL for /admin/apps + /admin/scripts * deactivating a user expires every API key for them * mint rejects bound key carrying instance:* scopes (422) * list_active_owners returns the right set for the startup warning Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1317,6 +1317,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-test",
|
||||
"chrono",
|
||||
"figment",
|
||||
"picloud-executor-core",
|
||||
"picloud-manager-core",
|
||||
@@ -1331,6 +1332,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -39,3 +39,5 @@ figment.workspace = true
|
||||
axum-test = "17"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
614
crates/picloud/tests/authz.rs
Normal file
614
crates/picloud/tests/authz.rs
Normal file
@@ -0,0 +1,614 @@
|
||||
//! 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)
|
||||
.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::<Value>()["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::<Value>()["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::<Value>()["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).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
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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_can_manage_users_but_not_app_admin_settings(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 editor everywhere).
|
||||
s.server
|
||||
.get("/api/v1/admin/apps/default")
|
||||
.add_header("authorization", format!("Bearer {token}"))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
// Allowed: write scripts (implicit editor).
|
||||
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
|
||||
assert!(script["id"].is_string());
|
||||
|
||||
// Denied: delete the default app (AppAdmin only).
|
||||
let denied = s
|
||||
.server
|
||||
.delete("/api/v1/admin/apps/default")
|
||||
.add_header("authorization", format!("Bearer {token}"))
|
||||
.await;
|
||||
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[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::<Value>()["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::<Value>()["id"], via_key.json::<Value>()["id"]);
|
||||
assert_eq!(
|
||||
via_session.json::<Value>()["username"],
|
||||
via_key.json::<Value>()["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::<Value>()["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::<Value>()["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::<Value>()["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::<Value>()["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<String> = 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<String> = 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::<Value>()["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)");
|
||||
}
|
||||
Reference in New Issue
Block a user