// ── Security Boundary ──────────────────────────────────────────────────────── // All routes are currently trusted-local: no authentication or authorization // middleware is applied. The server is designed to bind on 127.0.0.1 only. // // Route classification (for when middleware is added later): // // STATIC — safe to expose publicly: GET / and static /assets/* // READ — safe to expose read-only: GET /health // GET /api/archives // GET /api/archives/:id/entries // GET /api/archives/:id/entries/search // GET /api/archives/:id/entries/:uid // GET /api/archives/:id/entries/:uid/artifacts/:idx // GET /api/archives/:id/runs // GET /api/archives/:id/tags // ADMIN — requires auth if ever public: GET /api/admin/archives // WRITE — requires auth if ever public: POST /api/archives/:id/captures // POST /api/archives/:id/tags // PUT /api/archives/:id/tags/:tag_id // DELETE /api/archives/:id/tags/:tag_id // // Do not add middleware here until the auth model is chosen. See docs/README.md. // ───────────────────────────────────────────────────────────────────────────── use std::{path::PathBuf, sync::Arc}; use archivr_core::{archive, capture, database}; use axum::{ Json, Router, extract::{Path, Query, Request, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{delete, get, post}, }; use tower_http::services::{ServeDir, ServeFile}; use tower::ServiceExt; use crate::registry::{MountedArchive, ServerRegistry}; #[derive(Clone)] pub struct AppState { registry: Arc, } #[derive(Debug, serde::Deserialize, Default)] pub struct EntrySearchParams { pub q: Option, pub tag: Option, } pub fn app(registry: ServerRegistry) -> Router { let state = AppState { registry: Arc::new(registry), }; let static_dir = static_dir(); Router::new() .route("/health", get(|| async { "ok" })) .route("/api/archives", get(list_archives)) .route("/api/archives/:archive_id/entries", get(list_entries)) .route("/api/archives/:archive_id/entries/search", get(search_entries_handler)) .route( "/api/archives/:archive_id/entries/:entry_uid", get(entry_detail), ) .route( "/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index", get(serve_artifact), ) .route( "/api/archives/:archive_id/entries/:entry_uid/favicon", get(serve_entry_favicon), ) .route("/api/archives/:archive_id/runs", get(list_runs)) .route("/api/archives/:archive_id/captures", post(capture_handler)) .route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler)) .route( "/api/archives/:archive_id/entries/:entry_uid/tags", get(list_entry_tags).post(assign_entry_tag_handler), ) .route( "/api/archives/:archive_id/entries/:entry_uid/tags/:tag_uid", delete(remove_entry_tag_handler), ) .nest_service("/assets", ServeDir::new(static_dir.join("assets"))) .fallback_service(ServeFile::new(static_dir.join("index.html"))) .with_state(state) } fn static_dir() -> PathBuf { std::env::var_os("ARCHIVR_STATIC_DIR") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static")) } async fn list_archives(State(state): State) -> Json> { Json(state.registry.archives.clone()) } async fn list_entries( State(state): State, Path(archive_id): Path, ) -> Result>, ApiError> { let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; Ok(Json(archive::list_root_entries(&conn)?)) } async fn search_entries_handler( State(state): State, Path(archive_id): Path, Query(params): Query, ) -> Result>, ApiError> { let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; let raw = params.q.as_deref().unwrap_or(""); let mut search_query = archive::parse_search_query(raw) .map_err(|prefix| ApiError::bad_request(&format!("unknown search prefix: {prefix}")))?; if let Some(tag) = params.tag { search_query.tag = Some(tag); } Ok(Json(archive::search_entries(&conn, &search_query)?)) } async fn entry_detail( State(state): State, Path((archive_id, entry_uid)): Path<(String, String)>, ) -> Result, ApiError> { let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; let detail = archive::get_entry_detail(&conn, &entry_uid)? .ok_or(ApiError::not_found("entry not found"))?; Ok(Json(detail)) } async fn list_runs( State(state): State, Path(archive_id): Path, ) -> Result>, ApiError> { let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; Ok(Json(archive::list_runs(&conn)?)) } async fn serve_artifact( State(state): State, Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>, req: Request, ) -> Result { let mounted = mounted_archive(&state, &archive_id)?; let paths = archive::read_archive_paths(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?; let detail = archive::get_entry_detail(&conn, &entry_uid)? .ok_or(ApiError::not_found("entry not found"))?; let artifact = detail .artifacts .get(artifact_index) .ok_or(ApiError::not_found("artifact index out of range"))?; let file_path = archive::resolve_artifact_path(&paths.store_path, artifact)?; // ServeFile streams the file, handles Range requests (video seeking), // sets Content-Type/ETag/Last-Modified. Error type is Infallible. Ok(ServeFile::new(&file_path) .oneshot(req) .await .unwrap() .into_response()) } async fn serve_entry_favicon( State(state): State, Path((archive_id, entry_uid)): Path<(String, String)>, req: Request, ) -> Result { let mounted = mounted_archive(&state, &archive_id)?; let paths = archive::read_archive_paths(&mounted.archive_path)?; let conn = database::open_or_initialize(&mounted.archive_path)?; let detail = archive::get_entry_detail(&conn, &entry_uid)? .ok_or(ApiError::not_found("entry not found"))?; let artifact = detail .artifacts .iter() .find(|a| a.artifact_role == "favicon") .ok_or(ApiError::not_found("no favicon for this entry"))?; let file_path = archive::resolve_artifact_path(&paths.store_path, artifact)?; Ok(ServeFile::new(&file_path) .oneshot(req) .await .unwrap() .into_response()) } #[derive(Debug, serde::Deserialize)] struct CreateTagBody { path: String, } #[derive(Debug, serde::Deserialize)] struct AssignTagBody { tag_path: String, } async fn list_tags( State(state): State, Path(archive_id): Path, ) -> Result>, ApiError> { let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; Ok(Json(archive::list_tag_tree(&conn)?)) } async fn create_tag_handler( State(state): State, Path(archive_id): Path, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { if body.path.trim().is_empty() { return Err(ApiError::bad_request("tag path must not be empty")); } let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; let tag = archive::create_tag(&conn, &body.path)?; Ok((StatusCode::CREATED, Json(tag))) } async fn list_entry_tags( State(state): State, Path((archive_id, entry_uid)): Path<(String, String)>, ) -> Result>, ApiError> { let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; match archive::get_entry_tags(&conn, &entry_uid)? { Some(tags) => Ok(Json(tags)), None => Err(ApiError::not_found("entry not found")), } } async fn assign_entry_tag_handler( State(state): State, Path((archive_id, entry_uid)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { if body.tag_path.trim().is_empty() { return Err(ApiError::bad_request("tag_path must not be empty")); } let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; match archive::assign_entry_tag(&conn, &entry_uid, &body.tag_path)? { Some(tag) => Ok((StatusCode::CREATED, Json(tag))), None => Err(ApiError::not_found("entry not found")), } } async fn remove_entry_tag_handler( State(state): State, Path((archive_id, entry_uid, tag_uid)): Path<(String, String, String)>, ) -> Result { let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; if archive::remove_entry_tag(&conn, &entry_uid, &tag_uid)? { Ok(StatusCode::NO_CONTENT) } else { Err(ApiError::not_found("entry or tag not found")) } } #[derive(Debug, serde::Deserialize)] struct CaptureBody { locator: String, } async fn capture_handler( State(state): State, Path(archive_id): Path, Json(body): Json, ) -> Result, ApiError> { if body.locator.trim().is_empty() { return Err(ApiError::bad_request("locator must not be empty")); } let mounted = mounted_archive(&state, &archive_id)?; let archive_paths = archive::read_archive_paths(&mounted.archive_path) .map_err(ApiError::from)?; let result = capture::perform_capture(&archive_paths, &body.locator) .map_err(ApiError::from)?; Ok(Json(result)) } fn mounted_archive<'a>( state: &'a AppState, archive_id: &str, ) -> Result<&'a MountedArchive, ApiError> { state .registry .archives .iter() .find(|archive| archive.id == archive_id) .ok_or(ApiError::not_found("archive not found")) } #[derive(Debug)] pub struct ApiError { status: StatusCode, message: String, } impl ApiError { fn not_found(message: &str) -> Self { Self { status: StatusCode::NOT_FOUND, message: message.to_string(), } } fn bad_request(message: &str) -> Self { Self { status: StatusCode::BAD_REQUEST, message: message.to_string(), } } } impl From for ApiError where E: Into, { fn from(error: E) -> Self { let error = error.into(); Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: format!("{error:#}"), } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { (self.status, self.message).into_response() } } #[cfg(test)] mod tests { use super::*; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::ServiceExt; #[tokio::test] async fn archives_endpoint_lists_mounted_archives() { let registry = ServerRegistry { archives: vec![MountedArchive { id: "personal".to_string(), label: "Personal".to_string(), archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"), }], bind: None, }; let response = app(registry) .oneshot( Request::builder() .uri("/api/archives") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn missing_archive_returns_404() { let response = app(ServerRegistry::default()) .oneshot( Request::builder() .uri("/api/archives/missing/entries") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn artifact_missing_archive_returns_404() { let response = app(ServerRegistry::default()) .oneshot( Request::builder() .uri("/api/archives/nope/entries/entry_abc/artifacts/0") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn artifact_missing_entry_returns_404() { let dir = tempfile::tempdir().unwrap(); archivr_core::archive::initialize_archive( dir.path(), &dir.path().join("store"), "test", false, ) .unwrap(); let archive_path = dir.path().join(".archivr"); let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), label: "Test".to_string(), archive_path, }], bind: None, }; let response = app(registry) .oneshot( Request::builder() .uri("/api/archives/test/entries/entry_doesnotexist/artifacts/0") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn artifact_out_of_range_index_returns_404() { let dir = tempfile::tempdir().unwrap(); archivr_core::archive::initialize_archive( dir.path(), &dir.path().join("store"), "test", false, ) .unwrap(); let archive_path = dir.path().join(".archivr"); let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), label: "Test".to_string(), archive_path, }], bind: None, }; let response = app(registry) .oneshot( Request::builder() .uri("/api/archives/test/entries/entry_doesnotexist/artifacts/99") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn artifact_serves_file_with_ok_status() { // Initialize archive (creates .archivr dir, store dirs, and db) let dir = tempfile::tempdir().unwrap(); let store_path = dir.path().join("store"); let paths = archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false) .unwrap(); // Write artifact file to the store let artifact_relpath = "raw/a/b/test.html"; let artifact_dir = store_path.join("raw").join("a").join("b"); std::fs::create_dir_all(&artifact_dir).unwrap(); std::fs::write(artifact_dir.join("test.html"), b"hello").unwrap(); // Populate the database with user, source identity, run, entry, blob, artifact let conn = database::open_or_initialize(&paths.archive_path).unwrap(); let user_id = database::ensure_default_user(&conn).unwrap(); let source_identity_id = database::upsert_source_identity( &conn, "web", "page", Some("test-page"), Some("https://example.com/page"), "https://example.com/page", ) .unwrap(); let run = database::create_archive_run(&conn, user_id, 1).unwrap(); let entry = database::create_archived_entry( &conn, &database::NewEntry { source_identity_id, archive_run_id: run.id, parent_entry_id: None, root_entry_id: None, created_by_user_id: user_id, owned_by_user_id: user_id, source_kind: "web".to_string(), entity_kind: "page".to_string(), title: Some("Test Page".to_string()), visibility: "private".to_string(), representation_kind: "html".to_string(), source_metadata_json: r#"{"source":"test"}"#.to_string(), display_metadata_json: None, }, ) .unwrap(); let blob_id = database::upsert_blob( &conn, &database::BlobRecord { sha256: "abc123testblob".to_string(), byte_size: 18, mime_type: Some("text/html".to_string()), extension: Some("html".to_string()), raw_relpath: artifact_relpath.to_string(), }, ) .unwrap(); database::add_entry_artifact( &conn, &database::NewArtifact { entry_id: entry.id, artifact_role: "primary_media".to_string(), storage_area: "raw".to_string(), relpath: artifact_relpath.to_string(), blob_id: Some(blob_id), logical_path: None, metadata_json: None, }, ) .unwrap(); drop(conn); // release before the HTTP handler opens the same db file let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), label: "Test".to_string(), archive_path: paths.archive_path.clone(), }], bind: None, }; let uri = format!( "/api/archives/test/entries/{}/artifacts/0", entry.entry_uid ); let response = app(registry) .oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn search_missing_archive_returns_404() { let response = app(ServerRegistry::default()) .oneshot( Request::builder() .uri("/api/archives/nope/entries/search?q=anything") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn search_empty_q_returns_ok() { let dir = tempfile::tempdir().unwrap(); archivr_core::archive::initialize_archive( dir.path(), &dir.path().join("store"), "test", false, ) .unwrap(); let archive_path = dir.path().join(".archivr"); let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), label: "Test".to_string(), archive_path, }], bind: None, }; let response = app(registry) .oneshot( Request::builder() .uri("/api/archives/test/entries/search") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn search_unknown_prefix_returns_400() { let dir = tempfile::tempdir().unwrap(); archivr_core::archive::initialize_archive( dir.path(), &dir.path().join("store"), "test", false, ) .unwrap(); let archive_path = dir.path().join(".archivr"); let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), label: "Test".to_string(), archive_path, }], bind: None, }; let response = app(registry) .oneshot( Request::builder() .uri("/api/archives/test/entries/search?q=unknownprefix%3Aval") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } // ---- helpers ---- fn make_test_registry(dir: &tempfile::TempDir) -> (ServerRegistry, std::path::PathBuf) { let paths = archivr_core::archive::initialize_archive( dir.path(), &dir.path().join("store"), "test", false, ) .unwrap(); let registry = ServerRegistry { archives: vec![MountedArchive { id: "test".to_string(), label: "Test".to_string(), archive_path: paths.archive_path.clone(), }], bind: None, }; (registry, paths.archive_path) } fn make_test_entry(archive_path: &std::path::Path) -> archivr_core::database::ArchivedEntry { let conn = database::open_or_initialize(archive_path).unwrap(); let user_id = database::ensure_default_user(&conn).unwrap(); let run = database::create_archive_run(&conn, user_id, 1).unwrap(); let si = database::upsert_source_identity( &conn, "web", "page", None, Some("https://example.com/test"), "https://example.com/test", ) .unwrap(); database::create_archived_entry( &conn, &database::NewEntry { source_identity_id: si, archive_run_id: run.id, parent_entry_id: None, root_entry_id: None, created_by_user_id: user_id, owned_by_user_id: user_id, source_kind: "web".to_string(), entity_kind: "page".to_string(), title: Some("Test Entry".to_string()), visibility: "private".to_string(), representation_kind: "html".to_string(), source_metadata_json: "{}".to_string(), display_metadata_json: None, }, ) .unwrap() } async fn body_json(response: axum::response::Response) -> serde_json::Value { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); serde_json::from_slice(&bytes).unwrap() } fn json_body(payload: &serde_json::Value) -> Body { Body::from(serde_json::to_vec(payload).unwrap()) } // ---- tag route tests ---- #[tokio::test] async fn test_list_tags_unknown_archive() { let response = app(ServerRegistry::default()) .oneshot( Request::builder() .uri("/api/archives/ghost/tags") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_create_tag_unknown_archive() { let response = app(ServerRegistry::default()) .oneshot( Request::builder() .method("POST") .uri("/api/archives/ghost/tags") .header("content-type", "application/json") .body(json_body(&serde_json::json!({"path": "/science"}))) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_create_tag_empty_path() { let dir = tempfile::tempdir().unwrap(); let (registry, _) = make_test_registry(&dir); let response = app(registry) .oneshot( Request::builder() .method("POST") .uri("/api/archives/test/tags") .header("content-type", "application/json") .body(json_body(&serde_json::json!({"path": ""}))) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_tag_round_trip() { let dir = tempfile::tempdir().unwrap(); let (registry, _) = make_test_registry(&dir); let create_response = app(registry.clone()) .oneshot( Request::builder() .method("POST") .uri("/api/archives/test/tags") .header("content-type", "application/json") .body(json_body(&serde_json::json!({"path": "/science"}))) .unwrap(), ) .await .unwrap(); assert_eq!(create_response.status(), StatusCode::CREATED); let list_response = app(registry.clone()) .oneshot( Request::builder() .uri("/api/archives/test/tags") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(list_response.status(), StatusCode::OK); let tree = body_json(list_response).await; let slugs: Vec<&str> = tree .as_array() .unwrap() .iter() .map(|n| n["tag"]["slug"].as_str().unwrap()) .collect(); assert!(slugs.contains(&"science"), "expected 'science' in tag tree, got {slugs:?}"); } #[tokio::test] async fn test_entry_tag_assign_and_remove() { let dir = tempfile::tempdir().unwrap(); let (registry, archive_path) = make_test_registry(&dir); let entry = make_test_entry(&archive_path); let entry_uid = entry.entry_uid.clone(); let entry_tags_uri = format!("/api/archives/test/entries/{entry_uid}/tags"); // Assign tag let assign_response = app(registry.clone()) .oneshot( Request::builder() .method("POST") .uri(&entry_tags_uri) .header("content-type", "application/json") .body(json_body(&serde_json::json!({"tag_path": "/science"}))) .unwrap(), ) .await .unwrap(); assert_eq!(assign_response.status(), StatusCode::CREATED); let assigned_tag = body_json(assign_response).await; let tag_uid = assigned_tag["tag_uid"].as_str().unwrap().to_string(); // List entry tags — should contain the assigned tag let list_response = app(registry.clone()) .oneshot( Request::builder() .uri(&entry_tags_uri) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(list_response.status(), StatusCode::OK); let tags = body_json(list_response).await; assert_eq!(tags.as_array().unwrap().len(), 1); // Remove tag let delete_uri = format!("{entry_tags_uri}/{tag_uid}"); let delete_response = app(registry.clone()) .oneshot( Request::builder() .method("DELETE") .uri(&delete_uri) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); // List entry tags again — should be empty let list2_response = app(registry.clone()) .oneshot( Request::builder() .uri(&entry_tags_uri) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(list2_response.status(), StatusCode::OK); let tags2 = body_json(list2_response).await; assert!(tags2.as_array().unwrap().is_empty(), "tags should be empty after removal"); } #[tokio::test] async fn test_search_with_tag_param() { let dir = tempfile::tempdir().unwrap(); let (registry, archive_path) = make_test_registry(&dir); let entry = make_test_entry(&archive_path); let entry_uid = entry.entry_uid.clone(); // Assign /science tag to entry let assign_resp = app(registry.clone()) .oneshot( Request::builder() .method("POST") .uri(format!("/api/archives/test/entries/{entry_uid}/tags")) .header("content-type", "application/json") .body(json_body(&serde_json::json!({"tag_path": "/science"}))) .unwrap(), ) .await .unwrap(); assert_eq!(assign_resp.status(), StatusCode::CREATED, "assign tag should return 201"); // Search with ?tag=/science — entry should appear let response = app(registry.clone()) .oneshot( Request::builder() .uri("/api/archives/test/entries/search?tag=%2Fscience") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let results = body_json(response).await; assert_eq!( results.as_array().unwrap().len(), 1, "expected 1 result for /science tag, got {}", results.as_array().unwrap().len() ); // Search with ?tag=/art — should return empty let response2 = app(registry.clone()) .oneshot( Request::builder() .uri("/api/archives/test/entries/search?tag=%2Fart") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response2.status(), StatusCode::OK); let results2 = body_json(response2).await; assert!( results2.as_array().unwrap().is_empty(), "expected 0 results for /art tag" ); } #[tokio::test] async fn test_list_entry_tags_unknown_entry() { let dir = tempfile::tempdir().unwrap(); let (registry, _) = make_test_registry(&dir); let response = app(registry) .oneshot( Request::builder() .uri("/api/archives/test/entries/ghost_uid/tags") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_assign_entry_tag_unknown_entry() { let dir = tempfile::tempdir().unwrap(); let (registry, _) = make_test_registry(&dir); let response = app(registry) .oneshot( Request::builder() .method("POST") .uri("/api/archives/test/entries/ghost_uid/tags") .header("content-type", "application/json") .body(json_body(&serde_json::json!({"tag_path": "/science"}))) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_assign_entry_tag_empty_tag_path() { let dir = tempfile::tempdir().unwrap(); let (registry, archive_path) = make_test_registry(&dir); let entry = make_test_entry(&archive_path); let entry_uid = entry.entry_uid.clone(); let response = app(registry) .oneshot( Request::builder() .method("POST") .uri(format!("/api/archives/test/entries/{entry_uid}/tags")) .header("content-type", "application/json") .body(json_body(&serde_json::json!({"tag_path": ""}))) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_remove_entry_tag_unknown_entry() { let dir = tempfile::tempdir().unwrap(); let (registry, _) = make_test_registry(&dir); let response = app(registry) .oneshot( Request::builder() .method("DELETE") .uri("/api/archives/test/entries/ghost_uid/tags/ghost_tag_uid") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn capture_rejects_empty_locator() { let response = app(ServerRegistry::default()) .oneshot( Request::builder() .method("POST") .uri("/api/archives/test/captures") .header("content-type", "application/json") .body(Body::from(r#"{"locator":""}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn capture_rejects_unknown_archive() { let response = app(ServerRegistry::default()) .oneshot( Request::builder() .method("POST") .uri("/api/archives/nonexistent/captures") .header("content-type", "application/json") .body(Body::from(r#"{"locator":"tweet:1234567890"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } }