various: simplify code; work on security and performance
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9a5114addcab5fbff430ab2b919b83466a6a6964
This commit is contained in:
parent
016841b200
commit
c4adc4e3e0
75 changed files with 12921 additions and 358 deletions
|
|
@ -10,8 +10,10 @@ use tower::ServiceExt;
|
|||
|
||||
use pinakes_core::cache::CacheLayer;
|
||||
use pinakes_core::config::{
|
||||
AccountsConfig, Config, DirectoryConfig, JobsConfig, ScanningConfig, ServerConfig,
|
||||
SqliteConfig, StorageBackendType, StorageConfig, ThumbnailConfig, UiConfig, WebhookConfig,
|
||||
AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig,
|
||||
JobsConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType,
|
||||
StorageConfig, ThumbnailConfig, TranscodingConfig, UiConfig, UserAccount, UserRole,
|
||||
WebhookConfig,
|
||||
};
|
||||
use pinakes_core::jobs::JobQueue;
|
||||
use pinakes_core::storage::StorageBackend;
|
||||
|
|
@ -41,12 +43,57 @@ fn post_json(uri: &str, body: &str) -> Request<Body> {
|
|||
req
|
||||
}
|
||||
|
||||
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;
|
||||
/// Build a GET request with Bearer auth
|
||||
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
|
||||
}
|
||||
|
||||
let config = Config {
|
||||
/// Build a POST JSON request with Bearer auth
|
||||
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 DELETE request with Bearer auth
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
fn default_config() -> Config {
|
||||
Config {
|
||||
storage: StorageConfig {
|
||||
backend: StorageBackendType::Sqlite,
|
||||
sqlite: Some(SqliteConfig {
|
||||
|
|
@ -72,7 +119,20 @@ async fn setup_app() -> axum::Router {
|
|||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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, |_id, _kind, _cancel, _jobs| tokio::spawn(async {}));
|
||||
let config = Arc::new(RwLock::new(config));
|
||||
|
|
@ -92,11 +152,117 @@ async fn setup_app() -> axum::Router {
|
|||
job_queue,
|
||||
cache: Arc::new(CacheLayer::new(60)),
|
||||
scheduler: Arc::new(scheduler),
|
||||
plugin_manager: None,
|
||||
transcode_service: None,
|
||||
};
|
||||
|
||||
pinakes_server::app::create_router(state)
|
||||
}
|
||||
|
||||
/// Hash a password for test user accounts
|
||||
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).
|
||||
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;
|
||||
|
||||
// Create users in database so resolve_user_id works
|
||||
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.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, |_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(),
|
||||
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
job_queue,
|
||||
cache: Arc::new(CacheLayer::new(60)),
|
||||
scheduler: Arc::new(scheduler),
|
||||
plugin_manager: None,
|
||||
transcode_service: None,
|
||||
};
|
||||
|
||||
let app = pinakes_server::app::create_router(state);
|
||||
|
||||
// Login each user to get tokens
|
||||
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)
|
||||
}
|
||||
|
||||
async fn login_user(app: axum::Router, username: &str, password: &str) -> String {
|
||||
let body = format!(r#"{{"username":"{}","password":"{}"}}"#, username, 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()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Existing tests (no auth)
|
||||
// ===================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_media_empty() {
|
||||
let app = setup_app().await;
|
||||
|
|
@ -210,3 +376,623 @@ async fn test_scheduled_tasks_endpoint() {
|
|||
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);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Authentication tests
|
||||
// ===================================================================
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Authorization / RBAC tests
|
||||
// ===================================================================
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Social
|
||||
// ===================================================================
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Playlists
|
||||
// ===================================================================
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Analytics
|
||||
// ===================================================================
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Streaming/Transcode
|
||||
// ===================================================================
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Phase 2 feature tests: Subtitles
|
||||
// ===================================================================
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Health: public access test
|
||||
// ===================================================================
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Input validation & edge case tests
|
||||
// ===================================================================
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue