mod common;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use happyview::db::{adapt_sql, now_rfc3339};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use serial_test::serial;
use tower::ServiceExt;
use common::app::TestApp;
use common::fixtures;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async fn json_body(resp: axum::response::Response) -> Value {
let body = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&body).unwrap()
}
fn admin_get(
uri: &str,
cookie: (axum::http::HeaderName, axum::http::HeaderValue),
) -> Request
{
Request::builder()
.uri(uri)
.header(cookie.0, cookie.1)
.body(Body::empty())
.unwrap()
}
fn admin_post(
uri: &str,
cookie: (axum::http::HeaderName, axum::http::HeaderValue),
body: &Value,
) -> Request {
Request::builder()
.method("POST")
.uri(uri)
.header(cookie.0, cookie.1)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap()
}
fn admin_delete(
uri: &str,
cookie: (axum::http::HeaderName, axum::http::HeaderValue),
) -> Request {
Request::builder()
.method("DELETE")
.uri(uri)
.header(cookie.0, cookie.1)
.body(Body::empty())
.unwrap()
}
async fn response_json(resp: axum::http::Response) -> Value {
let body = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&body).unwrap_or(json!(null))
}
fn bearer_get(uri: &str, key: &str) -> Request {
Request::builder()
.uri(uri)
.header("authorization", format!("Bearer {key}"))
.body(Body::empty())
.unwrap()
}
/// A super admin's scoped API key must be limited to its stored permissions —
/// it must NOT inherit the owner's super privileges (H5).
#[tokio::test]
#[serial]
async fn super_user_api_key_is_bounded_by_its_permissions() {
common::require_db!();
let app = TestApp::new().await;
// The TestApp admin is a super user. Create a key scoped to stats:read only.
let create = app
.router
.clone()
.oneshot(admin_post(
"/admin/api-keys",
app.admin_cookie(),
&json!({ "name": "ci-monitor", "permissions": ["stats:read"] }),
))
.await
.unwrap();
assert_eq!(create.status(), StatusCode::CREATED);
let key = response_json(create).await["key"]
.as_str()
.expect("api key returned")
.to_string();
// It can reach the permission it was granted.
let allowed = app
.router
.clone()
.oneshot(bearer_get("/admin/stats", &key))
.await
.unwrap();
assert_eq!(allowed.status(), StatusCode::OK);
// It must NOT reach a permission it wasn't granted, even though its owner
// is super. Before the fix this returned 200 (full super via the key).
let denied = app
.router
.clone()
.oneshot(bearer_get("/admin/lexicons", &key))
.await
.unwrap();
assert_eq!(
denied.status(),
StatusCode::FORBIDDEN,
"a super user's scoped key must not grant permissions outside its list"
);
// And it must not reach super-only operations (user management).
let users = app
.router
.clone()
.oneshot(bearer_get("/admin/users", &key))
.await
.unwrap();
assert_ne!(users.status(), StatusCode::OK);
}
// ---------------------------------------------------------------------------
// Auth tests
// ---------------------------------------------------------------------------
#[tokio::test]
#[serial]
async fn admin_no_auth_returns_401() {
common::require_db!();
let app = TestApp::new().await;
let resp = app
.router
.clone()
.oneshot(
Request::builder()
.uri("/admin/lexicons")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
#[serial]
async fn admin_wrong_token_returns_401() {
common::require_db!();
let app = TestApp::new().await;
// No valid session cookie — the request will be rejected.
let resp = app
.router
.clone()
.oneshot(
Request::builder()
.uri("/admin/lexicons")
.header("cookie", "session=invalid-cookie-value")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
#[serial]
async fn admin_valid_token_returns_200() {
common::require_db!();
let app = TestApp::new().await;
let resp = app
.router
.clone()
.oneshot(admin_get("/admin/lexicons", app.admin_cookie()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
#[serial]
async fn admin_non_admin_did_returns_403() {
common::require_db!();
let app = TestApp::new().await;
// Use a DID that is NOT in the admins table.
let resp = app
.router
.clone()
.oneshot(admin_get(
"/admin/lexicons",
common::auth::admin_cookie_header("did:plc:notadmin", &app.state.cookie_key),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
#[serial]
async fn admin_auto_bootstrap_first_user() {
common::require_db!();
let app = TestApp::new().await;
let backend = app.state.db_backend;
// Clear the seeded user so the table is empty.
happyview::db::query("DELETE FROM happyview_users")
.execute(&app.state.db)
.await
.unwrap();
// Use a new DID via cookie auth.
let bootstrap_did = "did:plc:bootstrap";
let resp = app
.router
.clone()
.oneshot(admin_get(
"/admin/lexicons",
common::auth::admin_cookie_header(bootstrap_did, &app.state.cookie_key),
))
.await
.unwrap();
// The first user should be auto-bootstrapped as admin.
assert_eq!(resp.status(), StatusCode::OK);
// Verify the DID was inserted.
let sql = adapt_sql(
"SELECT COUNT(*) FROM happyview_users WHERE did = ?",
backend,
);
let count: (i64,) = happyview::db::query_as(&sql)
.bind(bootstrap_did)
.fetch_one(&app.state.db)
.await
.unwrap();
assert_eq!(count.0, 1);
}
// ---------------------------------------------------------------------------
// Lexicon CRUD
// ---------------------------------------------------------------------------
#[tokio::test]
#[serial]
async fn lexicon_create_returns_201() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({
"lexicon_json": fixtures::game_record_lexicon(),
"backfill": true
});
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = json_body(resp).await;
assert_eq!(json["id"], "games.gamesgamesgamesgames.game");
assert_eq!(json["revision"], 1);
}
#[tokio::test]
#[serial]
async fn lexicon_upsert_returns_200_with_incremented_revision() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({
"lexicon_json": fixtures::game_record_lexicon(),
"backfill": true
});
// First create
let resp = app
.router
.clone()
.clone()
.oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
// Upsert
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = json_body(resp).await;
assert_eq!(json["revision"], 2);
}
#[tokio::test]
#[serial]
async fn lexicon_invalid_version_returns_400() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({
"lexicon_json": { "lexicon": 99, "id": "test.bad" },
});
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
#[serial]
async fn lexicon_missing_id_returns_400() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({
"lexicon_json": { "lexicon": 1 },
});
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ---------------------------------------------------------------------------
// Lexicon-triggered backfill
// ---------------------------------------------------------------------------
/// Count backfill jobs for a collection.
async fn backfill_job_count(app: &TestApp, collection: &str) -> i64 {
let sql = adapt_sql(
"SELECT COUNT(*) FROM happyview_backfill_jobs WHERE collection = ?",
app.state.db_backend,
);
let row: (i64,) = happyview::db::query_as(&sql)
.bind(collection)
.fetch_one(&app.state.db)
.await
.expect("count backfill jobs");
row.0
}
#[tokio::test]
#[serial]
async fn record_lexicon_with_backfill_starts_a_backfill_job() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({
"lexicon_json": fixtures::game_record_lexicon(),
"backfill": true
});
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = json_body(resp).await;
let job_id = json["backfill_job_id"]
.as_str()
.expect("backfill_job_id in upload response");
let sql = adapt_sql(
"SELECT collection FROM happyview_backfill_jobs WHERE id = ?",
app.state.db_backend,
);
let row: Option<(Option,)> = happyview::db::query_as(&sql)
.bind(job_id)
.fetch_optional(&app.state.db)
.await
.expect("query backfill job");
assert_eq!(
row.expect("backfill job row exists").0.as_deref(),
Some("games.gamesgamesgamesgames.game"),
"the job must target the uploaded lexicon's collection"
);
}
#[tokio::test]
#[serial]
async fn re_uploading_a_lexicon_does_not_start_a_second_backfill_job() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({
"lexicon_json": fixtures::game_record_lexicon(),
"backfill": true
});
for _ in 0..2 {
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body))
.await
.unwrap();
assert!(resp.status().is_success());
}
assert_eq!(
backfill_job_count(&app, "games.gamesgamesgamesgames.game").await,
1,
"only the first upload (revision 1) may start a backfill"
);
}
#[tokio::test]
#[serial]
async fn record_lexicon_without_backfill_starts_no_job() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({
"lexicon_json": fixtures::game_record_lexicon(),
"backfill": false
});
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
assert!(json_body(resp).await["backfill_job_id"].is_null());
assert_eq!(
backfill_job_count(&app, "games.gamesgamesgamesgames.game").await,
0
);
}
#[tokio::test]
#[serial]
async fn query_lexicon_with_backfill_starts_no_job() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({
"lexicon_json": fixtures::list_games_query_lexicon(),
"backfill": true,
"target_collection": "games.gamesgamesgamesgames.game"
});
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
assert!(
json_body(resp).await["backfill_job_id"].is_null(),
"there is nothing to backfill for a non-record lexicon"
);
}
/// An uploader holding `lexicons:create` but not `backfill:create` gets the
/// lexicon, not a 403 — the backfill is skipped and reported as skipped.
#[tokio::test]
#[serial]
async fn lexicon_upload_without_backfill_permission_skips_the_job() {
common::require_db!();
let app = TestApp::new().await;
let create = app
.router
.clone()
.oneshot(admin_post(
"/admin/api-keys",
app.admin_cookie(),
&json!({ "name": "lexicon-only", "permissions": ["lexicons:create"] }),
))
.await
.unwrap();
assert_eq!(create.status(), StatusCode::CREATED);
let key = response_json(create).await["key"]
.as_str()
.expect("api key returned")
.to_string();
let resp = app
.router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/lexicons")
.header("authorization", format!("Bearer {key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"lexicon_json": fixtures::game_record_lexicon(),
"backfill": true
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"a missing backfill permission must not fail the upload"
);
assert!(json_body(resp).await["backfill_job_id"].is_null());
assert_eq!(
backfill_job_count(&app, "games.gamesgamesgamesgames.game").await,
0
);
}
#[tokio::test]
#[serial]
async fn lexicon_list_all() {
common::require_db!();
let app = TestApp::new().await;
// Seed a lexicon
app.router
.clone()
.clone()
.oneshot(admin_post(
"/admin/lexicons",
app.admin_cookie(),
&json!({ "lexicon_json": fixtures::game_record_lexicon() }),
))
.await
.unwrap();
let resp = app
.router
.clone()
.oneshot(admin_get("/admin/lexicons", app.admin_cookie()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = json_body(resp).await;
let arr = json.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], "games.gamesgamesgamesgames.game");
}
#[tokio::test]
#[serial]
async fn lexicon_get_by_id() {
common::require_db!();
let app = TestApp::new().await;
app.router
.clone()
.clone()
.oneshot(admin_post(
"/admin/lexicons",
app.admin_cookie(),
&json!({ "lexicon_json": fixtures::game_record_lexicon() }),
))
.await
.unwrap();
let resp = app
.router
.clone()
.oneshot(admin_get(
"/admin/lexicons/games.gamesgamesgamesgames.game",
app.admin_cookie(),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = json_body(resp).await;
assert_eq!(json["id"], "games.gamesgamesgamesgames.game");
}
#[tokio::test]
#[serial]
async fn lexicon_get_not_found() {
common::require_db!();
let app = TestApp::new().await;
let resp = app
.router
.clone()
.oneshot(admin_get(
"/admin/lexicons/nonexistent.lexicon",
app.admin_cookie(),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
#[serial]
async fn lexicon_delete() {
common::require_db!();
let app = TestApp::new().await;
app.router
.clone()
.clone()
.oneshot(admin_post(
"/admin/lexicons",
app.admin_cookie(),
&json!({ "lexicon_json": fixtures::game_record_lexicon() }),
))
.await
.unwrap();
let resp = app
.router
.clone()
.oneshot(admin_delete(
"/admin/lexicons/games.gamesgamesgamesgames.game",
app.admin_cookie(),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
#[serial]
async fn lexicon_delete_not_found() {
common::require_db!();
let app = TestApp::new().await;
let resp = app
.router
.clone()
.oneshot(admin_delete(
"/admin/lexicons/nonexistent.lexicon",
app.admin_cookie(),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
#[tokio::test]
#[serial]
async fn stats_empty_db() {
common::require_db!();
let app = TestApp::new().await;
let resp = app
.router
.clone()
.oneshot(admin_get("/admin/stats", app.admin_cookie()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = json_body(resp).await;
assert_eq!(json["total_records"], 0);
assert!(json["collections"].as_array().unwrap().is_empty());
}
#[tokio::test]
#[serial]
async fn stats_with_seeded_records() {
common::require_db!();
let app = TestApp::new().await;
let backend = app.state.db_backend;
// Seed a lexicon so the stats query can join against it
let lexicon_json_val = serde_json::json!({
"lexicon": 1,
"id": "test.collection",
"defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "properties": {} } } }
});
let now = now_rfc3339();
let sql = adapt_sql(
"INSERT INTO happyview_lexicons (id, lexicon_json, created_at) VALUES (?, ?, ?)",
backend,
);
happyview::db::query(&sql)
.bind("test.collection")
.bind(serde_json::to_string(&lexicon_json_val).unwrap_or_default())
.bind(&now)
.execute(&app.state.db)
.await
.unwrap();
// Seed records directly
let record_val = serde_json::json!({"title": "test"});
let sql = adapt_sql(
"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
backend,
);
happyview::db::query(&sql)
.bind("at://did:plc:test/test.collection/1")
.bind("did:plc:test")
.bind("test.collection")
.bind("1")
.bind(serde_json::to_string(&record_val).unwrap_or_default())
.bind("bafytest")
.bind(&now)
.execute(&app.state.db)
.await
.unwrap();
let resp = app
.router
.clone()
.oneshot(admin_get("/admin/stats", app.admin_cookie()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = json_body(resp).await;
assert_eq!(json["total_records"], 1);
assert_eq!(json["collections"][0]["collection"], "test.collection");
assert_eq!(json["collections"][0]["count"], 1);
}
// ---------------------------------------------------------------------------
// Backfill
// ---------------------------------------------------------------------------
#[tokio::test]
#[serial]
async fn backfill_create_job() {
common::require_db!();
let app = TestApp::new().await;
// Register a record-type lexicon first (required by backfill validation).
let lexicon_body = json!({
"lexicon_json": fixtures::game_record_lexicon(),
"backfill": true
});
app.router
.clone()
.clone()
.oneshot(admin_post(
"/admin/lexicons",
app.admin_cookie(),
&lexicon_body,
))
.await
.unwrap();
let body = json!({ "collection": "games.gamesgamesgamesgames.game" });
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/backfill", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = json_body(resp).await;
assert_eq!(json["status"], "running");
assert!(json.get("id").is_some());
}
#[tokio::test]
#[serial]
async fn backfill_list_jobs() {
common::require_db!();
let app = TestApp::new().await;
// Create a job first
app.router
.clone()
.clone()
.oneshot(admin_post(
"/admin/backfill",
app.admin_cookie(),
&json!({}),
))
.await
.unwrap();
let resp = app
.router
.clone()
.oneshot(admin_get("/admin/backfill/status", app.admin_cookie()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = json_body(resp).await;
assert_eq!(json.as_array().unwrap().len(), 1);
}
#[tokio::test]
#[serial]
async fn backfill_cancel_running_job() {
common::require_db!();
let app = TestApp::new().await;
let backend = app.state.db_backend;
// Insert a running job directly so we don't need a real relay.
let job_id = uuid::Uuid::new_v4().to_string();
let now = now_rfc3339();
let sql = adapt_sql(
"INSERT INTO happyview_backfill_jobs (id, status, stage, started_at, created_at) VALUES (?, 'running', 'discovering_repos', ?, ?)",
backend,
);
happyview::db::query(&sql)
.bind(&job_id)
.bind(&now)
.bind(&now)
.execute(&app.state.db)
.await
.unwrap();
let resp = app
.router
.clone()
.oneshot(admin_post(
&format!("/admin/backfill/{job_id}/cancel"),
app.admin_cookie(),
&json!({}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = json_body(resp).await;
assert_eq!(json["id"], job_id);
assert_eq!(json["status"], "cancelling");
}
#[tokio::test]
#[serial]
async fn backfill_cancel_already_cancelling_is_idempotent() {
common::require_db!();
let app = TestApp::new().await;
let backend = app.state.db_backend;
let job_id = uuid::Uuid::new_v4().to_string();
let now = now_rfc3339();
let sql = adapt_sql(
"INSERT INTO happyview_backfill_jobs (id, status, stage, started_at, created_at) VALUES (?, 'cancelling', 'fetching_records', ?, ?)",
backend,
);
happyview::db::query(&sql)
.bind(&job_id)
.bind(&now)
.bind(&now)
.execute(&app.state.db)
.await
.unwrap();
let resp = app
.router
.clone()
.oneshot(admin_post(
&format!("/admin/backfill/{job_id}/cancel"),
app.admin_cookie(),
&json!({}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = json_body(resp).await;
assert_eq!(json["status"], "cancelling");
}
#[tokio::test]
#[serial]
async fn backfill_cancel_completed_returns_400() {
common::require_db!();
let app = TestApp::new().await;
let backend = app.state.db_backend;
let job_id = uuid::Uuid::new_v4().to_string();
let now = now_rfc3339();
let sql = adapt_sql(
"INSERT INTO happyview_backfill_jobs (id, status, stage, completed_at, created_at) VALUES (?, 'completed', 'completed', ?, ?)",
backend,
);
happyview::db::query(&sql)
.bind(&job_id)
.bind(&now)
.bind(&now)
.execute(&app.state.db)
.await
.unwrap();
let resp = app
.router
.clone()
.oneshot(admin_post(
&format!("/admin/backfill/{job_id}/cancel"),
app.admin_cookie(),
&json!({}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
#[serial]
async fn backfill_cancel_not_found_returns_404() {
common::require_db!();
let app = TestApp::new().await;
let resp = app
.router
.clone()
.oneshot(admin_post(
"/admin/backfill/nonexistent-id/cancel",
app.admin_cookie(),
&json!({}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------------
// Admin management
// ---------------------------------------------------------------------------
#[tokio::test]
#[serial]
async fn admin_create_returns_did() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({ "did": "did:plc:newadmin" });
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/users", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = json_body(resp).await;
assert_eq!(json["did"], "did:plc:newadmin");
assert!(json.get("id").is_some());
}
#[tokio::test]
#[serial]
async fn admin_created_did_authenticates() {
common::require_db!();
let app = TestApp::new().await;
let new_did = "did:plc:newadmin2";
let body = json!({ "did": new_did });
// Create admin via the existing admin
app.router
.clone()
.clone()
.oneshot(admin_post("/admin/users", app.admin_cookie(), &body))
.await
.unwrap();
// Use cookie auth with the new DID
let resp = app
.router
.clone()
.oneshot(admin_get(
"/admin/lexicons",
common::auth::admin_cookie_header(new_did, &app.state.cookie_key),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
#[serial]
async fn admin_list_returns_dids() {
common::require_db!();
let app = TestApp::new().await;
let cookie = app.admin_cookie();
let resp = app
.router
.clone()
.oneshot(admin_get("/admin/users", cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = json_body(resp).await;
let admins = json.as_array().unwrap();
assert!(!admins.is_empty());
for admin in admins {
assert!(admin.get("did").is_some());
assert!(admin.get("api_key").is_none());
assert!(admin.get("api_key_hash").is_none());
}
}
#[tokio::test]
#[serial]
async fn admin_delete_returns_204() {
common::require_db!();
let app = TestApp::new().await;
// Create an admin to delete
let resp = app
.router
.clone()
.clone()
.oneshot(admin_post(
"/admin/users",
app.admin_cookie(),
&json!({ "did": "did:plc:disposable" }),
))
.await
.unwrap();
let json = json_body(resp).await;
let id = json["id"].as_str().unwrap();
let cookie = app.admin_cookie();
let resp = app
.router
.clone()
.oneshot(admin_delete(&format!("/admin/users/{id}"), cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
#[serial]
async fn admin_delete_not_found() {
common::require_db!();
let app = TestApp::new().await;
let cookie = app.admin_cookie();
let resp = app
.router
.clone()
.oneshot(admin_delete(
"/admin/users/00000000-0000-0000-0000-000000000000",
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
#[serial]
async fn delete_collection_enqueues_a_job() {
common::require_db!();
let app = TestApp::new().await;
let resp = app
.router
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/admin/records/collection?collection=app.test.post")
.header(app.admin_cookie().0, app.admin_cookie().1)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let body: serde_json::Value = {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
};
let job_id = body["job_id"].as_str().expect("job_id missing");
// These four columns are exactly what the reserved-prefix authorization
// boundary depends on: the literal job type, an input built only from the
// validated `collection` param (no other request-controlled key), no
// inherited PDS session for a local-table delete, and `created_by`
// sourced from the authenticated session rather than the request.
let sql = happyview::db::adapt_sql(
"SELECT job_type, input, created_by, CAST(inherit_auth AS INTEGER) FROM happyview_jobs WHERE id = ?",
app.state.db_backend,
);
let (job_type, input, created_by, inherit_auth): (String, String, String, i64) =
happyview::db::query_as(&sql)
.bind(job_id)
.fetch_one(&app.state.db)
.await
.expect("job row missing");
assert_eq!(job_type, "happyview.delete-collection");
assert_eq!(inherit_auth, 0, "must not inherit a PDS session");
assert_eq!(created_by, app.admin_did, "must come from the auth session");
let input_json: serde_json::Value = serde_json::from_str(&input).unwrap();
assert_eq!(
input_json,
serde_json::json!({ "collection": "app.test.post" }),
"input must carry exactly the validated collection and nothing else"
);
}
// ---------------------------------------------------------------------------
// Identifier resolution on user creation (issue #85)
// ---------------------------------------------------------------------------
/// A handle that cannot be resolved must be refused outright. Storing it
/// verbatim produces a row whose `did` column holds a handle, and the login
/// authorization check in `auth::routes` matches the OAuth session's DID
/// exactly — so such a user can never sign in, and the failure surfaces at
/// login rather than at the point of the mistake.
#[tokio::test]
#[serial]
async fn admin_create_rejects_unresolvable_handle() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({ "did": "nonexistent-handle.invalid" });
let resp = app
.router
.clone()
.oneshot(admin_post("/admin/users", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// And no row was created.
let count: (i64,) = happyview::db::query_as(&adapt_sql(
"SELECT COUNT(*) FROM happyview_users WHERE did = ?",
app.state.db_backend,
))
.bind("nonexistent-handle.invalid")
.fetch_one(&app.state.db)
.await
.unwrap();
assert_eq!(count.0, 0, "an unresolvable handle must not be stored");
}
/// Input that is neither a DID nor a syntactically valid handle is refused
/// before any network work is attempted.
#[tokio::test]
#[serial]
async fn admin_create_rejects_malformed_identifier() {
common::require_db!();
let app = TestApp::new().await;
for bad in ["not a handle", "", "@"] {
let resp = app
.router
.clone()
.oneshot(admin_post(
"/admin/users",
app.admin_cookie(),
&json!({ "did": bad }),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"expected {bad:?} to be refused"
);
}
}
/// Adding the same account twice reports a conflict rather than a 500 from the
/// UNIQUE constraint on `did`.
#[tokio::test]
#[serial]
async fn admin_create_duplicate_did_conflicts() {
common::require_db!();
let app = TestApp::new().await;
let body = json!({ "did": "did:plc:duplicate" });
let first = app
.router
.clone()
.oneshot(admin_post("/admin/users", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::CREATED);
let second = app
.router
.clone()
.oneshot(admin_post("/admin/users", app.admin_cookie(), &body))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::CONFLICT);
}