diff --git a/Cargo.lock b/Cargo.lock index fa3e1de..be62712 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/crates/pinakes-server/Cargo.toml b/crates/pinakes-server/Cargo.toml index d96001c..14e329e 100644 --- a/crates/pinakes-server/Cargo.toml +++ b/crates/pinakes-server/Cargo.toml @@ -41,4 +41,5 @@ workspace = true [dev-dependencies] http-body-util = "0.1.3" -tempfile = "3.25.0" +reqwest = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/pinakes-server/tests/e2e.rs b/crates/pinakes-server/tests/e2e.rs new file mode 100644 index 0000000..96d8aeb --- /dev/null +++ b/crates/pinakes-server/tests/e2e.rs @@ -0,0 +1,65 @@ +/// End-to-end tests that bind a real TCP listener and exercise the HTTP layer. +/// +/// These tests differ from the router-level `oneshot` tests in that they verify +/// the full Axum `serve` path: `TcpListener` binding, HTTP framing, and response +/// serialization. Each test spins up a server on an ephemeral port, issues a +/// real HTTP request via reqwest, then shuts down. +mod common; + +use std::net::SocketAddr; + +use tokio::net::TcpListener; + +/// Bind a listener on an ephemeral port, spawn the server in the background, +/// and return the bound address as a string. +/// +/// Uses `into_make_service_with_connect_info` so that the governor rate +/// limiter can extract `ConnectInfo` from real TCP connections. +async fn bind_and_serve() -> String { + let app = common::setup_app().await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + format!("http://{addr}") +} + +#[tokio::test] +async fn health_endpoint_responds_over_real_tcp() { + let base = bind_and_serve().await; + let resp = reqwest::get(format!("{base}/api/v1/health")) + .await + .expect("health request failed"); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("body not JSON"); + assert!( + body["status"].is_string(), + "expected health response to contain 'status' field" + ); +} + +#[tokio::test] +async fn media_list_responds_over_real_tcp() { + // setup_app has authentication_disabled=true; verifies the router serves + // real TCP traffic, not just in-process oneshot requests. + let base = bind_and_serve().await; + let resp = reqwest::get(format!("{base}/api/v1/media")) + .await + .expect("media list request failed"); + assert_eq!(resp.status(), 200); +} + +#[tokio::test] +async fn unknown_route_returns_404_over_real_tcp() { + let base = bind_and_serve().await; + let resp = reqwest::get(format!("{base}/api/v1/nonexistent-route")) + .await + .expect("request failed"); + assert_eq!(resp.status(), 404); +} diff --git a/crates/pinakes-server/tests/enrichment.rs b/crates/pinakes-server/tests/enrichment.rs new file mode 100644 index 0000000..9f93951 --- /dev/null +++ b/crates/pinakes-server/tests/enrichment.rs @@ -0,0 +1,143 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + get, + get_authed, + post_json_authed, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +// GET /api/v1/media/{id}/metadata/external (viewer) + +#[tokio::test] +async fn get_external_metadata_requires_auth() { + let (app, ..) = setup_app_with_auth().await; + let response = app + .oneshot(get( + "/api/v1/media/00000000-0000-0000-0000-000000000000/external-metadata", + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn get_external_metadata_viewer_ok() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/external-metadata", + &viewer_token, + )) + .await + .unwrap(); + // Media does not exist; 200 with empty array or 404 are both valid + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::NOT_FOUND + ); +} + +// POST /api/v1/media/{id}/enrich (editor) + +#[tokio::test] +async fn trigger_enrichment_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/enrich", + "{}", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn trigger_enrichment_editor_accepted() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/enrich", + "{}", + &editor_token, + )) + .await + .unwrap(); + // Route is accessible to editors; media not found returns 404, job queued + // returns 200 + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::NOT_FOUND + ); +} + +// POST /api/v1/jobs/enrich (editor, batch) + +#[tokio::test] +async fn batch_enrich_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn batch_enrich_empty_ids_rejected() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":[]}"#, + &editor_token, + )) + .await + .unwrap(); + // Validation requires 1-1000 ids + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::UNPROCESSABLE_ENTITY + ); +} + +#[tokio::test] +async fn batch_enrich_editor_accepted() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, + &editor_token, + )) + .await + .unwrap(); + // Job is queued and a job_id is returned + assert_eq!(response.status(), StatusCode::OK); +} + +// No-auth coverage (exercises setup_app and get) + +#[tokio::test] +async fn get_external_metadata_auth_disabled() { + let app = setup_app().await; + let response = app + .oneshot(get( + "/api/v1/media/00000000-0000-0000-0000-000000000000/external-metadata", + )) + .await + .unwrap(); + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::NOT_FOUND + ); +} diff --git a/crates/pinakes-server/tests/users.rs b/crates/pinakes-server/tests/users.rs new file mode 100644 index 0000000..3f99f13 --- /dev/null +++ b/crates/pinakes-server/tests/users.rs @@ -0,0 +1,217 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + delete_authed, + get, + get_authed, + patch_json_authed, + post_json_authed, + response_body, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +// GET /api/v1/users (admin) + +#[tokio::test] +async fn list_users_requires_admin() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed("/api/v1/users", &editor_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn list_users_viewer_forbidden() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed("/api/v1/users", &viewer_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn list_users_admin_ok() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed("/api/v1/users", &admin_token)) + .await + .unwrap(); + let status = response.status(); + let body = response_body(response).await; + assert_eq!(status, StatusCode::OK); + let users = body.as_array().expect("users is array"); + // setup_app_with_auth seeds three users + assert_eq!(users.len(), 3); +} + +// POST /api/v1/users (admin) + +#[tokio::test] +async fn create_user_requires_admin() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/users", + r#"{"username":"newuser","password":"password123","role":"viewer"}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn create_user_admin_ok() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/users", + r#"{"username":"newuser","password":"password123","role":"viewer"}"#, + &admin_token, + )) + .await + .unwrap(); + let status = response.status(); + let body = response_body(response).await; + assert!( + status == StatusCode::OK || status == StatusCode::CREATED, + "unexpected status: {status}" + ); + assert!(body["id"].is_string(), "expected id field, got: {body}"); + assert_eq!(body["username"].as_str().unwrap(), "newuser"); +} + +#[tokio::test] +async fn create_user_duplicate_username() { + let (app, admin_token, ..) = setup_app_with_auth().await; + // "admin" already exists + let response = app + .oneshot(post_json_authed( + "/api/v1/users", + r#"{"username":"admin","password":"password123","role":"viewer"}"#, + &admin_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +async fn create_user_password_too_short() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/users", + r#"{"username":"shortpass","password":"short","role":"viewer"}"#, + &admin_token, + )) + .await + .unwrap(); + // Password minimum is 8 chars; should be rejected + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::UNPROCESSABLE_ENTITY + ); +} + +// GET /api/v1/users/{id} (admin) + +#[tokio::test] +async fn get_user_requires_admin() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn get_user_not_found() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + &admin_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +// PATCH /api/v1/users/{id} (admin) + +#[tokio::test] +async fn update_user_requires_admin() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(patch_json_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + r#"{"role":"editor"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +// DELETE /api/v1/users/{id} (admin) + +#[tokio::test] +async fn delete_user_requires_admin() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(delete_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn delete_user_not_found() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(delete_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + &admin_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +// GET /api/v1/users/{id}/libraries (admin) + +#[tokio::test] +async fn get_user_libraries_requires_admin() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000/libraries", + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +// No-auth coverage (exercises setup_app and get helpers) + +#[tokio::test] +async fn media_list_no_auth_users_file() { + let app = setup_app().await; + let response = app.oneshot(get("/api/v1/media")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/crates/pinakes-server/tests/webhooks.rs b/crates/pinakes-server/tests/webhooks.rs new file mode 100644 index 0000000..7171e1b --- /dev/null +++ b/crates/pinakes-server/tests/webhooks.rs @@ -0,0 +1,90 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + get, + get_authed, + post_json_authed, + response_body, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +// GET /api/v1/webhooks (viewer) + +#[tokio::test] +async fn list_webhooks_requires_auth() { + let (app, ..) = setup_app_with_auth().await; + let response = app.oneshot(get("/api/v1/webhooks")).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn list_webhooks_viewer_ok() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed("/api/v1/webhooks", &viewer_token)) + .await + .unwrap(); + let status = response.status(); + let body = response_body(response).await; + assert_eq!(status, StatusCode::OK); + // No webhooks configured in test config: empty array + assert!(body.is_array(), "expected array, got: {body}"); + assert_eq!(body.as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn list_webhooks_no_auth_disabled_ok() { + // Auth disabled (setup_app): viewer-level route still accessible + let app = setup_app().await; + let response = app.oneshot(get("/api/v1/webhooks")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +// POST /api/v1/webhooks/test (editor) + +#[tokio::test] +async fn test_webhook_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/webhooks/test", + "{}", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_webhook_no_dispatcher_returns_ok() { + // No webhook dispatcher in test setup; route should return 200 with + // "no webhooks configured" message rather than erroring. + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/webhooks/test", + "{}", + &editor_token, + )) + .await + .unwrap(); + // Either OK or the route returns a structured response about no webhooks + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::BAD_REQUEST + ); +} + +#[tokio::test] +async fn test_webhook_requires_auth() { + let (app, ..) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed("/api/v1/webhooks/test", "{}", "badtoken")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +}