A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: add e2e tests for health, admin, and XRPC routes

Trezy c9c57d69 c0fe3da2

+1239
+7
Cargo.lock
··· 728 728 "tower-http", 729 729 "tracing", 730 730 "tracing-subscriber", 731 + "urlencoding", 731 732 "uuid", 732 733 "wiremock", 733 734 ] ··· 2747 2748 "percent-encoding", 2748 2749 "serde", 2749 2750 ] 2751 + 2752 + [[package]] 2753 + name = "urlencoding" 2754 + version = "2.1.3" 2755 + source = "registry+https://github.com/rust-lang/crates.io-index" 2756 + checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 2750 2757 2751 2758 [[package]] 2752 2759 name = "utf-8"
+1
Cargo.toml
··· 30 30 tower = { version = "0.5", features = ["util"] } 31 31 http-body-util = "0.1" 32 32 serial_test = "3" 33 + urlencoding = "2.1.3"
+9
docker-compose.test.yml
··· 1 + services: 2 + postgres: 3 + image: postgres:17 4 + environment: 5 + POSTGRES_USER: happyview 6 + POSTGRES_PASSWORD: happyview 7 + POSTGRES_DB: happyview_test 8 + ports: 9 + - "5433:5432"
+68
tests/common/app.rs
··· 1 + use axum::Router; 2 + use happyview::config::Config; 3 + use happyview::lexicon::LexiconRegistry; 4 + use happyview::{admin, server, AppState}; 5 + use tokio::sync::watch; 6 + use wiremock::MockServer; 7 + 8 + use crate::common::db; 9 + 10 + pub struct TestApp { 11 + pub router: Router, 12 + pub state: AppState, 13 + pub mock_server: MockServer, 14 + pub admin_secret: String, 15 + } 16 + 17 + impl TestApp { 18 + /// Build a fully wired TestApp with a real Postgres database and wiremock 19 + /// for external services (AIP, relay, PLC directory). 20 + pub async fn new() -> Self { 21 + let pool = db::test_pool().await; 22 + db::truncate_all(&pool).await; 23 + 24 + let mock_server = MockServer::start().await; 25 + let mock_url = mock_server.uri(); 26 + 27 + let admin_secret = "test-admin-secret".to_string(); 28 + 29 + let config = Config { 30 + host: "127.0.0.1".into(), 31 + port: 0, 32 + database_url: String::new(), // not used — pool is already connected 33 + aip_url: mock_url.clone(), 34 + jetstream_url: String::new(), 35 + admin_secret: Some(admin_secret.clone()), 36 + relay_url: mock_url.clone(), 37 + plc_url: mock_url.clone(), 38 + }; 39 + 40 + admin::bootstrap(&pool, &config.admin_secret).await; 41 + 42 + let lexicons = LexiconRegistry::new(); 43 + lexicons 44 + .load_from_db(&pool) 45 + .await 46 + .expect("failed to load lexicons"); 47 + 48 + let initial_collections = lexicons.get_record_collections().await; 49 + let (collections_tx, _collections_rx) = watch::channel(initial_collections); 50 + 51 + let state = AppState { 52 + config, 53 + http: reqwest::Client::new(), 54 + db: pool, 55 + lexicons, 56 + collections_tx, 57 + }; 58 + 59 + let router = server::router(state.clone()); 60 + 61 + Self { 62 + router, 63 + state, 64 + mock_server, 65 + admin_secret, 66 + } 67 + } 68 + }
+25
tests/common/auth.rs
··· 1 + use axum::http::{HeaderName, HeaderValue}; 2 + use wiremock::matchers::{header, method, path}; 3 + use wiremock::{Mock, MockServer, ResponseTemplate}; 4 + 5 + use crate::common::fixtures; 6 + 7 + /// Build an Authorization header for admin endpoints. 8 + pub fn admin_auth_header(token: &str) -> (HeaderName, HeaderValue) { 9 + ( 10 + HeaderName::from_static("authorization"), 11 + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), 12 + ) 13 + } 14 + 15 + /// Mount a mock on the given server that responds to AIP userinfo requests 16 + /// with a successful response containing the given DID. 17 + pub async fn mock_aip_userinfo(mock_server: &MockServer, did: &str) { 18 + Mock::given(method("GET")) 19 + .and(path("/oauth/userinfo")) 20 + .respond_with( 21 + ResponseTemplate::new(200).set_body_json(fixtures::userinfo_response(did)), 22 + ) 23 + .mount(mock_server) 24 + .await; 25 + }
+26
tests/common/db.rs
··· 1 + use sqlx::PgPool; 2 + 3 + /// Connect to the test database using `TEST_DATABASE_URL`. 4 + pub async fn test_pool() -> PgPool { 5 + let url = std::env::var("TEST_DATABASE_URL") 6 + .expect("TEST_DATABASE_URL must be set for e2e tests"); 7 + 8 + let pool = PgPool::connect(&url) 9 + .await 10 + .expect("failed to connect to test database"); 11 + 12 + sqlx::migrate!() 13 + .run(&pool) 14 + .await 15 + .expect("failed to run migrations on test database"); 16 + 17 + pool 18 + } 19 + 20 + /// Truncate all application tables, preserving schema. 21 + pub async fn truncate_all(pool: &PgPool) { 22 + sqlx::query("TRUNCATE records, lexicons, backfill_jobs, admins RESTART IDENTITY CASCADE") 23 + .execute(pool) 24 + .await 25 + .expect("failed to truncate tables"); 26 + }
+92
tests/common/fixtures.rs
··· 1 + use serde_json::{json, Value}; 2 + 3 + /// A minimal record-type lexicon JSON for testing. 4 + pub fn game_record_lexicon() -> Value { 5 + json!({ 6 + "lexicon": 1, 7 + "id": "games.gamesgamesgamesgames.game", 8 + "defs": { 9 + "main": { 10 + "type": "record", 11 + "key": "tid", 12 + "record": { 13 + "type": "object", 14 + "properties": { 15 + "title": { "type": "string" } 16 + } 17 + } 18 + } 19 + } 20 + }) 21 + } 22 + 23 + /// A query-type lexicon JSON that targets the game record collection. 24 + pub fn list_games_query_lexicon() -> Value { 25 + json!({ 26 + "lexicon": 1, 27 + "id": "games.gamesgamesgamesgames.listGames", 28 + "defs": { 29 + "main": { 30 + "type": "query", 31 + "parameters": { 32 + "type": "params", 33 + "properties": { 34 + "limit": { "type": "integer" } 35 + } 36 + }, 37 + "output": { 38 + "encoding": "application/json" 39 + } 40 + } 41 + } 42 + }) 43 + } 44 + 45 + /// A procedure-type lexicon JSON that targets the game record collection. 46 + pub fn create_game_procedure_lexicon() -> Value { 47 + json!({ 48 + "lexicon": 1, 49 + "id": "games.gamesgamesgamesgames.createGame", 50 + "defs": { 51 + "main": { 52 + "type": "procedure", 53 + "input": { 54 + "encoding": "application/json" 55 + }, 56 + "output": { 57 + "encoding": "application/json" 58 + } 59 + } 60 + } 61 + }) 62 + } 63 + 64 + /// A fake DID document for testing PLC directory resolution. 65 + pub fn did_document(did: &str, pds_endpoint: &str) -> Value { 66 + json!({ 67 + "id": did, 68 + "alsoKnownAs": [format!("at://test.handle")], 69 + "service": [{ 70 + "id": "#atproto_pds", 71 + "type": "AtprotoPersonalDataServer", 72 + "serviceEndpoint": pds_endpoint 73 + }] 74 + }) 75 + } 76 + 77 + /// A fake app.bsky.actor.profile getRecord response. 78 + pub fn profile_record() -> Value { 79 + json!({ 80 + "uri": "at://did:plc:test/app.bsky.actor.profile/self", 81 + "cid": "bafytest", 82 + "value": { 83 + "displayName": "Test User", 84 + "description": "A test user" 85 + } 86 + }) 87 + } 88 + 89 + /// A fake AIP userinfo response. 90 + pub fn userinfo_response(did: &str) -> Value { 91 + json!({ "sub": did }) 92 + }
+8
tests/common/mod.rs
··· 1 + #[allow(dead_code, unused_imports)] 2 + pub mod app; 3 + #[allow(dead_code, unused_imports)] 4 + pub mod auth; 5 + #[allow(dead_code, unused_imports)] 6 + pub mod db; 7 + #[allow(dead_code, unused_imports)] 8 + pub mod fixtures;
+530
tests/e2e_admin.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Request, StatusCode}; 5 + use http_body_util::BodyExt; 6 + use serde_json::{json, Value}; 7 + use serial_test::serial; 8 + use tower::ServiceExt; 9 + 10 + use common::app::TestApp; 11 + use common::auth::admin_auth_header; 12 + use common::fixtures; 13 + 14 + // --------------------------------------------------------------------------- 15 + // Helpers 16 + // --------------------------------------------------------------------------- 17 + 18 + async fn json_body(resp: axum::response::Response) -> Value { 19 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 20 + serde_json::from_slice(&body).unwrap() 21 + } 22 + 23 + fn admin_get(uri: &str, token: &str) -> Request<Body> { 24 + let (hname, hval) = admin_auth_header(token); 25 + Request::builder() 26 + .uri(uri) 27 + .header(hname, hval) 28 + .body(Body::empty()) 29 + .unwrap() 30 + } 31 + 32 + fn admin_post(uri: &str, token: &str, body: &Value) -> Request<Body> { 33 + let (hname, hval) = admin_auth_header(token); 34 + Request::builder() 35 + .method("POST") 36 + .uri(uri) 37 + .header(hname, hval) 38 + .header("content-type", "application/json") 39 + .body(Body::from(serde_json::to_vec(body).unwrap())) 40 + .unwrap() 41 + } 42 + 43 + fn admin_delete(uri: &str, token: &str) -> Request<Body> { 44 + let (hname, hval) = admin_auth_header(token); 45 + Request::builder() 46 + .method("DELETE") 47 + .uri(uri) 48 + .header(hname, hval) 49 + .body(Body::empty()) 50 + .unwrap() 51 + } 52 + 53 + // --------------------------------------------------------------------------- 54 + // Auth tests 55 + // --------------------------------------------------------------------------- 56 + 57 + #[tokio::test] 58 + #[serial] 59 + async fn admin_no_auth_returns_401() { 60 + let app = TestApp::new().await; 61 + 62 + let resp = app 63 + .router 64 + .oneshot( 65 + Request::builder() 66 + .uri("/admin/lexicons") 67 + .body(Body::empty()) 68 + .unwrap(), 69 + ) 70 + .await 71 + .unwrap(); 72 + 73 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 74 + } 75 + 76 + #[tokio::test] 77 + #[serial] 78 + async fn admin_wrong_token_returns_401() { 79 + let app = TestApp::new().await; 80 + 81 + let resp = app 82 + .router 83 + .oneshot(admin_get("/admin/lexicons", "wrong-token")) 84 + .await 85 + .unwrap(); 86 + 87 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 88 + } 89 + 90 + #[tokio::test] 91 + #[serial] 92 + async fn admin_valid_token_returns_200() { 93 + let app = TestApp::new().await; 94 + 95 + let resp = app 96 + .router 97 + .oneshot(admin_get("/admin/lexicons", &app.admin_secret)) 98 + .await 99 + .unwrap(); 100 + 101 + assert_eq!(resp.status(), StatusCode::OK); 102 + } 103 + 104 + // --------------------------------------------------------------------------- 105 + // Lexicon CRUD 106 + // --------------------------------------------------------------------------- 107 + 108 + #[tokio::test] 109 + #[serial] 110 + async fn lexicon_create_returns_201() { 111 + let app = TestApp::new().await; 112 + let body = json!({ 113 + "lexicon_json": fixtures::game_record_lexicon(), 114 + "backfill": true 115 + }); 116 + 117 + let resp = app 118 + .router 119 + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) 120 + .await 121 + .unwrap(); 122 + 123 + assert_eq!(resp.status(), StatusCode::CREATED); 124 + let json = json_body(resp).await; 125 + assert_eq!(json["id"], "games.gamesgamesgamesgames.game"); 126 + assert_eq!(json["revision"], 1); 127 + } 128 + 129 + #[tokio::test] 130 + #[serial] 131 + async fn lexicon_upsert_returns_200_with_incremented_revision() { 132 + let app = TestApp::new().await; 133 + let body = json!({ 134 + "lexicon_json": fixtures::game_record_lexicon(), 135 + "backfill": true 136 + }); 137 + 138 + // First create 139 + let resp = app 140 + .router 141 + .clone() 142 + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) 143 + .await 144 + .unwrap(); 145 + assert_eq!(resp.status(), StatusCode::CREATED); 146 + 147 + // Upsert 148 + let resp = app 149 + .router 150 + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) 151 + .await 152 + .unwrap(); 153 + assert_eq!(resp.status(), StatusCode::OK); 154 + let json = json_body(resp).await; 155 + assert_eq!(json["revision"], 2); 156 + } 157 + 158 + #[tokio::test] 159 + #[serial] 160 + async fn lexicon_invalid_version_returns_400() { 161 + let app = TestApp::new().await; 162 + let body = json!({ 163 + "lexicon_json": { "lexicon": 99, "id": "test.bad" }, 164 + }); 165 + 166 + let resp = app 167 + .router 168 + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) 169 + .await 170 + .unwrap(); 171 + 172 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 173 + } 174 + 175 + #[tokio::test] 176 + #[serial] 177 + async fn lexicon_missing_id_returns_400() { 178 + let app = TestApp::new().await; 179 + let body = json!({ 180 + "lexicon_json": { "lexicon": 1 }, 181 + }); 182 + 183 + let resp = app 184 + .router 185 + .oneshot(admin_post("/admin/lexicons", &app.admin_secret, &body)) 186 + .await 187 + .unwrap(); 188 + 189 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 190 + } 191 + 192 + #[tokio::test] 193 + #[serial] 194 + async fn lexicon_list_all() { 195 + let app = TestApp::new().await; 196 + 197 + // Seed a lexicon 198 + app.router 199 + .clone() 200 + .oneshot(admin_post( 201 + "/admin/lexicons", 202 + &app.admin_secret, 203 + &json!({ "lexicon_json": fixtures::game_record_lexicon() }), 204 + )) 205 + .await 206 + .unwrap(); 207 + 208 + let resp = app 209 + .router 210 + .oneshot(admin_get("/admin/lexicons", &app.admin_secret)) 211 + .await 212 + .unwrap(); 213 + 214 + assert_eq!(resp.status(), StatusCode::OK); 215 + let json = json_body(resp).await; 216 + let arr = json.as_array().unwrap(); 217 + assert_eq!(arr.len(), 1); 218 + assert_eq!(arr[0]["id"], "games.gamesgamesgamesgames.game"); 219 + } 220 + 221 + #[tokio::test] 222 + #[serial] 223 + async fn lexicon_get_by_id() { 224 + let app = TestApp::new().await; 225 + 226 + app.router 227 + .clone() 228 + .oneshot(admin_post( 229 + "/admin/lexicons", 230 + &app.admin_secret, 231 + &json!({ "lexicon_json": fixtures::game_record_lexicon() }), 232 + )) 233 + .await 234 + .unwrap(); 235 + 236 + let resp = app 237 + .router 238 + .oneshot(admin_get( 239 + "/admin/lexicons/games.gamesgamesgamesgames.game", 240 + &app.admin_secret, 241 + )) 242 + .await 243 + .unwrap(); 244 + 245 + assert_eq!(resp.status(), StatusCode::OK); 246 + let json = json_body(resp).await; 247 + assert_eq!(json["id"], "games.gamesgamesgamesgames.game"); 248 + } 249 + 250 + #[tokio::test] 251 + #[serial] 252 + async fn lexicon_get_not_found() { 253 + let app = TestApp::new().await; 254 + 255 + let resp = app 256 + .router 257 + .oneshot(admin_get( 258 + "/admin/lexicons/nonexistent.lexicon", 259 + &app.admin_secret, 260 + )) 261 + .await 262 + .unwrap(); 263 + 264 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 265 + } 266 + 267 + #[tokio::test] 268 + #[serial] 269 + async fn lexicon_delete() { 270 + let app = TestApp::new().await; 271 + 272 + app.router 273 + .clone() 274 + .oneshot(admin_post( 275 + "/admin/lexicons", 276 + &app.admin_secret, 277 + &json!({ "lexicon_json": fixtures::game_record_lexicon() }), 278 + )) 279 + .await 280 + .unwrap(); 281 + 282 + let resp = app 283 + .router 284 + .oneshot(admin_delete( 285 + "/admin/lexicons/games.gamesgamesgamesgames.game", 286 + &app.admin_secret, 287 + )) 288 + .await 289 + .unwrap(); 290 + 291 + assert_eq!(resp.status(), StatusCode::NO_CONTENT); 292 + } 293 + 294 + #[tokio::test] 295 + #[serial] 296 + async fn lexicon_delete_not_found() { 297 + let app = TestApp::new().await; 298 + 299 + let resp = app 300 + .router 301 + .oneshot(admin_delete( 302 + "/admin/lexicons/nonexistent.lexicon", 303 + &app.admin_secret, 304 + )) 305 + .await 306 + .unwrap(); 307 + 308 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 309 + } 310 + 311 + // --------------------------------------------------------------------------- 312 + // Stats 313 + // --------------------------------------------------------------------------- 314 + 315 + #[tokio::test] 316 + #[serial] 317 + async fn stats_empty_db() { 318 + let app = TestApp::new().await; 319 + 320 + let resp = app 321 + .router 322 + .oneshot(admin_get("/admin/stats", &app.admin_secret)) 323 + .await 324 + .unwrap(); 325 + 326 + assert_eq!(resp.status(), StatusCode::OK); 327 + let json = json_body(resp).await; 328 + assert_eq!(json["total_records"], 0); 329 + assert!(json["collections"].as_array().unwrap().is_empty()); 330 + } 331 + 332 + #[tokio::test] 333 + #[serial] 334 + async fn stats_with_seeded_records() { 335 + let app = TestApp::new().await; 336 + 337 + // Seed records directly 338 + sqlx::query( 339 + "INSERT INTO records (uri, did, collection, rkey, record, cid) VALUES ($1, $2, $3, $4, $5, $6)", 340 + ) 341 + .bind("at://did:plc:test/test.collection/1") 342 + .bind("did:plc:test") 343 + .bind("test.collection") 344 + .bind("1") 345 + .bind(serde_json::json!({"title": "test"})) 346 + .bind("bafytest") 347 + .execute(&app.state.db) 348 + .await 349 + .unwrap(); 350 + 351 + let resp = app 352 + .router 353 + .oneshot(admin_get("/admin/stats", &app.admin_secret)) 354 + .await 355 + .unwrap(); 356 + 357 + assert_eq!(resp.status(), StatusCode::OK); 358 + let json = json_body(resp).await; 359 + assert_eq!(json["total_records"], 1); 360 + assert_eq!(json["collections"][0]["collection"], "test.collection"); 361 + assert_eq!(json["collections"][0]["count"], 1); 362 + } 363 + 364 + // --------------------------------------------------------------------------- 365 + // Backfill 366 + // --------------------------------------------------------------------------- 367 + 368 + #[tokio::test] 369 + #[serial] 370 + async fn backfill_create_job() { 371 + let app = TestApp::new().await; 372 + let body = json!({ "collection": "test.collection" }); 373 + 374 + let resp = app 375 + .router 376 + .oneshot(admin_post("/admin/backfill", &app.admin_secret, &body)) 377 + .await 378 + .unwrap(); 379 + 380 + assert_eq!(resp.status(), StatusCode::CREATED); 381 + let json = json_body(resp).await; 382 + assert_eq!(json["status"], "pending"); 383 + assert!(json.get("id").is_some()); 384 + } 385 + 386 + #[tokio::test] 387 + #[serial] 388 + async fn backfill_list_jobs() { 389 + let app = TestApp::new().await; 390 + 391 + // Create a job first 392 + app.router 393 + .clone() 394 + .oneshot(admin_post( 395 + "/admin/backfill", 396 + &app.admin_secret, 397 + &json!({}), 398 + )) 399 + .await 400 + .unwrap(); 401 + 402 + let resp = app 403 + .router 404 + .oneshot(admin_get("/admin/backfill/status", &app.admin_secret)) 405 + .await 406 + .unwrap(); 407 + 408 + assert_eq!(resp.status(), StatusCode::OK); 409 + let json = json_body(resp).await; 410 + assert_eq!(json.as_array().unwrap().len(), 1); 411 + } 412 + 413 + // --------------------------------------------------------------------------- 414 + // Admin management 415 + // --------------------------------------------------------------------------- 416 + 417 + #[tokio::test] 418 + #[serial] 419 + async fn admin_create_returns_api_key() { 420 + let app = TestApp::new().await; 421 + let body = json!({ "name": "test-admin" }); 422 + 423 + let resp = app 424 + .router 425 + .oneshot(admin_post("/admin/admins", &app.admin_secret, &body)) 426 + .await 427 + .unwrap(); 428 + 429 + assert_eq!(resp.status(), StatusCode::CREATED); 430 + let json = json_body(resp).await; 431 + assert_eq!(json["name"], "test-admin"); 432 + assert!(json.get("api_key").is_some()); 433 + assert!(json.get("id").is_some()); 434 + } 435 + 436 + #[tokio::test] 437 + #[serial] 438 + async fn admin_created_key_authenticates() { 439 + let app = TestApp::new().await; 440 + let body = json!({ "name": "new-admin" }); 441 + 442 + // Create admin 443 + let resp = app 444 + .router 445 + .clone() 446 + .oneshot(admin_post("/admin/admins", &app.admin_secret, &body)) 447 + .await 448 + .unwrap(); 449 + let json = json_body(resp).await; 450 + let api_key = json["api_key"].as_str().unwrap(); 451 + 452 + // Use the new key 453 + let resp = app 454 + .router 455 + .oneshot(admin_get("/admin/lexicons", api_key)) 456 + .await 457 + .unwrap(); 458 + 459 + assert_eq!(resp.status(), StatusCode::OK); 460 + } 461 + 462 + #[tokio::test] 463 + #[serial] 464 + async fn admin_list_excludes_keys() { 465 + let app = TestApp::new().await; 466 + 467 + let resp = app 468 + .router 469 + .oneshot(admin_get("/admin/admins", &app.admin_secret)) 470 + .await 471 + .unwrap(); 472 + 473 + assert_eq!(resp.status(), StatusCode::OK); 474 + let json = json_body(resp).await; 475 + let admins = json.as_array().unwrap(); 476 + assert!(!admins.is_empty()); 477 + // No admin should expose api_key or api_key_hash 478 + for admin in admins { 479 + assert!(admin.get("api_key").is_none()); 480 + assert!(admin.get("api_key_hash").is_none()); 481 + } 482 + } 483 + 484 + #[tokio::test] 485 + #[serial] 486 + async fn admin_delete_returns_204() { 487 + let app = TestApp::new().await; 488 + 489 + // Create an admin to delete 490 + let resp = app 491 + .router 492 + .clone() 493 + .oneshot(admin_post( 494 + "/admin/admins", 495 + &app.admin_secret, 496 + &json!({ "name": "disposable" }), 497 + )) 498 + .await 499 + .unwrap(); 500 + let json = json_body(resp).await; 501 + let id = json["id"].as_str().unwrap(); 502 + 503 + let resp = app 504 + .router 505 + .oneshot(admin_delete( 506 + &format!("/admin/admins/{id}"), 507 + &app.admin_secret, 508 + )) 509 + .await 510 + .unwrap(); 511 + 512 + assert_eq!(resp.status(), StatusCode::NO_CONTENT); 513 + } 514 + 515 + #[tokio::test] 516 + #[serial] 517 + async fn admin_delete_not_found() { 518 + let app = TestApp::new().await; 519 + 520 + let resp = app 521 + .router 522 + .oneshot(admin_delete( 523 + "/admin/admins/00000000-0000-0000-0000-000000000000", 524 + &app.admin_secret, 525 + )) 526 + .await 527 + .unwrap(); 528 + 529 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 530 + }
+28
tests/e2e_health.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::Request; 5 + use http_body_util::BodyExt; 6 + use serial_test::serial; 7 + use tower::ServiceExt; 8 + 9 + #[tokio::test] 10 + #[serial] 11 + async fn health_returns_200_ok() { 12 + let app = common::app::TestApp::new().await; 13 + 14 + let resp = app 15 + .router 16 + .oneshot( 17 + Request::builder() 18 + .uri("/health") 19 + .body(Body::empty()) 20 + .unwrap(), 21 + ) 22 + .await 23 + .unwrap(); 24 + 25 + assert_eq!(resp.status(), 200); 26 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 27 + assert_eq!(&body[..], b"ok"); 28 + }
+445
tests/e2e_xrpc.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Request, StatusCode}; 5 + use http_body_util::BodyExt; 6 + use serde_json::{json, Value}; 7 + use serial_test::serial; 8 + use tower::ServiceExt; 9 + use wiremock::matchers::{method, path, path_regex}; 10 + use wiremock::{Mock, ResponseTemplate}; 11 + 12 + use common::app::TestApp; 13 + use common::auth::{admin_auth_header, mock_aip_userinfo}; 14 + use common::fixtures; 15 + 16 + // --------------------------------------------------------------------------- 17 + // Helpers 18 + // --------------------------------------------------------------------------- 19 + 20 + async fn json_body(resp: axum::response::Response) -> Value { 21 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 22 + serde_json::from_slice(&body).unwrap() 23 + } 24 + 25 + fn admin_post(uri: &str, token: &str, body: &Value) -> Request<Body> { 26 + let (hname, hval) = admin_auth_header(token); 27 + Request::builder() 28 + .method("POST") 29 + .uri(uri) 30 + .header(hname, hval) 31 + .header("content-type", "application/json") 32 + .body(Body::from(serde_json::to_vec(body).unwrap())) 33 + .unwrap() 34 + } 35 + 36 + fn authed_get(uri: &str, token: &str) -> Request<Body> { 37 + Request::builder() 38 + .uri(uri) 39 + .header("authorization", format!("Bearer {token}")) 40 + .body(Body::empty()) 41 + .unwrap() 42 + } 43 + 44 + /// Seed the game record lexicon and a query lexicon into the test app. 45 + async fn seed_lexicons(app: &TestApp) { 46 + // Record lexicon 47 + app.router 48 + .clone() 49 + .oneshot(admin_post( 50 + "/admin/lexicons", 51 + &app.admin_secret, 52 + &json!({ 53 + "lexicon_json": fixtures::game_record_lexicon(), 54 + "backfill": false 55 + }), 56 + )) 57 + .await 58 + .unwrap(); 59 + 60 + // Query lexicon 61 + app.router 62 + .clone() 63 + .oneshot(admin_post( 64 + "/admin/lexicons", 65 + &app.admin_secret, 66 + &json!({ 67 + "lexicon_json": fixtures::list_games_query_lexicon(), 68 + "target_collection": "games.gamesgamesgamesgames.game" 69 + }), 70 + )) 71 + .await 72 + .unwrap(); 73 + 74 + // Procedure lexicon 75 + app.router 76 + .clone() 77 + .oneshot(admin_post( 78 + "/admin/lexicons", 79 + &app.admin_secret, 80 + &json!({ 81 + "lexicon_json": fixtures::create_game_procedure_lexicon(), 82 + "target_collection": "games.gamesgamesgamesgames.game" 83 + }), 84 + )) 85 + .await 86 + .unwrap(); 87 + } 88 + 89 + /// Seed a record directly into the database. 90 + async fn seed_record(app: &TestApp, uri: &str, did: &str, collection: &str, record: &Value) { 91 + let rkey = uri.split('/').last().unwrap_or("1"); 92 + sqlx::query( 93 + "INSERT INTO records (uri, did, collection, rkey, record, cid) VALUES ($1, $2, $3, $4, $5, $6)", 94 + ) 95 + .bind(uri) 96 + .bind(did) 97 + .bind(collection) 98 + .bind(rkey) 99 + .bind(record) 100 + .bind("bafytest") 101 + .execute(&app.state.db) 102 + .await 103 + .unwrap(); 104 + } 105 + 106 + // --------------------------------------------------------------------------- 107 + // Profile 108 + // --------------------------------------------------------------------------- 109 + 110 + #[tokio::test] 111 + #[serial] 112 + async fn profile_no_auth_returns_401() { 113 + let app = TestApp::new().await; 114 + 115 + let resp = app 116 + .router 117 + .oneshot( 118 + Request::builder() 119 + .uri("/xrpc/app.bsky.actor.getProfile") 120 + .body(Body::empty()) 121 + .unwrap(), 122 + ) 123 + .await 124 + .unwrap(); 125 + 126 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 127 + } 128 + 129 + #[tokio::test] 130 + #[serial] 131 + async fn profile_with_mocked_services_returns_200() { 132 + let app = TestApp::new().await; 133 + let did = "did:plc:testuser"; 134 + 135 + // Mock AIP userinfo 136 + mock_aip_userinfo(&app.mock_server, did).await; 137 + 138 + // Mock PLC directory 139 + Mock::given(method("GET")) 140 + .and(path(format!("/{did}"))) 141 + .respond_with(ResponseTemplate::new(200).set_body_json( 142 + fixtures::did_document(did, &app.mock_server.uri()), 143 + )) 144 + .mount(&app.mock_server) 145 + .await; 146 + 147 + // Mock PDS getRecord for profile 148 + Mock::given(method("GET")) 149 + .and(path("/xrpc/com.atproto.repo.getRecord")) 150 + .respond_with( 151 + ResponseTemplate::new(200).set_body_json(fixtures::profile_record()), 152 + ) 153 + .mount(&app.mock_server) 154 + .await; 155 + 156 + let resp = app 157 + .router 158 + .oneshot(authed_get( 159 + "/xrpc/app.bsky.actor.getProfile", 160 + "valid-token", 161 + )) 162 + .await 163 + .unwrap(); 164 + 165 + assert_eq!(resp.status(), StatusCode::OK); 166 + let json = json_body(resp).await; 167 + assert_eq!(json["did"], did); 168 + assert_eq!(json["displayName"], "Test User"); 169 + } 170 + 171 + // --------------------------------------------------------------------------- 172 + // Catch-all GET (queries) 173 + // --------------------------------------------------------------------------- 174 + 175 + #[tokio::test] 176 + #[serial] 177 + async fn xrpc_get_unknown_method_returns_400() { 178 + let app = TestApp::new().await; 179 + 180 + let resp = app 181 + .router 182 + .oneshot( 183 + Request::builder() 184 + .uri("/xrpc/nonexistent.method") 185 + .body(Body::empty()) 186 + .unwrap(), 187 + ) 188 + .await 189 + .unwrap(); 190 + 191 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 192 + } 193 + 194 + #[tokio::test] 195 + #[serial] 196 + async fn xrpc_get_non_query_returns_400() { 197 + let app = TestApp::new().await; 198 + seed_lexicons(&app).await; 199 + 200 + // game is a record, not a query 201 + let resp = app 202 + .router 203 + .oneshot( 204 + Request::builder() 205 + .uri("/xrpc/games.gamesgamesgamesgames.game") 206 + .body(Body::empty()) 207 + .unwrap(), 208 + ) 209 + .await 210 + .unwrap(); 211 + 212 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 213 + } 214 + 215 + #[tokio::test] 216 + #[serial] 217 + async fn xrpc_get_single_record_by_uri() { 218 + let app = TestApp::new().await; 219 + seed_lexicons(&app).await; 220 + 221 + let did = "did:plc:test"; 222 + let uri = "at://did:plc:test/games.gamesgamesgamesgames.game/abc123"; 223 + let record = json!({"title": "Test Game", "$type": "games.gamesgamesgamesgames.game"}); 224 + seed_record(&app, uri, did, "games.gamesgamesgamesgames.game", &record).await; 225 + 226 + // Mock PLC for PDS resolution 227 + Mock::given(method("GET")) 228 + .and(path(format!("/{did}"))) 229 + .respond_with(ResponseTemplate::new(200).set_body_json( 230 + fixtures::did_document(did, "https://pds.example.com"), 231 + )) 232 + .mount(&app.mock_server) 233 + .await; 234 + 235 + let resp = app 236 + .router 237 + .oneshot( 238 + Request::builder() 239 + .uri(&format!( 240 + "/xrpc/games.gamesgamesgamesgames.listGames?uri={}", 241 + urlencoding::encode(uri) 242 + )) 243 + .body(Body::empty()) 244 + .unwrap(), 245 + ) 246 + .await 247 + .unwrap(); 248 + 249 + assert_eq!(resp.status(), StatusCode::OK); 250 + let json = json_body(resp).await; 251 + assert_eq!(json["record"]["title"], "Test Game"); 252 + assert_eq!(json["record"]["uri"], uri); 253 + } 254 + 255 + #[tokio::test] 256 + #[serial] 257 + async fn xrpc_get_record_not_found() { 258 + let app = TestApp::new().await; 259 + seed_lexicons(&app).await; 260 + 261 + let resp = app 262 + .router 263 + .oneshot( 264 + Request::builder() 265 + .uri("/xrpc/games.gamesgamesgamesgames.listGames?uri=at%3A%2F%2Fdid%3Aplc%3Anone%2Fgames.gamesgamesgamesgames.game%2Fmissing") 266 + .body(Body::empty()) 267 + .unwrap(), 268 + ) 269 + .await 270 + .unwrap(); 271 + 272 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 273 + } 274 + 275 + #[tokio::test] 276 + #[serial] 277 + async fn xrpc_get_list_with_pagination() { 278 + let app = TestApp::new().await; 279 + seed_lexicons(&app).await; 280 + 281 + let did = "did:plc:test"; 282 + 283 + // Mock PLC for enrichment 284 + Mock::given(method("GET")) 285 + .and(path_regex("/did:plc:.*")) 286 + .respond_with(ResponseTemplate::new(200).set_body_json( 287 + fixtures::did_document(did, "https://pds.example.com"), 288 + )) 289 + .mount(&app.mock_server) 290 + .await; 291 + 292 + // Seed 3 records 293 + for i in 1..=3 { 294 + let uri = format!("at://{did}/games.gamesgamesgamesgames.game/rec{i}"); 295 + seed_record( 296 + &app, 297 + &uri, 298 + did, 299 + "games.gamesgamesgamesgames.game", 300 + &json!({"title": format!("Game {i}")}), 301 + ) 302 + .await; 303 + } 304 + 305 + // Request with limit=2 306 + let resp = app 307 + .router 308 + .clone() 309 + .oneshot( 310 + Request::builder() 311 + .uri("/xrpc/games.gamesgamesgamesgames.listGames?limit=2") 312 + .body(Body::empty()) 313 + .unwrap(), 314 + ) 315 + .await 316 + .unwrap(); 317 + 318 + assert_eq!(resp.status(), StatusCode::OK); 319 + let json = json_body(resp).await; 320 + assert_eq!(json["records"].as_array().unwrap().len(), 2); 321 + assert!(json.get("cursor").is_some()); 322 + 323 + // Use cursor for next page 324 + let cursor = json["cursor"].as_str().unwrap(); 325 + let resp = app 326 + .router 327 + .oneshot( 328 + Request::builder() 329 + .uri(&format!( 330 + "/xrpc/games.gamesgamesgamesgames.listGames?limit=2&cursor={cursor}" 331 + )) 332 + .body(Body::empty()) 333 + .unwrap(), 334 + ) 335 + .await 336 + .unwrap(); 337 + 338 + assert_eq!(resp.status(), StatusCode::OK); 339 + let json = json_body(resp).await; 340 + assert_eq!(json["records"].as_array().unwrap().len(), 1); 341 + assert!(json.get("cursor").is_none()); 342 + } 343 + 344 + #[tokio::test] 345 + #[serial] 346 + async fn xrpc_get_list_filtered_by_did() { 347 + let app = TestApp::new().await; 348 + seed_lexicons(&app).await; 349 + 350 + // Mock PLC 351 + Mock::given(method("GET")) 352 + .and(path_regex("/did:plc:.*")) 353 + .respond_with(ResponseTemplate::new(200).set_body_json( 354 + fixtures::did_document("did:plc:a", "https://pds.example.com"), 355 + )) 356 + .mount(&app.mock_server) 357 + .await; 358 + 359 + // Seed records for two different DIDs 360 + seed_record( 361 + &app, 362 + "at://did:plc:a/games.gamesgamesgamesgames.game/1", 363 + "did:plc:a", 364 + "games.gamesgamesgamesgames.game", 365 + &json!({"title": "Game A"}), 366 + ) 367 + .await; 368 + seed_record( 369 + &app, 370 + "at://did:plc:b/games.gamesgamesgamesgames.game/2", 371 + "did:plc:b", 372 + "games.gamesgamesgamesgames.game", 373 + &json!({"title": "Game B"}), 374 + ) 375 + .await; 376 + 377 + let resp = app 378 + .router 379 + .oneshot( 380 + Request::builder() 381 + .uri("/xrpc/games.gamesgamesgamesgames.listGames?did=did:plc:a") 382 + .body(Body::empty()) 383 + .unwrap(), 384 + ) 385 + .await 386 + .unwrap(); 387 + 388 + assert_eq!(resp.status(), StatusCode::OK); 389 + let json = json_body(resp).await; 390 + let records = json["records"].as_array().unwrap(); 391 + assert_eq!(records.len(), 1); 392 + assert_eq!(records[0]["title"], "Game A"); 393 + } 394 + 395 + // --------------------------------------------------------------------------- 396 + // Catch-all POST (procedures) 397 + // --------------------------------------------------------------------------- 398 + 399 + #[tokio::test] 400 + #[serial] 401 + async fn xrpc_post_no_auth_returns_401() { 402 + let app = TestApp::new().await; 403 + seed_lexicons(&app).await; 404 + 405 + let resp = app 406 + .router 407 + .oneshot( 408 + Request::builder() 409 + .method("POST") 410 + .uri("/xrpc/games.gamesgamesgamesgames.createGame") 411 + .header("content-type", "application/json") 412 + .body(Body::from(b"{}".to_vec())) 413 + .unwrap(), 414 + ) 415 + .await 416 + .unwrap(); 417 + 418 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 419 + } 420 + 421 + #[tokio::test] 422 + #[serial] 423 + async fn xrpc_post_non_procedure_returns_400() { 424 + let app = TestApp::new().await; 425 + seed_lexicons(&app).await; 426 + 427 + // Mock AIP userinfo so auth passes 428 + mock_aip_userinfo(&app.mock_server, "did:plc:test").await; 429 + 430 + let resp = app 431 + .router 432 + .oneshot( 433 + Request::builder() 434 + .method("POST") 435 + .uri("/xrpc/games.gamesgamesgamesgames.listGames") 436 + .header("authorization", "Bearer valid-token") 437 + .header("content-type", "application/json") 438 + .body(Body::from(b"{}".to_vec())) 439 + .unwrap(), 440 + ) 441 + .await 442 + .unwrap(); 443 + 444 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 445 + }