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`) pub fn test_addr() -> ConnectInfo { ConnectInfo("127.0.0.1:9999".parse().unwrap()) } /// Build a GET request with `ConnectInfo` for rate limiter /// compatibility pub fn get(uri: &str) -> Request { 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 { 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 { 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 { 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 { 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 { 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 { 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![], }, rate_limits: RateLimitConfig::default(), ui: UiConfig::default(), accounts: AccountsConfig::default(), jobs: JobsConfig::default(), thumbnails: ThumbnailConfig::default(), webhooks: Vec::::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) }