//! Tenant-isolation matrix: every admin endpoint must treat another user's //! resources as nonexistent (404), and unauthenticated requests as 401. //! //! Needs a disposable Postgres database: //! TEST_DATABASE_URL=postgres://photos:photos@localhost:5432/photos_test cargo test //! Skips silently when TEST_DATABASE_URL is unset. S3 is never contacted — //! the matrix stops at the ownership checks by construction. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use cookie::{Cookie, CookieJar, Key}; use sha2::{Digest, Sha512}; use tower::ServiceExt; use uuid::Uuid; use photos::config::Config; use photos::state::AppState; const SECRET: &str = "test-secret-test-secret-test-secret-1234"; fn test_config(database_url: String) -> Config { Config { database_url, bind_addr: "127.0.0.1:0".into(), public_url: "http://localhost:8080".into(), session_secret: SECRET.into(), s3_bucket: "photos".into(), // Unroutable on purpose: no test may reach S3. s3_endpoint: Some("http://127.0.0.1:9".into()), s3_region: "us-east-1".into(), s3_access_key: "test".into(), s3_secret_key: "test".into(), s3_force_path_style: true, oidc_issuer: "https://auth.invalid".into(), oidc_client_id: "x".into(), oidc_client_secret: "x".into(), allowed_emails: vec!["a@test".into(), "b@test".into()], static_dir: "frontend/dist".into(), worker_concurrency: 1, dev_autologin_email: None, } } fn session_for(user_id: Uuid, email: &str) -> String { let key = Key::from(&Sha512::digest(SECRET.as_bytes())); let exp = chrono::Utc::now().timestamp() + 3600; // Use the production payload builder so the test can't drift from what // user_from_jar parses. let mut jar = CookieJar::new(); jar.signed_mut(&key).add(Cookie::new( "photos_session", photos::auth::session_payload(user_id, email, exp), )); format!("photos_session={}", jar.get("photos_session").unwrap().value()) } async fn request( router: &axum::Router, method: &str, path: &str, cookie: Option<&str>, json: Option, ) -> (StatusCode, serde_json::Value) { let mut builder = Request::builder().method(method).uri(path); if let Some(cookie) = cookie { builder = builder.header(header::COOKIE, cookie); } let body = match json { Some(value) => { builder = builder.header(header::CONTENT_TYPE, "application/json"); Body::from(value.to_string()) } // Zip endpoints take a form; everything else ignores the body. None if path.ends_with("/zip") => { builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); Body::from("ids=") } None => Body::empty(), }; let response = router .clone() .oneshot(builder.body(body).unwrap()) .await .unwrap(); let status = response.status(); let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) .await .unwrap(); let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); (status, value) } async fn seed_user(state: &AppState, email: &str) -> Uuid { let (id,): (Uuid,) = sqlx::query_as( "insert into users (oidc_subject, email) values ($1, $2) returning id", ) .bind(format!("test:{email}")) .bind(email) .fetch_one(&state.db) .await .unwrap(); id } #[tokio::test] async fn tenant_isolation_matrix() { let Ok(database_url) = std::env::var("TEST_DATABASE_URL") else { eprintln!("TEST_DATABASE_URL not set — skipping tenancy matrix"); return; }; let state = AppState::new(test_config(database_url)).await.unwrap(); sqlx::query("truncate users, albums, photos, shares, ratings, tags, jobs cascade") .execute(&state.db) .await .unwrap(); let alice = seed_user(&state, "a@test").await; let bob = seed_user(&state, "b@test").await; let cookie_a = session_for(alice, "a@test"); let cookie_b = session_for(bob, "b@test"); let router = photos::routes::router(&state).with_state(state.clone()); // Alice creates an album through the real API. let (status, album) = request( &router, "POST", "/api/albums", Some(&cookie_a), Some(serde_json::json!({ "name": "Alice's Wedding" })), ) .await; assert_eq!(status, StatusCode::OK); let album_id = album["id"].as_str().unwrap().to_string(); // Seed a photo (kept non-ready so no code path reaches S3) and a share. let photo_id = Uuid::new_v4(); sqlx::query( "insert into photos (id, album_id, filename, content_type, size_bytes, sha256) values ($1, $2::uuid, 'a.jpg', 'image/jpeg', 3, 'hash-a')", ) .bind(photo_id) .bind(&album_id) .execute(&state.db) .await .unwrap(); let share_id = Uuid::new_v4(); sqlx::query("insert into shares (id, album_id, token) values ($1, $2::uuid, 'tenanttesttoken123456789')") .bind(share_id) .bind(&album_id) .execute(&state.db) .await .unwrap(); // ---- Bob vs Alice's resources: everything must be a 404 (or absent). ---- let bob_hits: &[(&str, String, Option)] = &[ ("GET", format!("/api/albums/{album_id}"), None), ( "PATCH", format!("/api/albums/{album_id}"), Some(serde_json::json!({ "name": "stolen" })), ), ("DELETE", format!("/api/albums/{album_id}"), None), ("POST", format!("/api/albums/{album_id}/photos?filename=x.jpg"), None), ("GET", format!("/api/albums/{album_id}/shares"), None), ( "POST", format!("/api/albums/{album_id}/shares"), Some(serde_json::json!({ "label": "x" })), ), ("POST", format!("/api/albums/{album_id}/zip"), None), ("GET", format!("/api/albums/{album_id}/photos/by-hash/hash-a"), None), ("DELETE", format!("/api/photos/{photo_id}"), None), ("POST", format!("/api/photos/{photo_id}/reprocess"), None), ("DELETE", format!("/api/shares/{share_id}"), None), ("POST", format!("/api/shares/{share_id}/reset-lock"), None), ]; for (method, path, body) in bob_hits { let (status, _) = request(&router, method, path, Some(&cookie_b), body.clone()).await; assert_eq!( status, StatusCode::NOT_FOUND, "cross-tenant {method} {path} must 404" ); } let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_b), None).await; assert_eq!(status, StatusCode::OK); assert_eq!(list.as_array().unwrap().len(), 0, "bob must see no albums"); // ---- Dual-auth image routes must ALSO reject a non-owning photographer. // Bob owns no share and doesn't own the album, so authorize_photo falls // through to the share-cookie path and 401s BEFORE any S3 access — the // cross-tenant original leak these routes previously allowed. ---- for path in [ format!("/api/img/{photo_id}/thumb"), format!("/api/img/{photo_id}/preview"), format!("/api/photos/{photo_id}/original"), ] { let (status, _) = request(&router, "GET", &path, Some(&cookie_b), None).await; assert_eq!( status, StatusCode::UNAUTHORIZED, "cross-tenant image GET {path} must not authorize" ); } // ---- Alice keeps full access to her own resources. ---- let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_a), None).await; assert_eq!(status, StatusCode::OK); assert_eq!(list.as_array().unwrap().len(), 1); let (status, _) = request( &router, "GET", &format!("/api/albums/{album_id}"), Some(&cookie_a), None, ) .await; assert_eq!(status, StatusCode::OK); let (status, _) = request( &router, "GET", &format!("/api/albums/{album_id}/photos/by-hash/hash-a"), Some(&cookie_a), None, ) .await; assert_eq!(status, StatusCode::OK); // Ownership check runs before the body is read: empty upload = 400, not 404. let (status, _) = request( &router, "POST", &format!("/api/albums/{album_id}/photos?filename=x.jpg"), Some(&cookie_a), None, ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); // No ready photos yet: zip is a 400 for the owner, never an S3 call. let (status, _) = request( &router, "POST", &format!("/api/albums/{album_id}/zip"), Some(&cookie_a), None, ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); // ---- No session at all: 401 on the admin surface. ---- for path in ["/api/albums", "/api/me"] { let (status, _) = request(&router, "GET", path, None, None).await; assert_eq!(status, StatusCode::UNAUTHORIZED, "{path} without session"); } }