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, FieldType, LocalizedLabel, NewFieldDefinition, 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 { 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) -> String { resp.headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap() .split(';') .next() .unwrap() .to_owned() } async fn login(app: &axum::Router, email: &str, pw: &str) -> String { let resp = app.clone().oneshot(login_request(email, pw)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); session_cookie(&resp) } fn obj(number: &str, name: &str, v: Visibility) -> ObjectInput { ObjectInput { object_number: number.into(), object_name: name.into(), number_of_objects: 1, brief_description: Some("d".into()), current_location: Some("vault".into()), current_owner: None, recorder: None, recording_date: None, visibility: v, } } #[sqlx::test(migrations = "../db/migrations")] async fn list_and_get_require_auth(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) .await .unwrap(); let app = build_app(state(pool)); let list = app .clone() .oneshot( Request::builder() .uri("/api/admin/objects") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let get = app .oneshot( Request::builder() .uri(format!("/api/admin/objects/{}", domain::ObjectId::new())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(list.status(), StatusCode::UNAUTHORIZED); assert_eq!(get.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "../db/migrations")] async fn list_shows_all_visibility_levels(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) .await .unwrap(); seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; let db = db::Db::from_pool(pool.clone()); let mut tx = db.pool().begin().await.unwrap(); catalog::create_object( &mut tx, AuditActor::System, &obj("D-1", "draft", Visibility::Draft), ) .await .unwrap(); catalog::create_object( &mut tx, AuditActor::System, &obj("P-1", "pub", Visibility::Public), ) .await .unwrap(); tx.commit().await.unwrap(); let app = build_app(state(pool)); let cookie = login(&app, "ed@example.com", "pw-editor-123").await; let resp = app .oneshot( Request::builder() .uri("/api/admin/objects") .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json: serde_json::Value = serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap(); assert_eq!(json["total"], 2); let items = json["items"].as_array().unwrap(); assert!(items.iter().any(|i| i["object_number"] == "D-1")); assert!(items[0].get("current_location").is_some()); } #[sqlx::test(migrations = "../db/migrations")] async fn get_by_id_returns_full_view(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) .await .unwrap(); seed_user(&pool, "ed@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, &obj("D-1", "draft", Visibility::Draft), ) .await .unwrap(); tx.commit().await.unwrap(); let app = build_app(state(pool)); let cookie = login(&app, "ed@example.com", "pw-editor-123").await; let resp = app .clone() .oneshot( Request::builder() .uri(format!("/api/admin/objects/{id}")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json: serde_json::Value = serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap(); assert_eq!(json["object_number"], "D-1"); assert_eq!(json["visibility"], "draft"); let missing = app .oneshot( Request::builder() .uri(format!("/api/admin/objects/{}", domain::ObjectId::new())) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(missing.status(), StatusCode::NOT_FOUND); } #[sqlx::test(migrations = "../db/migrations")] async fn create_update_delete_lifecycle(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) .await .unwrap(); seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; let app = build_app(state(pool.clone())); let cookie = login(&app, "ed@example.com", "pw-editor-123").await; // create (internal allowed) let create = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/admin/objects") .header(header::COOKIE, &cookie) .header(header::CONTENT_TYPE, "application/json") .body(Body::from( r#"{"object_number":"A-1","object_name":"amphora","number_of_objects":1,"visibility":"internal"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(create.status(), StatusCode::CREATED); let created: serde_json::Value = serde_json::from_slice(&create.into_body().collect().await.unwrap().to_bytes()).unwrap(); let id = created["id"].as_str().unwrap().to_owned(); // update (name change; visibility omitted and unchanged) let update = app .clone() .oneshot( Request::builder() .method("PUT") .uri(format!("/api/admin/objects/{id}")) .header(header::COOKIE, &cookie) .header(header::CONTENT_TYPE, "application/json") .body(Body::from( r#"{"object_number":"A-1","object_name":"big amphora","number_of_objects":2}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(update.status(), StatusCode::NO_CONTENT); let db = db::Db::from_pool(pool.clone()); let obj = catalog::object_by_id(db.pool(), id.parse().unwrap()) .await .unwrap() .unwrap(); assert_eq!(obj.object_name, "big amphora"); assert_eq!(obj.visibility, Visibility::Internal); // unchanged by update // delete let del = app .clone() .oneshot( Request::builder() .method("DELETE") .uri(format!("/api/admin/objects/{id}")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(del.status(), StatusCode::NO_CONTENT); assert!( catalog::object_by_id(db.pool(), id.parse().unwrap()) .await .unwrap() .is_none() ); } #[sqlx::test(migrations = "../db/migrations")] async fn create_rejects_public_visibility(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) .await .unwrap(); seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; let app = build_app(state(pool)); let cookie = login(&app, "ed@example.com", "pw-editor-123").await; let resp = app .oneshot( Request::builder() .method("POST") .uri("/api/admin/objects") .header(header::COOKIE, &cookie) .header(header::CONTENT_TYPE, "application/json") .body(Body::from( r#"{"object_number":"A-1","object_name":"x","number_of_objects":1,"visibility":"public"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } #[sqlx::test(migrations = "../db/migrations")] async fn set_fields_and_list_field_definitions(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) .await .unwrap(); seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; let db = db::Db::from_pool(pool.clone()); let mut tx = db.pool().begin().await.unwrap(); db::fields::create_field_definition( &mut tx, &NewFieldDefinition { key: "inscription".into(), field_type: FieldType::Text, required: false, group_key: None, labels: vec![LocalizedLabel { lang: "en".into(), label: "Inscription".into(), }], }, ) .await .unwrap(); let id = catalog::create_object( &mut tx, AuditActor::System, &obj("A-1", "amphora", Visibility::Draft), ) .await .unwrap(); tx.commit().await.unwrap(); let app = build_app(state(pool.clone())); let cookie = login(&app, "ed@example.com", "pw-editor-123").await; // field-definitions list let defs = app .clone() .oneshot( Request::builder() .uri("/api/admin/field-definitions") .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(defs.status(), StatusCode::OK); let defs_json: serde_json::Value = serde_json::from_slice(&defs.into_body().collect().await.unwrap().to_bytes()).unwrap(); assert!( defs_json .as_array() .unwrap() .iter() .any(|d| d["key"] == "inscription" && d["data_type"] == "text") ); // set the field let set = app .clone() .oneshot( Request::builder() .method("PUT") .uri(format!("/api/admin/objects/{id}/fields")) .header(header::COOKIE, &cookie) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"inscription":"To the gods"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(set.status(), StatusCode::NO_CONTENT); let stored = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); assert_eq!(stored.fields["inscription"], "To the gods"); // unknown field → 422 let bad = app .oneshot( Request::builder() .method("PUT") .uri(format!("/api/admin/objects/{id}/fields")) .header(header::COOKIE, &cookie) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"nope":"x"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(bad.status(), StatusCode::UNPROCESSABLE_ENTITY); } #[sqlx::test(migrations = "../db/migrations")] async fn create_requires_auth(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) .await .unwrap(); let app = build_app(state(pool)); let resp = app .oneshot( Request::builder() .method("POST") .uri("/api/admin/objects") .header(header::CONTENT_TYPE, "application/json") .body(Body::from( r#"{"object_number":"A-1","object_name":"x","number_of_objects":1,"visibility":"draft"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "../db/migrations")] async fn field_endpoints_require_auth(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) .await .unwrap(); let app = build_app(state(pool)); let defs = app .clone() .oneshot( Request::builder() .uri("/api/admin/field-definitions") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let set = app .oneshot( Request::builder() .method("PUT") .uri(format!( "/api/admin/objects/{}/fields", domain::ObjectId::new() )) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"k":"v"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(defs.status(), StatusCode::UNAUTHORIZED); assert_eq!(set.status(), StatusCode::UNAUTHORIZED); }