meta: move public crates to packages/

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I928162008cb1ba02e1aa0e7aa971e8326a6a6964
This commit is contained in:
raf 2026-03-23 02:32:37 +03:00
commit 00bab69598
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
308 changed files with 53890 additions and 53889 deletions

View file

@ -0,0 +1,738 @@
mod common;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use common::{
delete_authed,
get,
get_authed,
patch_json_authed,
post_json,
post_json_authed,
put_json_authed,
response_body,
setup_app,
setup_app_with_auth,
test_addr,
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_list_media_empty() {
let app = setup_app().await;
let response = app.oneshot(get("/api/v1/media")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let items: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(items.len(), 0);
}
#[tokio::test]
async fn test_create_and_list_tags() {
let app = setup_app().await;
// Create a tag
let response = app
.clone()
.oneshot(post_json("/api/v1/tags", r#"{"name":"Music"}"#))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// List tags
let response = app.oneshot(get("/api/v1/tags")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let tags: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0]["name"], "Music");
}
#[tokio::test]
async fn test_search_empty() {
let app = setup_app().await;
let response = app.oneshot(get("/api/v1/search?q=test")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["total_count"], 0);
}
#[tokio::test]
async fn test_media_not_found() {
let app = setup_app().await;
let response = app
.oneshot(get("/api/v1/media/00000000-0000-0000-0000-000000000000"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_collections_crud() {
let app = setup_app().await;
// Create collection
let response = app
.clone()
.oneshot(post_json(
"/api/v1/collections",
r#"{"name":"Favorites","kind":"manual"}"#,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// List collections
let response = app.oneshot(get("/api/v1/collections")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let cols: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(cols.len(), 1);
assert_eq!(cols[0]["name"], "Favorites");
}
#[tokio::test]
async fn test_statistics_endpoint() {
let app = setup_app().await;
let response = app.oneshot(get("/api/v1/statistics")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let stats: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(stats["total_media"], 0);
assert_eq!(stats["total_size_bytes"], 0);
}
#[tokio::test]
async fn test_scheduled_tasks_endpoint() {
let app = setup_app().await;
let response = app.oneshot(get("/api/v1/tasks/scheduled")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let tasks: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert!(!tasks.is_empty(), "should have default scheduled tasks");
// Verify structure of first task
assert!(tasks[0]["id"].is_string());
assert!(tasks[0]["name"].is_string());
assert!(tasks[0]["schedule"].is_string());
}
#[tokio::test]
async fn test_user_management_crud() {
let app = setup_app().await;
// Create a user
let response = app
.clone()
.oneshot(post_json(
"/api/v1/users",
r#"{"username":"testuser","password":"password123","role":"viewer"}"#,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let user: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(user["username"], "testuser");
assert_eq!(user["role"], "viewer");
let user_id = user["id"].as_str().unwrap();
// List users
let response = app.clone().oneshot(get("/api/v1/users")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let users: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(users.len(), 1);
assert_eq!(users[0]["username"], "testuser");
// Get specific user
let response = app
.clone()
.oneshot(get(&format!("/api/v1/users/{user_id}")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let retrieved_user: serde_json::Value =
serde_json::from_slice(&body).unwrap();
assert_eq!(retrieved_user["username"], "testuser");
// Delete user
let mut req = Request::builder()
.method("DELETE")
.uri(format!("/api/v1/users/{user_id}"))
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(test_addr());
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Verify user is deleted
let response = app
.oneshot(get(&format!("/api/v1/users/{user_id}")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_health_endpoint() {
let app = setup_app().await;
// Health endpoint should be publicly accessible
let response = app.oneshot(get("/api/v1/health")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_user_duplicate_username() {
let app = setup_app().await;
// Create first user
let response = app
.clone()
.oneshot(post_json(
"/api/v1/users",
r#"{"username":"duplicate","password":"password1","role":"viewer"}"#,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Try to create user with same username
let response = app
.oneshot(post_json(
"/api/v1/users",
r#"{"username":"duplicate","password":"password2","role":"viewer"}"#,
))
.await
.unwrap();
// Should fail with conflict (409) for duplicate username
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_unauthenticated_request_rejected() {
let (app, ..) = setup_app_with_auth().await;
// Request without Bearer token
let response = app.oneshot(get("/api/v1/media")).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_invalid_token_rejected() {
let (app, ..) = setup_app_with_auth().await;
let response = app
.oneshot(get_authed("/api/v1/media", "totally-invalid-token"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_login_valid_credentials() {
let (app, ..) = setup_app_with_auth().await;
let response = app
.oneshot(post_json(
"/api/v1/auth/login",
r#"{"username":"admin","password":"adminpass"}"#,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert!(body["token"].is_string());
assert_eq!(body["username"], "admin");
assert_eq!(body["role"], "admin");
}
#[tokio::test]
async fn test_login_invalid_password() {
let (app, ..) = setup_app_with_auth().await;
let response = app
.oneshot(post_json(
"/api/v1/auth/login",
r#"{"username":"admin","password":"wrongpassword"}"#,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_login_unknown_user() {
let (app, ..) = setup_app_with_auth().await;
let response = app
.oneshot(post_json(
"/api/v1/auth/login",
r#"{"username":"nonexistent","password":"whatever"}"#,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_auth_me_endpoint() {
let (app, admin_token, ..) = setup_app_with_auth().await;
let response = app
.oneshot(get_authed("/api/v1/auth/me", &admin_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert_eq!(body["username"], "admin");
assert_eq!(body["role"], "admin");
}
#[tokio::test]
async fn test_logout() {
let (app, admin_token, ..) = setup_app_with_auth().await;
// Logout
let response = app
.clone()
.oneshot(post_json_authed("/api/v1/auth/logout", "", &admin_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Subsequent requests with same token should fail
let response = app
.oneshot(get_authed("/api/v1/media", &admin_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_viewer_cannot_access_editor_routes() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
// POST /tags is an editor-only route
let response = app
.oneshot(post_json_authed(
"/api/v1/tags",
r#"{"name":"test"}"#,
&viewer_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_viewer_cannot_access_admin_routes() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
// GET /users is an admin-only route
let response = app
.oneshot(get_authed("/api/v1/users", &viewer_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_editor_cannot_access_admin_routes() {
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 test_editor_can_write() {
let (app, _, editor_token, _) = setup_app_with_auth().await;
let response = app
.oneshot(post_json_authed(
"/api/v1/tags",
r#"{"name":"EditorTag"}"#,
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_admin_can_access_all() {
let (app, admin_token, ..) = setup_app_with_auth().await;
// Viewer route
let response = app
.clone()
.oneshot(get_authed("/api/v1/media", &admin_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Editor route
let response = app
.clone()
.oneshot(post_json_authed(
"/api/v1/tags",
r#"{"name":"AdminTag"}"#,
&admin_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Admin route
let response = app
.oneshot(get_authed("/api/v1/users", &admin_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_rating_invalid_stars_zero() {
let (app, _, editor_token, _) = setup_app_with_auth().await;
let response = app
.oneshot(post_json_authed(
"/api/v1/media/00000000-0000-0000-0000-000000000000/ratings",
r#"{"stars":0}"#,
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_rating_invalid_stars_six() {
let (app, _, editor_token, _) = setup_app_with_auth().await;
let response = app
.oneshot(post_json_authed(
"/api/v1/media/00000000-0000-0000-0000-000000000000/ratings",
r#"{"stars":6}"#,
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_comment_empty_text() {
let (app, _, editor_token, _) = setup_app_with_auth().await;
let response = app
.oneshot(post_json_authed(
"/api/v1/media/00000000-0000-0000-0000-000000000000/comments",
r#"{"text":""}"#,
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_favorites_list_empty() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
let response = app
.oneshot(get_authed("/api/v1/favorites", &viewer_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert!(body.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_playlist_crud() {
let (app, _, editor_token, _) = setup_app_with_auth().await;
// Create
let response = app
.clone()
.oneshot(post_json_authed(
"/api/v1/playlists",
r#"{"name":"My Playlist"}"#,
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let playlist_id = body["id"].as_str().unwrap().to_string();
assert_eq!(body["name"], "My Playlist");
// List
let response = app
.clone()
.oneshot(get_authed("/api/v1/playlists", &editor_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert_eq!(body.as_array().unwrap().len(), 1);
// Get
let response = app
.clone()
.oneshot(get_authed(
&format!("/api/v1/playlists/{playlist_id}"),
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Update
let response = app
.clone()
.oneshot(patch_json_authed(
&format!("/api/v1/playlists/{playlist_id}"),
r#"{"name":"Updated Playlist","description":"A test description"}"#,
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert_eq!(body["name"], "Updated Playlist");
// Delete
let response = app
.clone()
.oneshot(delete_authed(
&format!("/api/v1/playlists/{playlist_id}"),
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_playlist_empty_name() {
let (app, _, editor_token, _) = setup_app_with_auth().await;
let response = app
.oneshot(post_json_authed(
"/api/v1/playlists",
r#"{"name":""}"#,
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_most_viewed_empty() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
let response = app
.oneshot(get_authed("/api/v1/analytics/most-viewed", &viewer_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert!(body.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_record_event_and_query() {
let (app, _, editor_token, _) = setup_app_with_auth().await;
// Record an event
let response = app
.clone()
.oneshot(post_json_authed(
"/api/v1/analytics/events",
r#"{"event_type":"view","duration_secs":5.0}"#,
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert_eq!(body["recorded"], true);
}
#[tokio::test]
async fn test_transcode_session_not_found() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
let response = app
.oneshot(get_authed(
"/api/v1/transcode/00000000-0000-0000-0000-000000000000",
&viewer_token,
))
.await
.unwrap();
// Should be 404 or 500 (not found in DB)
assert!(
response.status() == StatusCode::NOT_FOUND
|| response.status() == StatusCode::INTERNAL_SERVER_ERROR
);
}
#[tokio::test]
async fn test_transcode_list_empty() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
let response = app
.oneshot(get_authed("/api/v1/transcode", &viewer_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert!(body.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_hls_segment_no_session() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
let response = app
.oneshot(get_authed(
"/api/v1/media/00000000-0000-0000-0000-000000000000/stream/hls/720p/\
segment0.ts",
&viewer_token,
))
.await
.unwrap();
// Should fail because media doesn't exist or no transcode session
assert!(
response.status() == StatusCode::BAD_REQUEST
|| response.status() == StatusCode::NOT_FOUND
|| response.status() == StatusCode::INTERNAL_SERVER_ERROR
);
}
#[tokio::test]
async fn test_subtitles_list() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
// Should return empty for nonexistent media (or not found)
let response = app
.oneshot(get_authed(
"/api/v1/media/00000000-0000-0000-0000-000000000000/subtitles",
&viewer_token,
))
.await
.unwrap();
assert!(
response.status() == StatusCode::OK
|| response.status() == StatusCode::NOT_FOUND
|| response.status() == StatusCode::INTERNAL_SERVER_ERROR
);
}
#[tokio::test]
async fn test_health_public() {
let (app, ..) = setup_app_with_auth().await;
// Health endpoint should be accessible without auth even when accounts
// enabled
let response = app.oneshot(get("/api/v1/health")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_invalid_uuid_in_path() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
let response = app
.oneshot(get_authed("/api/v1/media/not-a-uuid", &viewer_token))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_oversized_comment() {
let (app, _, editor_token, _) = setup_app_with_auth().await;
let long_text: String = "x".repeat(10_001);
let body = format!(r#"{{"text":"{long_text}"}}"#);
let response = app
.oneshot(post_json_authed(
"/api/v1/media/00000000-0000-0000-0000-000000000000/comments",
&body,
&editor_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_share_link_expired() {
// Uses no-auth setup since share links are complex to test with auth
// (need real media items). Verify the expire check logic works.
let app = setup_app().await;
// First import a dummy file to get a media_id, but we can't without a real
// file. So let's test the public share access endpoint with a nonexistent
// token.
let response = app
.oneshot(get("/api/v1/s/nonexistent_token"))
.await
.unwrap();
// Should fail with not found or internal error (no such share link)
assert!(
response.status() == StatusCode::NOT_FOUND
|| response.status() == StatusCode::INTERNAL_SERVER_ERROR
);
}
#[tokio::test]
async fn test_update_sync_device_requires_editor() {
let (app, _, _, viewer_token) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let response = app
.clone()
.oneshot(put_json_authed(
&format!("/api/v1/sync/devices/{fake_id}"),
r#"{"name":"renamed"}"#,
&viewer_token,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

View file

@ -0,0 +1,150 @@
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,
};
use tower::ServiceExt;
#[tokio::test]
async fn list_books_empty() {
let app = setup_app().await;
let resp = app.oneshot(get("/api/v1/books")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await;
let items = body.as_array().expect("array response");
assert!(items.is_empty());
}
#[tokio::test]
async fn get_book_metadata_not_found() {
let app = setup_app().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.oneshot(get(&format!("/api/v1/books/{fake_id}/metadata")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn list_books_with_filters() {
let app = setup_app().await;
let resp = app
.oneshot(get("/api/v1/books?author=Tolkien&limit=10"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn list_series_empty() {
let app = setup_app().await;
let resp = app.oneshot(get("/api/v1/books/series")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn list_authors_empty() {
let app = setup_app().await;
let resp = app
.oneshot(get("/api/v1/books/authors?offset=0&limit=50"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn reading_progress_nonexistent_book() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(get_authed(
&format!("/api/v1/books/{fake_id}/progress"),
&viewer,
))
.await
.unwrap();
// Nonexistent book always returns 404.
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn update_reading_progress_nonexistent_book() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(put_json_authed(
&format!("/api/v1/books/{fake_id}/progress"),
r#"{"current_page":42}"#,
&viewer,
))
.await
.unwrap();
// Nonexistent book: handler verifies existence first, so always 404.
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn reading_list_empty() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/books/reading-list", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn import_media_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(post_json_authed(
"/api/v1/media/import",
r#"{"path":"/tmp/test.txt"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_media_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(patch_json_authed(
&format!("/api/v1/media/{fake_id}"),
r#"{"title":"new"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_media_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(delete_authed(&format!("/api/v1/media/{fake_id}"), &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

View file

@ -0,0 +1,324 @@
use std::{net::SocketAddr, sync::Arc};
use axum::{
body::Body,
extract::ConnectInfo,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use pinakes_core::{
cache::CacheLayer,
config::{
AccountsConfig,
AnalyticsConfig,
CloudConfig,
Config,
DirectoryConfig,
EnrichmentConfig,
JobsConfig,
ManagedStorageConfig,
PhotoConfig,
PluginsConfig,
RateLimitConfig,
ScanningConfig,
ServerConfig,
SharingConfig,
SqliteConfig,
StorageBackendType,
StorageConfig,
SyncConfig,
ThumbnailConfig,
TlsConfig,
TranscodingConfig,
TrashConfig,
UiConfig,
UserAccount,
UserRole,
WebhookConfig,
},
jobs::JobQueue,
storage::{StorageBackend, sqlite::SqliteBackend},
};
use tokio::sync::RwLock;
use tower::ServiceExt;
/// Fake socket address for tests (governor needs
/// `ConnectInfo<SocketAddr>`)
pub fn test_addr() -> ConnectInfo<SocketAddr> {
ConnectInfo("127.0.0.1:9999".parse().unwrap())
}
/// Build a GET request with `ConnectInfo` for rate limiter
/// compatibility
pub fn get(uri: &str) -> Request<Body> {
let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap();
req.extensions_mut().insert(test_addr());
req
}
/// Build a POST request with `ConnectInfo`
pub fn post_json(uri: &str, body: &str) -> Request<Body> {
let mut req = Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
req.extensions_mut().insert(test_addr());
req
}
/// Build a GET request with Bearer auth
pub fn get_authed(uri: &str, token: &str) -> Request<Body> {
let mut req = Request::builder()
.uri(uri)
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(test_addr());
req
}
/// Build a POST JSON request with Bearer auth
pub fn post_json_authed(uri: &str, body: &str, token: &str) -> Request<Body> {
let mut req = Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))
.unwrap();
req.extensions_mut().insert(test_addr());
req
}
/// Build a PUT JSON request with Bearer auth
pub fn put_json_authed(uri: &str, body: &str, token: &str) -> Request<Body> {
let mut req = Request::builder()
.method("PUT")
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))
.unwrap();
req.extensions_mut().insert(test_addr());
req
}
/// Build a DELETE request with Bearer auth
pub fn delete_authed(uri: &str, token: &str) -> Request<Body> {
let mut req = Request::builder()
.method("DELETE")
.uri(uri)
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(test_addr());
req
}
/// Build a PATCH JSON request with Bearer auth
pub fn patch_json_authed(uri: &str, body: &str, token: &str) -> Request<Body> {
let mut req = Request::builder()
.method("PATCH")
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))
.unwrap();
req.extensions_mut().insert(test_addr());
req
}
pub fn default_config() -> Config {
Config {
storage: StorageConfig {
backend: StorageBackendType::Sqlite,
sqlite: Some(SqliteConfig {
path: ":memory:".into(),
}),
postgres: None,
},
directories: DirectoryConfig { roots: vec![] },
scanning: ScanningConfig {
watch: false,
poll_interval_secs: 300,
ignore_patterns: vec![],
import_concurrency: 8,
},
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 3000,
api_key: None,
tls: TlsConfig::default(),
authentication_disabled: true,
cors_enabled: false,
cors_origins: vec![],
swagger_ui: false,
},
rate_limits: RateLimitConfig::default(),
ui: UiConfig::default(),
accounts: AccountsConfig::default(),
jobs: JobsConfig::default(),
thumbnails: ThumbnailConfig::default(),
webhooks: Vec::<WebhookConfig>::new(),
scheduled_tasks: vec![],
plugins: PluginsConfig::default(),
transcoding: TranscodingConfig::default(),
enrichment: EnrichmentConfig::default(),
cloud: CloudConfig::default(),
analytics: AnalyticsConfig::default(),
photos: PhotoConfig::default(),
managed_storage: ManagedStorageConfig::default(),
sync: SyncConfig::default(),
sharing: SharingConfig::default(),
trash: TrashConfig::default(),
}
}
pub async fn setup_app() -> axum::Router {
let backend = SqliteBackend::in_memory().expect("in-memory SQLite");
backend.run_migrations().await.expect("migrations");
let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend;
let config = default_config();
let job_queue =
JobQueue::new(1, 0, |_id, _kind, _cancel, _jobs| tokio::spawn(async {}));
let config = Arc::new(RwLock::new(config));
let scheduler = pinakes_core::scheduler::TaskScheduler::new(
job_queue.clone(),
tokio_util::sync::CancellationToken::new(),
config.clone(),
None,
);
let state = pinakes_server::state::AppState {
storage,
config,
config_path: None,
scan_progress: pinakes_core::scan::ScanProgress::new(),
job_queue,
cache: Arc::new(CacheLayer::new(60)),
scheduler: Arc::new(scheduler),
plugin_manager: None,
plugin_pipeline: None,
transcode_service: None,
managed_storage: None,
chunked_upload_manager: None,
session_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
webhook_dispatcher: None,
};
pinakes_server::app::create_router(state, &RateLimitConfig::default())
}
/// Hash a password for test user accounts
pub fn hash_password(password: &str) -> String {
pinakes_core::users::auth::hash_password(password).unwrap()
}
/// Set up an app with accounts enabled and three pre-seeded users.
/// Returns (Router, `admin_token`, `editor_token`, `viewer_token`).
pub async fn setup_app_with_auth() -> (axum::Router, String, String, String) {
let backend = SqliteBackend::in_memory().expect("in-memory SQLite");
backend.run_migrations().await.expect("migrations");
let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend;
let users_to_create = vec![
("admin", "adminpass", UserRole::Admin),
("editor", "editorpass", UserRole::Editor),
("viewer", "viewerpass", UserRole::Viewer),
];
for (username, password, role) in &users_to_create {
let password_hash = hash_password(password);
storage
.create_user(username, &password_hash, *role, None)
.await
.expect("create user");
}
let mut config = default_config();
config.server.authentication_disabled = false;
config.accounts.enabled = true;
config.accounts.users = vec![
UserAccount {
username: "admin".to_string(),
password_hash: hash_password("adminpass"),
role: UserRole::Admin,
},
UserAccount {
username: "editor".to_string(),
password_hash: hash_password("editorpass"),
role: UserRole::Editor,
},
UserAccount {
username: "viewer".to_string(),
password_hash: hash_password("viewerpass"),
role: UserRole::Viewer,
},
];
let job_queue =
JobQueue::new(1, 0, |_id, _kind, _cancel, _jobs| tokio::spawn(async {}));
let config = Arc::new(RwLock::new(config));
let scheduler = pinakes_core::scheduler::TaskScheduler::new(
job_queue.clone(),
tokio_util::sync::CancellationToken::new(),
config.clone(),
None,
);
let state = pinakes_server::state::AppState {
storage,
config,
config_path: None,
scan_progress: pinakes_core::scan::ScanProgress::new(),
job_queue,
cache: Arc::new(CacheLayer::new(60)),
scheduler: Arc::new(scheduler),
plugin_manager: None,
plugin_pipeline: None,
transcode_service: None,
managed_storage: None,
chunked_upload_manager: None,
session_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
webhook_dispatcher: None,
};
let app =
pinakes_server::app::create_router(state, &RateLimitConfig::default());
let admin_token = login_user(app.clone(), "admin", "adminpass").await;
let editor_token = login_user(app.clone(), "editor", "editorpass").await;
let viewer_token = login_user(app.clone(), "viewer", "viewerpass").await;
(app, admin_token, editor_token, viewer_token)
}
pub async fn login_user(
app: axum::Router,
username: &str,
password: &str,
) -> String {
let body = format!(r#"{{"username":"{username}","password":"{password}"}}"#);
let response = app
.oneshot(post_json("/api/v1/auth/login", &body))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"login failed for user {username}"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
result["token"].as_str().unwrap().to_string()
}
pub async fn response_body(
response: axum::response::Response,
) -> serde_json::Value {
let body = response.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null)
}

View file

@ -0,0 +1,221 @@
/// 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;
use tower::ServiceExt;
/// 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<SocketAddr>` 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::<SocketAddr>(),
)
.await
.unwrap();
});
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]
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);
}
#[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);
}

View file

@ -0,0 +1,210 @@
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,
};
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
);
}
// 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);
}

View file

@ -0,0 +1,145 @@
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,
};
use tower::ServiceExt;
#[tokio::test]
async fn media_count_empty() {
let app = setup_app().await;
let resp = app.oneshot(get("/api/v1/media/count")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await;
assert_eq!(body["count"], 0);
}
#[tokio::test]
async fn batch_delete_empty_ids() {
let (app, admin, ..) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(post_json_authed(
"/api/v1/media/batch/delete",
r#"{"ids":[]}"#,
&admin,
))
.await
.unwrap();
// Empty ids should be rejected (validation requires 1+ items)
assert!(
resp.status() == StatusCode::BAD_REQUEST
|| resp.status() == StatusCode::UNPROCESSABLE_ENTITY
);
}
#[tokio::test]
async fn batch_delete_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let body = format!(r#"{{"ids":["{fake_id}"]}}"#);
let resp = app
.clone()
.oneshot(post_json_authed(
"/api/v1/media/batch/delete",
&body,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn list_trash_empty() {
let (app, _, editor, _) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/trash?offset=0&limit=50", &editor))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await;
assert_eq!(body["total_count"], 0);
let items = body["items"].as_array().expect("items array");
assert!(items.is_empty());
}
#[tokio::test]
async fn batch_tag_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(post_json_authed(
"/api/v1/media/batch/tag",
r#"{"media_ids":[],"tag_ids":[]}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn list_trash_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/trash", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn rename_media_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(patch_json_authed(
&format!("/api/v1/media/{fake_id}/rename"),
r#"{"new_name":"renamed.txt"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn permanent_delete_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(delete_authed(&format!("/api/v1/media/{fake_id}"), &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_sync_device_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(put_json_authed(
&format!("/api/v1/sync/devices/{fake_id}"),
r#"{"name":"renamed"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

View file

@ -0,0 +1,150 @@
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,
};
use tower::ServiceExt;
#[tokio::test]
async fn backlinks_for_nonexistent_media() {
let app = setup_app().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.oneshot(get(&format!("/api/v1/media/{fake_id}/backlinks")))
.await
.unwrap();
// Should return OK with empty list, or NOT_FOUND
assert!(
resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn outgoing_links_for_nonexistent_media() {
let app = setup_app().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.oneshot(get(&format!("/api/v1/media/{fake_id}/outgoing-links")))
.await
.unwrap();
assert!(
resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn notes_graph_empty() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/notes/graph", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await;
// Fresh database: graph must be empty.
if let Some(arr) = body.as_array() {
assert!(arr.is_empty(), "graph should be empty, got {arr:?}");
} else if let Some(obj) = body.as_object() {
// Accept an object if the schema uses {nodes:[], edges:[]} style.
let nodes_empty = obj
.get("nodes")
.and_then(|v| v.as_array())
.is_none_or(std::vec::Vec::is_empty);
let edges_empty = obj
.get("edges")
.and_then(|v| v.as_array())
.is_none_or(std::vec::Vec::is_empty);
assert!(
nodes_empty && edges_empty,
"graph should be empty, got {obj:?}"
);
} else {
panic!("expected array or object, got {body}");
}
}
#[tokio::test]
async fn unresolved_count_zero() {
let app = setup_app().await;
let resp = app
.oneshot(get("/api/v1/notes/unresolved-count"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await;
// Fresh database has no unresolved links.
let count = body["count"]
.as_u64()
.expect("response should have a numeric 'count' field");
assert_eq!(count, 0, "expected zero unresolved links in fresh database");
}
#[tokio::test]
async fn reindex_links_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(post_json_authed(
&format!("/api/v1/media/{fake_id}/reindex-links"),
"{}",
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_media_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(patch_json_authed(
&format!("/api/v1/media/{fake_id}"),
r#"{"title":"new title"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_media_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(delete_authed(&format!("/api/v1/media/{fake_id}"), &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_sync_device_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(put_json_authed(
&format!("/api/v1/sync/devices/{fake_id}"),
r#"{"name":"renamed"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

View file

@ -0,0 +1,274 @@
mod common;
use std::sync::Arc;
use axum::{body::Body, http::StatusCode};
use common::{
default_config,
delete_authed,
get,
get_authed,
patch_json_authed,
post_json,
post_json_authed,
put_json_authed,
response_body,
setup_app,
setup_app_with_auth,
test_addr,
};
use http_body_util::BodyExt;
use pinakes_core::{config::PluginsConfig, plugin::PluginManager};
use tower::ServiceExt;
async fn setup_app_with_plugins()
-> (axum::Router, Arc<PluginManager>, tempfile::TempDir) {
use pinakes_core::{
cache::CacheLayer,
config::RateLimitConfig,
jobs::JobQueue,
storage::{StorageBackend, sqlite::SqliteBackend},
};
use tokio::sync::RwLock;
let backend = SqliteBackend::in_memory().expect("in-memory SQLite");
backend.run_migrations().await.expect("migrations");
let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend;
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let data_dir = temp_dir.path().join("data");
let cache_dir = temp_dir.path().join("cache");
std::fs::create_dir_all(&data_dir).expect("create data dir");
std::fs::create_dir_all(&cache_dir).expect("create cache dir");
let plugin_config = PluginsConfig {
enabled: true,
data_dir: data_dir.clone(),
cache_dir: cache_dir.clone(),
plugin_dirs: vec![],
enable_hot_reload: false,
allow_unsigned: true,
max_concurrent_ops: 2,
plugin_timeout_secs: 10,
timeouts:
pinakes_core::config::PluginTimeoutConfig::default(),
max_consecutive_failures: 5,
trusted_keys: vec![],
};
let plugin_manager =
PluginManager::new(data_dir, cache_dir, plugin_config.clone().into())
.expect("create plugin manager");
let plugin_manager = Arc::new(plugin_manager);
let mut config = default_config();
config.plugins = plugin_config;
let job_queue =
JobQueue::new(1, 0, |_id, _kind, _cancel, _jobs| tokio::spawn(async {}));
let config = Arc::new(RwLock::new(config));
let scheduler = pinakes_core::scheduler::TaskScheduler::new(
job_queue.clone(),
tokio_util::sync::CancellationToken::new(),
config.clone(),
None,
);
let state = pinakes_server::state::AppState {
storage,
config,
config_path: None,
scan_progress: pinakes_core::scan::ScanProgress::new(),
job_queue,
cache: Arc::new(CacheLayer::new(60)),
scheduler: Arc::new(scheduler),
plugin_manager: Some(plugin_manager.clone()),
plugin_pipeline: None,
transcode_service: None,
managed_storage: None,
chunked_upload_manager: None,
session_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
webhook_dispatcher: None,
};
let router =
pinakes_server::app::create_router(state, &RateLimitConfig::default());
(router, plugin_manager, temp_dir)
}
#[tokio::test]
async fn test_list_plugins_empty() {
let (app, _pm, _tmp) = setup_app_with_plugins().await;
let response = app.oneshot(get("/api/v1/plugins")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let plugins: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(plugins.len(), 0, "should start with no plugins loaded");
}
#[tokio::test]
async fn test_plugin_manager_exists() {
let (app, _pm, _tmp) = setup_app_with_plugins().await;
let plugins = _pm.list_plugins().await;
assert_eq!(plugins.len(), 0);
let response = app.oneshot(get("/api/v1/plugins")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_plugin_not_found() {
let (app, _pm, _tmp) = setup_app_with_plugins().await;
let response = app
.oneshot(get("/api/v1/plugins/nonexistent"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_plugin_enable_disable() {
let (app, pm, _tmp) = setup_app_with_plugins().await;
assert!(pm.list_plugins().await.is_empty());
let mut req = axum::http::Request::builder()
.method("POST")
.uri("/api/v1/plugins/test-plugin/enable")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(test_addr());
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let mut req = axum::http::Request::builder()
.method("POST")
.uri("/api/v1/plugins/test-plugin/disable")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(test_addr());
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_plugin_uninstall_not_found() {
let (app, _pm, _tmp) = setup_app_with_plugins().await;
let mut req = axum::http::Request::builder()
.method("DELETE")
.uri("/api/v1/plugins/nonexistent")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(test_addr());
let response = app.oneshot(req).await.unwrap();
assert!(
response.status() == StatusCode::BAD_REQUEST
|| response.status() == StatusCode::NOT_FOUND
);
}
// RBAC tests using common helpers with auth setup
#[tokio::test]
async fn media_list_unauthenticated() {
let app = setup_app().await;
let resp = app.oneshot(get("/api/v1/media")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await;
assert!(body.is_array());
}
#[tokio::test]
async fn media_list_authenticated() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/media", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn import_unauthenticated_rejected() {
let (app, ..) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(post_json(
"/api/v1/media/import",
r#"{"path":"/tmp/test.txt"}"#,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn import_viewer_forbidden() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(post_json_authed(
"/api/v1/media/import",
r#"{"path":"/tmp/test.txt"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_media_viewer_forbidden() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(patch_json_authed(
&format!("/api/v1/media/{fake_id}"),
r#"{"title":"new"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_media_viewer_forbidden() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(delete_authed(&format!("/api/v1/media/{fake_id}"), &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_sync_device_viewer_forbidden() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(put_json_authed(
&format!("/api/v1/sync/devices/{fake_id}"),
r#"{"name":"renamed"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

View file

@ -0,0 +1,142 @@
mod common;
use axum::http::StatusCode;
use common::{
delete_authed,
get,
get_authed,
patch_json_authed,
post_json,
post_json_authed,
put_json_authed,
response_body,
setup_app,
setup_app_with_auth,
};
use tower::ServiceExt;
#[tokio::test]
async fn list_outgoing_shares_empty() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/shares/outgoing", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await;
let shares = body.as_array().expect("array response");
assert!(shares.is_empty());
}
#[tokio::test]
async fn list_incoming_shares_empty() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/shares/incoming", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn share_notifications_empty() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/notifications/shares", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn batch_delete_shares_requires_auth() {
let (app, ..) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(post_json("/api/v1/shares/batch/delete", r#"{"ids":[]}"#))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn batch_delete_shares_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(post_json_authed(
"/api/v1/shares/batch/delete",
r#"{"ids":[]}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_share_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let body = format!(r#"{{"media_id":"{fake_id}","share_type":"link"}}"#);
let resp = app
.clone()
.oneshot(post_json_authed("/api/v1/shares", &body, &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_share_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(patch_json_authed(
&format!("/api/v1/shares/{fake_id}"),
r#"{"permissions":["read"]}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_share_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(delete_authed(&format!("/api/v1/shares/{fake_id}"), &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_sync_device_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(put_json_authed(
&format!("/api/v1/sync/devices/{fake_id}"),
r#"{"name":"renamed"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn media_list_no_auth() {
let app = setup_app().await;
let resp = app.oneshot(get("/api/v1/media")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}

View file

@ -0,0 +1,137 @@
mod common;
use axum::http::StatusCode;
use common::{
delete_authed,
get,
get_authed,
patch_json_authed,
post_json,
post_json_authed,
put_json_authed,
response_body,
setup_app,
setup_app_with_auth,
};
use tower::ServiceExt;
#[tokio::test]
async fn list_sync_devices_empty() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/sync/devices", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await;
let devices = body.as_array().expect("array response");
assert!(devices.is_empty());
}
#[tokio::test]
async fn get_changes_sync_disabled() {
// Default config has sync.enabled = false; endpoint should reject
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/sync/changes", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn list_conflicts_requires_device_token() {
// list_conflicts requires X-Device-Token header; omitting it returns 400
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(get_authed("/api/v1/sync/conflicts", &viewer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn register_device_requires_auth() {
let (app, ..) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(post_json(
"/api/v1/sync/devices",
r#"{"name":"test","device_type":"desktop","client_version":"0.3.0"}"#,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn register_device_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let resp = app
.clone()
.oneshot(post_json_authed(
"/api/v1/sync/devices",
r#"{"name":"test","device_type":"desktop","client_version":"0.3.0"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_device_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(put_json_authed(
&format!("/api/v1/sync/devices/{fake_id}"),
r#"{"name":"renamed"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_device_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(delete_authed(
&format!("/api/v1/sync/devices/{fake_id}"),
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_media_requires_editor() {
let (app, _, _, viewer) = setup_app_with_auth().await;
let fake_id = uuid::Uuid::now_v7();
let resp = app
.clone()
.oneshot(patch_json_authed(
&format!("/api/v1/media/{fake_id}"),
r#"{"title":"new"}"#,
&viewer,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn media_list_no_auth() {
let app = setup_app().await;
let resp = app.oneshot(get("/api/v1/media")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}

View file

@ -0,0 +1,234 @@
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,
};
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);
}
// 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]
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);
}

View file

@ -0,0 +1,136 @@
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,
};
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);
}
// 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);
}