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, 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::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); }