pinakes-server: add more route tests
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ief16a2b3181bfa50193fb69a5ad4a9166a6a6964
This commit is contained in:
parent
db2d02a323
commit
cee172fcc3
5 changed files with 295 additions and 8 deletions
|
|
@ -81,7 +81,8 @@ pub async fn add_subtitle(
|
||||||
) -> Result<Json<SubtitleResponse>, ApiError> {
|
) -> Result<Json<SubtitleResponse>, ApiError> {
|
||||||
// Validate language code if provided.
|
// Validate language code if provided.
|
||||||
if let Some(ref lang) = req.language
|
if let Some(ref lang) = req.language
|
||||||
&& !validate_language_code(lang) {
|
&& !validate_language_code(lang)
|
||||||
|
{
|
||||||
return Err(ApiError(
|
return Err(ApiError(
|
||||||
pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()),
|
pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()),
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
/// End-to-end tests that bind a real TCP listener and exercise the HTTP layer.
|
/// 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
|
/// These tests differ from the router-level `oneshot` tests in that they verify
|
||||||
/// the full Axum `serve` path: `TcpListener` binding, HTTP framing, and response
|
/// the full Axum `serve` path: `TcpListener` binding, HTTP framing, and
|
||||||
/// serialization. Each test spins up a server on an ephemeral port, issues a
|
/// response serialization. Each test spins up a server on an ephemeral port,
|
||||||
/// real HTTP request via reqwest, then shuts down.
|
/// issues a real HTTP request via reqwest, then shuts down.
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
/// Bind a listener on an ephemeral port, spawn the server in the background,
|
/// Bind a listener on an ephemeral port, spawn the server in the background,
|
||||||
/// and return the bound address as a string.
|
/// and return the bound address as a string.
|
||||||
|
|
@ -30,6 +31,32 @@ async fn bind_and_serve() -> String {
|
||||||
format!("http://{addr}")
|
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::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
(
|
||||||
|
format!("http://{addr}"),
|
||||||
|
admin_token,
|
||||||
|
editor_token,
|
||||||
|
viewer_token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn health_endpoint_responds_over_real_tcp() {
|
async fn health_endpoint_responds_over_real_tcp() {
|
||||||
let base = bind_and_serve().await;
|
let base = bind_and_serve().await;
|
||||||
|
|
@ -63,3 +90,132 @@ async fn unknown_route_returns_404_over_real_tcp() {
|
||||||
.expect("request failed");
|
.expect("request failed");
|
||||||
assert_eq!(resp.status(), 404);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@ mod common;
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use common::{
|
use common::{
|
||||||
|
delete_authed,
|
||||||
get,
|
get,
|
||||||
get_authed,
|
get_authed,
|
||||||
|
patch_json_authed,
|
||||||
post_json_authed,
|
post_json_authed,
|
||||||
|
put_json_authed,
|
||||||
|
response_body,
|
||||||
setup_app,
|
setup_app,
|
||||||
setup_app_with_auth,
|
setup_app_with_auth,
|
||||||
};
|
};
|
||||||
|
|
@ -141,3 +145,66 @@ async fn get_external_metadata_auth_disabled() {
|
||||||
|| response.status() == StatusCode::NOT_FOUND
|
|| 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use common::{
|
||||||
get_authed,
|
get_authed,
|
||||||
patch_json_authed,
|
patch_json_authed,
|
||||||
post_json_authed,
|
post_json_authed,
|
||||||
|
put_json_authed,
|
||||||
response_body,
|
response_body,
|
||||||
setup_app,
|
setup_app,
|
||||||
setup_app_with_auth,
|
setup_app_with_auth,
|
||||||
|
|
@ -207,6 +208,22 @@ async fn get_user_libraries_requires_admin() {
|
||||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
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)
|
// No-auth coverage (exercises setup_app and get helpers)
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ mod common;
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use common::{
|
use common::{
|
||||||
|
delete_authed,
|
||||||
get,
|
get,
|
||||||
get_authed,
|
get_authed,
|
||||||
|
patch_json_authed,
|
||||||
post_json_authed,
|
post_json_authed,
|
||||||
|
put_json_authed,
|
||||||
response_body,
|
response_body,
|
||||||
setup_app,
|
setup_app,
|
||||||
setup_app_with_auth,
|
setup_app_with_auth,
|
||||||
|
|
@ -88,3 +91,46 @@ async fn test_webhook_requires_auth() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue