Files
biggus-dickus/crates/api/tests/admin.rs
T
logaritmisk d15afda9b2 feat(api): on-write search reindex after catalogue writes (#17)
Wire best-effort Meilisearch index sync into the admin write paths
(create/update/delete/set_fields/set_visibility). Adds
SearchClient::sync_object (reindex if the object exists, remove if gone —
one uniform path), an optional AppState.search client, and a reindex
helper that logs failures via tracing without failing the committed
write. Server gains MEILI_URL/MEILI_MASTER_KEY/MEILI_INDEX config;
search stays disabled (no-op) when unset. reindex_all remains the
recovery path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:25:43 +02:00

338 lines
9.5 KiB
Rust

use api::{AppState, build_app, migrate_sessions};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use db::{catalog, users};
use domain::{AuditActor, Email, NewUser, ObjectInput, Role, Visibility};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;
fn state(pool: PgPool) -> AppState {
AppState {
db: db::Db::from_pool(pool),
app_name: "Test".into(),
cookie_secure: false,
search: None,
}
}
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
let db = db::Db::from_pool(pool.clone());
let mut tx = db.pool().begin().await.unwrap();
users::create_user(
&mut tx,
AuditActor::System,
&NewUser {
email: Email::parse(email).unwrap(),
password_hash: auth::hash_password(password).unwrap(),
role,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
}
fn login_request(email: &str, password: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/api/admin/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(
r#"{{"email":"{email}","password":"{password}"}}"#
)))
.unwrap()
}
fn session_cookie(resp: &axum::http::Response<Body>) -> String {
let raw = resp
.headers()
.get(header::SET_COOKIE)
.expect("Set-Cookie")
.to_str()
.unwrap();
raw.split(';').next().unwrap().to_owned()
}
#[sqlx::test(migrations = "../db/migrations")]
async fn login_then_me_returns_identity(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();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let cookie = session_cookie(&resp);
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::OK);
let json: serde_json::Value =
serde_json::from_slice(&me.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(json["email"], "admin@example.com");
assert_eq!(json["role"], "admin");
}
#[sqlx::test(migrations = "../db/migrations")]
async fn me_without_session_is_401(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
let app = build_app(state(pool));
let resp = app
.oneshot(
Request::builder()
.uri("/api/admin/me")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn wrong_password_is_401(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "admin@example.com", "right", Role::Admin).await;
let app = build_app(state(pool));
let resp = app
.oneshot(login_request("admin@example.com", "wrong"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn editor_cannot_list_users_but_admin_can(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
seed_user(&pool, "admin@example.com", "pw-admin-123", Role::Admin).await;
let app = build_app(state(pool));
let resp = app
.clone()
.oneshot(login_request("editor@example.com", "pw-editor-123"))
.await
.unwrap();
let editor_cookie = session_cookie(&resp);
let listed = app
.clone()
.oneshot(
Request::builder()
.uri("/api/admin/users")
.header(header::COOKIE, &editor_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(listed.status(), StatusCode::FORBIDDEN);
let resp = app
.clone()
.oneshot(login_request("admin@example.com", "pw-admin-123"))
.await
.unwrap();
let admin_cookie = session_cookie(&resp);
let listed = app
.oneshot(
Request::builder()
.uri("/api/admin/users")
.header(header::COOKIE, &admin_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(listed.status(), StatusCode::OK);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn editor_can_publish_via_admin_endpoint(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
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: "P-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::Internal,
},
)
.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::NO_CONTENT);
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);
}