From 30041c40c0c5ae5ba974537f3060ef169ebbd88c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 15:18:38 +0300 Subject: [PATCH] pinakes-server: add more integration tests Signed-off-by: NotAShelf Change-Id: I7c6c8eaad569404c7a13cfa8114d84516a6a6964 --- Cargo.lock | Bin 245898 -> 245917 bytes crates/pinakes-server/Cargo.toml | 3 +- crates/pinakes-server/tests/e2e.rs | 65 +++++++ crates/pinakes-server/tests/enrichment.rs | 143 ++++++++++++++ crates/pinakes-server/tests/users.rs | 217 ++++++++++++++++++++++ crates/pinakes-server/tests/webhooks.rs | 90 +++++++++ 6 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 crates/pinakes-server/tests/e2e.rs create mode 100644 crates/pinakes-server/tests/enrichment.rs create mode 100644 crates/pinakes-server/tests/users.rs create mode 100644 crates/pinakes-server/tests/webhooks.rs diff --git a/Cargo.lock b/Cargo.lock index fa3e1de05565c5e0ad4fc9f9b26b7b727eca831a..be62712a8248eae73b6453f7b5a138a0d1dcb60c 100644 GIT binary patch delta 41 xcmeBb` 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); +}