//! Characterization of the classic HTML routes, feeds and the OpenAPI surface, //! recorded before the HTTP layer moves. Statuses are what the app returns today. use axum::{ Router, body::Body, http::{Method, Request, header}, }; use tower::ServiceExt; const PASSWORD: &str = "correct-horse-battery"; const EMAIL: &str = "owner@example.com"; const USERNAME: &str = "owner"; /// Public classic pages. `{id}`, `{username}`, `{movie}` are replaced by seeded values. const CLASSIC_VISITOR_PAGES: &[(&str, u16)] = &[ ("/", 200), ("/users", 200), ("/u/{username}", 308), ("/users/{id}", 200), ("/users/{id}?view=ratings", 200), ("/users/{id}?view=history", 200), ("/movies/{movie}", 200), ("/login", 200), ("/register", 200), ("/logout", 303), ("/tags/drama", 307), ("/users/{id}/watchlist", 200), ("/wrapups/{id}/2025", 404), ("/wrapups/global/2025", 404), ("/users/{id}/following-list", 303), ("/users/{id}/followers-list", 303), ("/posters/abc.jpg", 308), ("/images/missing.jpg", 404), ("/health", 200), ]; /// Pages that need the signed-in owner. const CLASSIC_OWNER_PAGES: &[(&str, u16)] = &[ ("/reviews/new", 200), ("/diary/export", 200), ("/import", 200), ("/import/done", 200), ("/import/00000000-0000-0000-0000-000000000000/mapping", 303), ("/import/00000000-0000-0000-0000-000000000000/preview", 303), ("/settings/profile", 200), ("/settings/integrations", 200), ("/watch-queue", 200), ("/users/{id}/watchlist", 200), ("/users/{id}/following-list", 200), ("/users/{id}/followers-list", 200), ("/movies/{movie}", 200), ("/users/{id}?embed=true", 200), ]; #[cfg(feature = "federation")] const FEDERATION_VISITOR_PAGES: &[(&str, u16)] = &[ ("/users/{id}/followers", 303), ("/users/{id}/following", 303), ]; #[cfg(feature = "federation")] const FEDERATION_OWNER_PAGES: &[(&str, u16)] = &[ ("/users/{id}/followers", 303), ("/users/{id}/following", 303), // The registered owner is not an admin. ("/admin/blocked-domains", 403), ("/social/blocked", 200), ]; /// Without federation the collection URLs permanently redirect to the HTML lists. #[cfg(not(feature = "federation"))] const FEDERATION_OFF_PAGES: &[(&str, u16)] = &[ ("/users/{id}/followers", 308), ("/users/{id}/following", 308), ]; struct Seed { router: Router, user_id: String, movie_id: String, /// Cookie header for the signed-in owner (csrf + token). owner_cookie: String, } fn test_config() -> config::AppConfig { config::AppConfig { server: config::ServerConfig { allow_registration: true, base_url: "http://localhost:3000".into(), ..Default::default() }, database: config::DatabaseConfig { url: "sqlite::memory:".into(), }, auth: config::AuthConfig { jwt_secret: Some("route-smoke-test-secret-0123456789abcdef".into()), ..Default::default() }, events: Default::default(), nats: Default::default(), storage: Default::default(), image_conversion: Default::default(), // Required to build; never called (seeding logs reviews with manual titles). metadata: config::MetadataConfig { tmdb_api_key: Some("test-key".into()), ..Default::default() }, federation: Default::default(), wrapup: Default::default(), } } async fn send(router: &Router, mut req: Request) -> (u16, axum::http::HeaderMap, String) { let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); req.extensions_mut() .insert(axum::extract::ConnectInfo::(addr)); let resp = router.clone().oneshot(req).await.unwrap(); let status = resp.status().as_u16(); let headers = resp.headers().clone(); let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); ( status, headers, String::from_utf8_lossy(&bytes).into_owned(), ) } fn get(uri: &str, cookie: Option<&str>) -> Request { let mut b = Request::builder().method(Method::GET).uri(uri); if let Some(c) = cookie { b = b.header(header::COOKIE, c); } b.body(Body::empty()).unwrap() } fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option { headers .get_all(header::SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .find_map(|c| { c.split(';') .next()? .trim() .strip_prefix(&format!("{name}=")) .map(str::to_string) }) } fn json_request( method: Method, uri: &str, token: Option<&str>, body: serde_json::Value, ) -> Request { let mut b = Request::builder() .method(method) .uri(uri) .header(header::CONTENT_TYPE, "application/json"); if let Some(t) = token { b = b.header(header::AUTHORIZATION, format!("Bearer {t}")); } b.body(Body::from(body.to_string())).unwrap() } /// One user (registered through the classic form), one movie with a spoiler review, one goal. async fn seed() -> Seed { let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); let router = bootstrap::build_server_on(&test_config(), pool) .await .unwrap(); // Register through the form: GET for the csrf cookie, then POST with the token. let (status, headers, _) = send(&router, get("/register", None)).await; assert_eq!(status, 200, "GET /register"); let csrf = cookie_value(&headers, "csrf").expect("csrf cookie"); let form = format!( "email={}&username={USERNAME}&password={PASSWORD}&_csrf={csrf}", EMAIL.replace('@', "%40") ); let req = Request::builder() .method(Method::POST) .uri("/register") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, format!("csrf={csrf}")) .body(Body::from(form)) .unwrap(); let (status, headers, _) = send(&router, req).await; assert_eq!(status, 303, "POST /register"); let session = cookie_value(&headers, "token").expect("session cookie after register"); let owner_cookie = format!("csrf={csrf}; token={session}"); // Bearer token for the JSON API. let (status, _, body) = send( &router, json_request( Method::POST, "/api/v1/auth/login", None, serde_json::json!({ "email": EMAIL, "password": PASSWORD }), ), ) .await; assert_eq!(status, 200, "login: {body}"); let login: serde_json::Value = serde_json::from_str(&body).unwrap(); let token = login["token"].as_str().unwrap().to_string(); let user_id = login["user_id"].as_str().unwrap().to_string(); let (status, _, body) = send( &router, json_request( Method::POST, "/api/v1/reviews", Some(&token), serde_json::json!({ "manual_title": "Seeded Movie", "manual_release_year": 1999, "manual_director": "Some Director", "rating": 4, "comment": "the butler did it", "watched_at": "2025-03-01", "spoilers": true, }), ), ) .await; assert!( status == 200 || status == 201, "log review {status}: {body}" ); let (status, _, body) = send( &router, json_request( Method::POST, "/api/v1/goals", Some(&token), serde_json::json!({ "year": 2025, "target_count": 12 }), ), ) .await; assert!( status == 200 || status == 201, "create goal {status}: {body}" ); let (status, _, body) = send( &router, Request::builder() .uri("/api/v1/movies") .header(header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::empty()) .unwrap(), ) .await; assert_eq!(status, 200, "list movies: {body}"); let movies: serde_json::Value = serde_json::from_str(&body).unwrap(); let first = movies .get("movies") .and_then(|m| m.get(0)) .or_else(|| movies.get(0)) .or_else(|| movies.get("items").and_then(|m| m.get(0))) .unwrap_or_else(|| panic!("no movie in {body}")); let movie_id = first["id"] .as_str() .or_else(|| first["movie"]["id"].as_str()) .unwrap_or_else(|| panic!("no movie id in {body}")) .to_string(); Seed { router, user_id, movie_id, owner_cookie, } } fn expand(seed: &Seed, template: &str) -> String { template .replace("{id}", &seed.user_id) .replace("{username}", USERNAME) .replace("{movie}", &seed.movie_id) } async fn check_table(seed: &Seed, table: &[(&str, u16)], cookie: Option<&str>) { let mut failures = Vec::new(); for (template, expected) in table { let uri = expand(seed, template); let (status, _, _) = send(&seed.router, get(&uri, cookie)).await; if status != *expected { failures.push(format!("{uri}: expected {expected}, got {status}")); } } assert!( failures.is_empty(), "route status drift:\n{}", failures.join("\n") ); } #[tokio::test] async fn every_classic_page_renders_for_a_visitor() { let seed = seed().await; check_table(&seed, CLASSIC_VISITOR_PAGES, None).await; #[cfg(feature = "federation")] check_table(&seed, FEDERATION_VISITOR_PAGES, None).await; #[cfg(not(feature = "federation"))] check_table(&seed, FEDERATION_OFF_PAGES, None).await; // The owner embeds this widget in an iframe on their personal website. let (status, _, body) = send( &seed.router, get(&expand(&seed, "/users/{id}?embed=true"), None), ) .await; assert_eq!(status, 200); assert!( body.contains("class=\"embed-profile\""), "embed template not rendered: {body}" ); } #[tokio::test] async fn every_classic_page_renders_for_the_signed_in_owner() { let seed = seed().await; let cookie = seed.owner_cookie.clone(); check_table(&seed, CLASSIC_OWNER_PAGES, Some(&cookie)).await; #[cfg(feature = "federation")] check_table(&seed, FEDERATION_OWNER_PAGES, Some(&cookie)).await; } #[tokio::test] async fn feeds_and_openapi_are_served() { let seed = seed().await; for template in [ "/feed.rss", "/users/{id}/feed.rss", "/openapi.json", "/docs/", "/scalar", ] { let uri = expand(&seed, template); let (status, _, _) = send(&seed.router, get(&uri, None)).await; assert_eq!(status, 200, "{uri}"); } } #[tokio::test] async fn openapi_paths_match_the_recorded_list() { let seed = seed().await; let (status, _, body) = send(&seed.router, get("/openapi.json", None)).await; assert_eq!(status, 200); let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); let mut lines = Vec::new(); for (path, item) in spec["paths"].as_object().unwrap() { for method in item.as_object().unwrap().keys() { if ["get", "put", "post", "delete", "patch", "head", "options"] .contains(&method.as_str()) { lines.push(format!("{} {path}", method.to_uppercase())); } } } lines.sort(); let actual = lines.join("\n") + "\n"; let fixture = if cfg!(feature = "federation") { "tests/fixtures/openapi_paths.txt" } else { "tests/fixtures/openapi_paths_federation_off.txt" }; let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(fixture); if std::env::var("UPDATE_OPENAPI_FIXTURE").is_ok() { std::fs::write(&path, &actual).unwrap(); } let expected = std::fs::read_to_string(&path).unwrap(); assert_eq!(actual, expected, "OpenAPI paths drifted from {fixture}"); }