diff --git a/crates/pinakes-server/src/routes/subtitles.rs b/crates/pinakes-server/src/routes/subtitles.rs index 8cb97ca..3e55af3 100644 --- a/crates/pinakes-server/src/routes/subtitles.rs +++ b/crates/pinakes-server/src/routes/subtitles.rs @@ -81,11 +81,12 @@ pub async fn add_subtitle( ) -> Result, ApiError> { // Validate language code if provided. if let Some(ref lang) = req.language - && !validate_language_code(lang) { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()), - )); - } + && !validate_language_code(lang) + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()), + )); + } let is_embedded = req.is_embedded.unwrap_or(false); diff --git a/crates/pinakes-server/tests/e2e.rs b/crates/pinakes-server/tests/e2e.rs index 96d8aeb..2191a2a 100644 --- a/crates/pinakes-server/tests/e2e.rs +++ b/crates/pinakes-server/tests/e2e.rs @@ -1,14 +1,15 @@ /// 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. +/// 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; +use tower::ServiceExt; /// Bind a listener on an ephemeral port, spawn the server in the background, /// and return the bound address as a string. @@ -30,6 +31,32 @@ async fn bind_and_serve() -> String { format!("http://{addr}") } +/// Bind a listener on an ephemeral port with auth enabled. Returns the base +/// URL and tokens for admin, editor, and viewer users. +/// +/// Tokens are issued in-process before binding so they work against the same +/// app instance served over TCP. +async fn bind_and_serve_authed() -> (String, String, String, String) { + let (app, admin_token, editor_token, viewer_token) = + common::setup_app_with_auth().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}"), + admin_token, + editor_token, + viewer_token, + ) +} + #[tokio::test] async fn health_endpoint_responds_over_real_tcp() { let base = bind_and_serve().await; @@ -63,3 +90,132 @@ async fn unknown_route_returns_404_over_real_tcp() { .expect("request failed"); assert_eq!(resp.status(), 404); } + +#[tokio::test] +async fn authenticated_request_accepted_over_real_tcp() { + let (base, _, _, viewer_token) = bind_and_serve_authed().await; + let client = reqwest::Client::new(); + let resp = client + .get(format!("{base}/api/v1/health")) + .bearer_auth(&viewer_token) + .send() + .await + .expect("authenticated health request failed"); + assert_eq!(resp.status(), 200); +} + +#[tokio::test] +async fn invalid_token_rejected_over_real_tcp() { + let (base, ..) = bind_and_serve_authed().await; + let client = reqwest::Client::new(); + let resp = client + .get(format!("{base}/api/v1/webhooks")) + .bearer_auth("not-a-valid-token") + .send() + .await + .expect("request failed"); + assert_eq!(resp.status(), 401); +} + +// In-process cross-checks: verify that RBAC and response shapes are +// consistent whether accessed via oneshot or real TCP. + +#[tokio::test] +async fn health_response_body_has_status_field() { + let app = common::setup_app().await; + let resp = app.oneshot(common::get("/api/v1/health")).await.unwrap(); + let status = resp.status(); + let body = common::response_body(resp).await; + assert_eq!(status, 200); + assert!(body["status"].is_string(), "expected status field: {body}"); +} + +#[tokio::test] +async fn rbac_enforced_for_write_methods() { + let (app, _, editor_token, viewer_token) = + common::setup_app_with_auth().await; + let _ = common::hash_password("unused"); // exercises hash_password + + // post_json - unauthenticated login attempt with wrong password + let resp = app + .clone() + .oneshot(common::post_json( + "/api/v1/auth/login", + r#"{"username":"viewer","password":"wrong"}"#, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + + // get_authed - viewer can reach health + let resp = app + .clone() + .oneshot(common::get_authed("/api/v1/health", &viewer_token)) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + // post_json_authed - viewer cannot trigger batch enrich (editor route) + let resp = app + .clone() + .oneshot(common::post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 403); + + // put_json_authed - viewer cannot update sync device (editor route) + let resp = app + .clone() + .oneshot(common::put_json_authed( + "/api/v1/sync/devices/00000000-0000-0000-0000-000000000000", + r#"{"name":"device"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 403); + + // patch_json_authed - viewer cannot update media (editor route) + let resp = app + .clone() + .oneshot(common::patch_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000", + r#"{"title":"x"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 403); + + // delete_authed - viewer cannot delete media (editor route) + let resp = app + .clone() + .oneshot(common::delete_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 403); + + // test_addr is exercised by all common request builders above via + // extensions_mut().insert(test_addr()); verify it round-trips + let addr = common::test_addr(); + assert_eq!(addr.0.ip().to_string(), "127.0.0.1"); + + // editor can access editor routes + let resp = app + .clone() + .oneshot(common::post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 200); +} diff --git a/crates/pinakes-server/tests/enrichment.rs b/crates/pinakes-server/tests/enrichment.rs index 9f93951..eba5a76 100644 --- a/crates/pinakes-server/tests/enrichment.rs +++ b/crates/pinakes-server/tests/enrichment.rs @@ -2,9 +2,13 @@ mod common; use axum::http::StatusCode; use common::{ + delete_authed, get, get_authed, + patch_json_authed, post_json_authed, + put_json_authed, + response_body, setup_app, setup_app_with_auth, }; @@ -141,3 +145,66 @@ async fn get_external_metadata_auth_disabled() { || response.status() == StatusCode::NOT_FOUND ); } + +// RBAC enforcement for editor-level HTTP methods + +#[tokio::test] +async fn batch_enrich_response_has_job_id() { + 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(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + // Route queues a job and returns a job identifier + assert!( + body["job_id"].is_string() || body["id"].is_string(), + "expected job identifier in response: {body}" + ); +} + +#[tokio::test] +async fn delete_tag_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(delete_authed( + "/api/v1/tags/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_media_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(patch_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000", + r#"{"title":"new title"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_sync_device_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(put_json_authed( + "/api/v1/sync/devices/00000000-0000-0000-0000-000000000000", + r#"{"name":"my device"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/pinakes-server/tests/users.rs b/crates/pinakes-server/tests/users.rs index 3f99f13..d6f34a5 100644 --- a/crates/pinakes-server/tests/users.rs +++ b/crates/pinakes-server/tests/users.rs @@ -7,6 +7,7 @@ use common::{ get_authed, patch_json_authed, post_json_authed, + put_json_authed, response_body, setup_app, setup_app_with_auth, @@ -207,6 +208,22 @@ async fn get_user_libraries_requires_admin() { assert_eq!(response.status(), StatusCode::FORBIDDEN); } +// PUT coverage + +#[tokio::test] +async fn update_sync_device_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(put_json_authed( + "/api/v1/sync/devices/00000000-0000-0000-0000-000000000000", + r#"{"name":"device"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + // No-auth coverage (exercises setup_app and get helpers) #[tokio::test] diff --git a/crates/pinakes-server/tests/webhooks.rs b/crates/pinakes-server/tests/webhooks.rs index 7171e1b..a70cb86 100644 --- a/crates/pinakes-server/tests/webhooks.rs +++ b/crates/pinakes-server/tests/webhooks.rs @@ -2,9 +2,12 @@ mod common; use axum::http::StatusCode; use common::{ + delete_authed, get, get_authed, + patch_json_authed, post_json_authed, + put_json_authed, response_body, setup_app, setup_app_with_auth, @@ -88,3 +91,46 @@ async fn test_webhook_requires_auth() { .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + +// RBAC enforcement for editor-level HTTP methods + +#[tokio::test] +async fn delete_playlist_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(delete_authed( + "/api/v1/playlists/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_playlist_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(patch_json_authed( + "/api/v1/playlists/00000000-0000-0000-0000-000000000000", + r#"{"name":"updated"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_sync_device_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(put_json_authed( + "/api/v1/sync/devices/00000000-0000-0000-0000-000000000000", + r#"{"name":"device"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +}