diff --git a/Cargo.lock b/Cargo.lock index 8010cb58..d4d6721a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -928,11 +928,13 @@ dependencies = [ "poster-fetcher", "presentation", "rss 0.1.0", + "serde_json", "sqlite", "sqlite-federation", "sqlx", "tmdb-enrichment", "tokio", + "tower", "tracing", "tracing-subscriber", "uuid", diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 4690586d..95458630 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -45,3 +45,5 @@ sqlite-federation = { workspace = true, optional = true } chrono = { workspace = true } domain = { workspace = true, features = ["test-helpers"] } uuid = { workspace = true } +serde_json = { workspace = true } +tower = { version = "0.5", features = ["util"] } diff --git a/crates/bootstrap/tests/fixtures/openapi_paths.txt b/crates/bootstrap/tests/fixtures/openapi_paths.txt new file mode 100644 index 00000000..851c39a0 --- /dev/null +++ b/crates/bootstrap/tests/fixtures/openapi_paths.txt @@ -0,0 +1,69 @@ +DELETE /api/v1/admin/blocked-domains/{domain} +DELETE /api/v1/goals/{year} +DELETE /api/v1/import/profiles/{id} +DELETE /api/v1/reviews/{id} +DELETE /api/v1/settings/webhook-tokens/{id} +DELETE /api/v1/watchlist/{movie_id} +DELETE /api/v1/wrapups/{id} +GET /api/v1/activity-feed +GET /api/v1/admin/blocked-domains +GET /api/v1/diary +GET /api/v1/diary/export +GET /api/v1/goals +GET /api/v1/import/profiles +GET /api/v1/import/sessions/{id} +GET /api/v1/insights +GET /api/v1/movies +GET /api/v1/movies/{id}/history +GET /api/v1/movies/{id}/profile +GET /api/v1/movies/{movie_id} +GET /api/v1/people/{id} +GET /api/v1/people/{id}/credits +GET /api/v1/profile +GET /api/v1/search +GET /api/v1/settings +GET /api/v1/settings/webhook-tokens +GET /api/v1/social/blocked +GET /api/v1/social/followers +GET /api/v1/social/followers/pending +GET /api/v1/social/followers/pending/count +GET /api/v1/social/following +GET /api/v1/social/following/pending +GET /api/v1/social/relationship +GET /api/v1/users +GET /api/v1/users/{id} +GET /api/v1/users/{id}/goals +GET /api/v1/watch-queue +GET /api/v1/watchlist +GET /api/v1/watchlist/{movie_id} +GET /api/v1/wrapups +GET /api/v1/wrapups/{id} +GET /api/v1/wrapups/{id}/report +POST /api/v1/admin/blocked-domains +POST /api/v1/auth/login +POST /api/v1/auth/register +POST /api/v1/goals +POST /api/v1/import/profiles +POST /api/v1/import/sessions +POST /api/v1/import/sessions/{id}/confirm +POST /api/v1/movies/{id}/sync-poster +POST /api/v1/reviews +POST /api/v1/settings/webhook-tokens +POST /api/v1/social/block +POST /api/v1/social/follow +POST /api/v1/social/followers/accept +POST /api/v1/social/followers/reject +POST /api/v1/social/followers/remove +POST /api/v1/social/unblock +POST /api/v1/social/unfollow +POST /api/v1/watch-queue/confirm +POST /api/v1/watch-queue/dismiss +POST /api/v1/watchlist +POST /api/v1/webhooks/jellyfin +POST /api/v1/webhooks/plex +POST /api/v1/wrapups/generate +PUT /api/v1/goals/{year} +PUT /api/v1/import/sessions/{id}/mapping +PUT /api/v1/profile +PUT /api/v1/profile/fields +PUT /api/v1/settings diff --git a/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt b/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt new file mode 100644 index 00000000..ea1b1b54 --- /dev/null +++ b/crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt @@ -0,0 +1,52 @@ +DELETE /api/v1/goals/{year} +DELETE /api/v1/import/profiles/{id} +DELETE /api/v1/reviews/{id} +DELETE /api/v1/settings/webhook-tokens/{id} +DELETE /api/v1/watchlist/{movie_id} +DELETE /api/v1/wrapups/{id} +GET /api/v1/activity-feed +GET /api/v1/diary +GET /api/v1/diary/export +GET /api/v1/goals +GET /api/v1/import/profiles +GET /api/v1/import/sessions/{id} +GET /api/v1/insights +GET /api/v1/movies +GET /api/v1/movies/{id}/history +GET /api/v1/movies/{id}/profile +GET /api/v1/movies/{movie_id} +GET /api/v1/people/{id} +GET /api/v1/people/{id}/credits +GET /api/v1/profile +GET /api/v1/search +GET /api/v1/settings +GET /api/v1/settings/webhook-tokens +GET /api/v1/users +GET /api/v1/users/{id} +GET /api/v1/users/{id}/goals +GET /api/v1/watch-queue +GET /api/v1/watchlist +GET /api/v1/watchlist/{movie_id} +GET /api/v1/wrapups +GET /api/v1/wrapups/{id} +GET /api/v1/wrapups/{id}/report +POST /api/v1/auth/login +POST /api/v1/auth/register +POST /api/v1/goals +POST /api/v1/import/profiles +POST /api/v1/import/sessions +POST /api/v1/import/sessions/{id}/confirm +POST /api/v1/movies/{id}/sync-poster +POST /api/v1/reviews +POST /api/v1/settings/webhook-tokens +POST /api/v1/watch-queue/confirm +POST /api/v1/watch-queue/dismiss +POST /api/v1/watchlist +POST /api/v1/webhooks/jellyfin +POST /api/v1/webhooks/plex +POST /api/v1/wrapups/generate +PUT /api/v1/goals/{year} +PUT /api/v1/import/sessions/{id}/mapping +PUT /api/v1/profile +PUT /api/v1/profile/fields +PUT /api/v1/settings diff --git a/crates/bootstrap/tests/route_smoke_test.rs b/crates/bootstrap/tests/route_smoke_test.rs new file mode 100644 index 00000000..44d49609 --- /dev/null +++ b/crates/bootstrap/tests/route_smoke_test.rs @@ -0,0 +1,385 @@ +//! 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::