diff --git a/crates/pinakes-server/tests/api.rs b/crates/pinakes-server/tests/api.rs index e519f21..37c4e65 100644 --- a/crates/pinakes-server/tests/api.rs +++ b/crates/pinakes-server/tests/api.rs @@ -3,7 +3,19 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; -use common::*; +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; @@ -708,3 +720,19 @@ async fn test_share_link_expired() { || 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); +} diff --git a/crates/pinakes-server/tests/books.rs b/crates/pinakes-server/tests/books.rs new file mode 100644 index 0000000..dbd0bce --- /dev/null +++ b/crates/pinakes-server/tests/books.rs @@ -0,0 +1,158 @@ +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!( + resp.status() == StatusCode::NOT_FOUND + || resp.status() == StatusCode::INTERNAL_SERVER_ERROR + ); +} + +#[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; expect NOT_FOUND or empty response + assert!( + resp.status() == StatusCode::NOT_FOUND || resp.status() == StatusCode::OK + ); +} + +#[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; expect NOT_FOUND or error + assert!( + resp.status() == StatusCode::NOT_FOUND + || resp.status() == StatusCode::INTERNAL_SERVER_ERROR + ); +} + +#[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); +} diff --git a/crates/pinakes-server/tests/media_ops.rs b/crates/pinakes-server/tests/media_ops.rs new file mode 100644 index 0000000..a884c7c --- /dev/null +++ b/crates/pinakes-server/tests/media_ops.rs @@ -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); +} diff --git a/crates/pinakes-server/tests/notes.rs b/crates/pinakes-server/tests/notes.rs new file mode 100644 index 0000000..0dc246e --- /dev/null +++ b/crates/pinakes-server/tests/notes.rs @@ -0,0 +1,125 @@ +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; + assert!(body.is_object() || body.is_array()); +} + +#[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); +} + +#[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); +} diff --git a/crates/pinakes-server/tests/plugin.rs b/crates/pinakes-server/tests/plugin.rs index 889c1a5..287fef7 100644 --- a/crates/pinakes-server/tests/plugin.rs +++ b/crates/pinakes-server/tests/plugin.rs @@ -2,7 +2,20 @@ mod common; use std::sync::Arc; use axum::{body::Body, http::StatusCode}; -use common::*; +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; @@ -164,3 +177,98 @@ async fn test_plugin_uninstall_not_found() { || 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); +} diff --git a/crates/pinakes-server/tests/shares.rs b/crates/pinakes-server/tests/shares.rs new file mode 100644 index 0000000..ea7c234 --- /dev/null +++ b/crates/pinakes-server/tests/shares.rs @@ -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); +} diff --git a/crates/pinakes-server/tests/sync.rs b/crates/pinakes-server/tests/sync.rs new file mode 100644 index 0000000..55fd585 --- /dev/null +++ b/crates/pinakes-server/tests/sync.rs @@ -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); +} diff --git a/crates/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs index 7fd166f..d1ee90c 100644 --- a/crates/pinakes-tui/src/app.rs +++ b/crates/pinakes-tui/src/app.rs @@ -146,6 +146,14 @@ impl AppState { import_input: String::new(), status_message: None, should_quit: false, + page_offset: 0, + page_size: 50, + total_media_count: 0, + server_url: server_url.to_string(), + // Multi-select + selected_items: FxHashSet::default(), + selection_mode: false, + pending_batch_delete: false, duplicate_groups: Vec::new(), duplicates_selected: None, database_stats: None, @@ -174,14 +182,6 @@ impl AppState { reading_progress: None, page_input: String::new(), entering_page: false, - page_offset: 0, - page_size: 50, - total_media_count: 0, - server_url: server_url.to_string(), - // Multi-select - selected_items: FxHashSet::default(), - selection_mode: false, - pending_batch_delete: false, } } }