From 642f709bbed212f7bd9d5699d6e5b8dea2dfc97d Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 15:04:07 +0200 Subject: [PATCH] fix(api): drop redundant dev-deps; fix server AppState for cookie_secure; add logout + illegal-transition tests --- crates/api/Cargo.toml | 2 - crates/api/src/admin.rs | 2 + crates/api/tests/admin.rs | 96 ++++++++++++++++++++++++++++++++++++ crates/server/src/lib.rs | 2 + crates/server/tests/serve.rs | 1 + 5 files changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index a50774f..720d7bb 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -21,5 +21,3 @@ tokio.workspace = true tower.workspace = true http-body-util.workspace = true serde_json.workspace = true -sqlx.workspace = true -auth = { path = "../auth" } diff --git a/crates/api/src/admin.rs b/crates/api/src/admin.rs index 593fc60..3b55eb4 100644 --- a/crates/api/src/admin.rs +++ b/crates/api/src/admin.rs @@ -138,6 +138,8 @@ pub(crate) async fn set_visibility( Path(id): Path, Json(req): Json, ) -> Result { + // 404 (not 400) for an unparseable id — same non-leaking convention as the public + // surface: never reveal whether an id could exist. let object_id = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; let mut tx = state diff --git a/crates/api/tests/admin.rs b/crates/api/tests/admin.rs index 4bb918e..93efd75 100644 --- a/crates/api/tests/admin.rs +++ b/crates/api/tests/admin.rs @@ -238,3 +238,99 @@ async fn editor_can_publish_via_admin_endpoint(pool: PgPool) { let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); assert_eq!(obj.visibility, Visibility::Public); } + +#[sqlx::test(migrations = "../db/migrations")] +async fn logout_invalidates_the_session(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + seed_user(&pool, "admin@example.com", "s3cret-passw0rd", Role::Admin).await; + let app = build_app(state(pool)); + + let resp = app + .clone() + .oneshot(login_request("admin@example.com", "s3cret-passw0rd")) + .await + .unwrap(); + let cookie = session_cookie(&resp); + + // logout with the session cookie + let out = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/logout") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(out.status(), StatusCode::NO_CONTENT); + + // the old cookie no longer authenticates + let me = app + .oneshot( + Request::builder() + .uri("/api/admin/me") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(me.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn illegal_visibility_transition_is_409(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await; + + // a draft object — draft -> public in one step is illegal (must pass through internal) + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object( + &mut tx, + AuditActor::System, + &ObjectInput { + object_number: "D-1".into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility: Visibility::Draft, + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let app = build_app(state(pool)); + let resp = app + .clone() + .oneshot(login_request("editor@example.com", "pw-editor-123")) + .await + .unwrap(); + let cookie = session_cookie(&resp); + + let publish = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/admin/objects/{id}/visibility")) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"visibility":"public"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(publish.status(), StatusCode::CONFLICT); +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index cad5f8d..e5351f5 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -20,6 +20,8 @@ pub async fn run(config: Config) -> anyhow::Result<()> { let state = AppState { db, app_name: config.app_name.clone(), + // Wired to config in the auth CLI task; Secure by default. + cookie_secure: true, }; let listener = TcpListener::bind(&config.bind_addr) diff --git a/crates/server/tests/serve.rs b/crates/server/tests/serve.rs index cdad388..c0c49cd 100644 --- a/crates/server/tests/serve.rs +++ b/crates/server/tests/serve.rs @@ -15,6 +15,7 @@ async fn serves_health_live_over_tcp() { let state = AppState { db, app_name: "Test".to_string(), + cookie_secure: false, }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();