diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 3c77176..63e83b7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1979,6 +1979,7 @@ version = "0.1.0" dependencies = [ "anyhow", "argon2", + "async-trait", "aws-config", "aws-credential-types", "aws-sdk-s3", @@ -1990,7 +1991,10 @@ dependencies = [ "diesel_migrations", "dotenv", "hex", + "http-body-util", + "hyper 1.7.0", "jsonwebtoken", + "once_cell", "rand 0.8.5", "serde", "serde_json", @@ -2054,6 +2058,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2931,7 +2955,9 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ + "futures-core", "futures-util", + "pin-project", "pin-project-lite", "tokio", "tower-layer", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0ca2ddb..320e0b8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" # Web framework axum = { version = "0.7", features = ["multipart"] } tokio = { version = "1", features = ["full"] } -tower = { version = "0.4", features = ["make"] } +tower = { version = "0.4", features = ["make", "util"] } tower-http = { version = "0.5", features = ["cors", "trace"] } axum-extra = { version = "0.9", features = ["typed-header"] } @@ -33,6 +33,7 @@ dotenv = "0.15" sha2 = "0.10" hex = "0.4" bytes = "1.5" +async-trait = "0.1" # Error handling thiserror = "1.0" @@ -44,3 +45,8 @@ jsonwebtoken = "9" # Misc rand = "0.8" + +[dev-dependencies] +once_cell = "1.19" +hyper = "1.2" +http-body-util = "0.1" diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..7ee0696 --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,10 @@ +pub mod auth; +pub mod config; +pub mod db; +pub mod error; +pub mod models; +pub mod routes; +pub mod s3; +pub mod schema; +pub mod state; +pub mod storage; diff --git a/backend/src/main.rs b/backend/src/main.rs index 1646b8b..8362509 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,23 +1,17 @@ -mod auth; -mod config; -mod db; -mod error; -mod models; -mod routes; -mod s3; -mod schema; -mod state; - use std::net::SocketAddr; +use std::sync::Arc; use tokio::net::TcpListener; use tower::make::Shared; use tracing_subscriber::EnvFilter; -use crate::auth::jwt::JwtService; -use crate::config::AppConfig; -use crate::s3::build_client; -use crate::state::AppState; +use paperless_backend::auth::jwt::JwtService; +use paperless_backend::config::AppConfig; +use paperless_backend::db; +use paperless_backend::routes; +use paperless_backend::s3::build_client; +use paperless_backend::state::AppState; +use paperless_backend::storage::S3Storage; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -27,9 +21,10 @@ async fn main() -> anyhow::Result<()> { let config = AppConfig::from_env()?; let pool = db::init_pool(&config.database_url)?; let s3_client = build_client(&config).await?; + let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone())); let jwt = JwtService::from_config(&config)?; - let state = AppState::new(pool, config, s3_client, jwt); + let state = AppState::new(pool, config, storage, jwt); let router = routes::create_router(state.clone()); diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index eb9e523..2241766 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -1,8 +1,6 @@ use std::collections::HashMap; use std::time::Duration; -use aws_sdk_s3::presigning::PresigningConfig; -use aws_sdk_s3::primitives::ByteStream; use axum::extract::{Json, Multipart, Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; @@ -267,21 +265,11 @@ pub async fn upload_document( } } - let mut put_request = state - .s3 - .put_object() - .bucket(&state.config.s3_bucket) - .key(&s3_key) - .body(ByteStream::from(file_bytes.clone())); - - if let Some(ref ct) = content_type { - put_request = put_request.content_type(ct.clone()); - } - - put_request - .send() + state + .storage + .put_object(&s3_key, file_bytes.clone(), content_type.clone()) .await - .map_err(|err| AppError::internal(format!("failed to upload to s3: {err}")))?; + .map_err(|err| AppError::internal(format!("failed to store document: {err}")))?; let metadata_value = if metadata.is_null() { Value::Object(Default::default()) @@ -349,22 +337,17 @@ pub async fn download_document( .filter(document_versions::version_number.eq(doc.current_version)) .first(&mut conn)?; - let presign_config = PresigningConfig::builder() - .expires_in(Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS)) - .build() - .map_err(|err| AppError::internal(format!("failed to build presigning config: {err}")))?; - - let presigned = state - .s3 - .get_object() - .bucket(&state.config.s3_bucket) - .key(&version.s3_key) - .presigned(presign_config) + let presigned_url = state + .storage + .presign_get_object( + &version.s3_key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + ) .await .map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?; Ok(Json(DocumentDownloadResponse { - url: presigned.uri().to_string(), + url: presigned_url, expires_in: PRESIGNED_URL_EXPIRY_SECONDS, filename: doc.original_name.clone(), content_type: doc.content_type.clone(), diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 87a5bff..ff809d6 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -16,8 +16,6 @@ diesel::table! { version_number -> Int4, #[max_length = 500] s3_key -> Varchar, - #[max_length = 100] - s3_bucket -> Varchar, size_bytes -> Int8, #[max_length = 64] checksum -> Varchar, diff --git a/backend/src/state.rs b/backend/src/state.rs index 0236bd2..d8ac37c 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use aws_sdk_s3::Client as S3Client; use diesel::{ pg::PgConnection, r2d2::{ConnectionManager, PooledConnection}, @@ -11,6 +10,7 @@ use crate::{ config::AppConfig, db::PgPool, error::{AppError, AppResult}, + storage::ObjectStorage, }; type PgPooledConnection = PooledConnection>; @@ -19,16 +19,21 @@ type PgPooledConnection = PooledConnection>; pub struct AppState { pub pool: PgPool, pub config: Arc, - pub s3: S3Client, + pub storage: Arc, pub jwt: JwtService, } impl AppState { - pub fn new(pool: PgPool, config: AppConfig, s3: S3Client, jwt: JwtService) -> Self { + pub fn new( + pool: PgPool, + config: AppConfig, + storage: Arc, + jwt: JwtService, + ) -> Self { Self { pool, config: Arc::new(config), - s3, + storage, jwt, } } diff --git a/backend/src/storage.rs b/backend/src/storage.rs new file mode 100644 index 0000000..0f4e31c --- /dev/null +++ b/backend/src/storage.rs @@ -0,0 +1,79 @@ +use std::time::Duration; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use aws_sdk_s3::presigning::PresigningConfig; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::Client as S3Client; + +#[async_trait] +pub trait ObjectStorage: Send + Sync + 'static { + async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + ) -> Result<()>; + + async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result; +} + +pub struct S3Storage { + client: S3Client, + bucket: String, +} + +impl S3Storage { + pub fn new(client: S3Client, bucket: impl Into) -> Self { + Self { + client, + bucket: bucket.into(), + } + } +} + +#[async_trait] +impl ObjectStorage for S3Storage { + async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + ) -> Result<()> { + let mut request = self + .client + .put_object() + .bucket(&self.bucket) + .key(key) + .body(ByteStream::from(bytes)); + + if let Some(content_type) = content_type { + request = request.content_type(content_type); + } + + request + .send() + .await + .context("failed to upload object to S3")?; + + Ok(()) + } + + async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result { + let presign_config = PresigningConfig::builder() + .expires_in(expires_in) + .build() + .context("failed to build S3 presigning config")?; + + let presigned = self + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .presigned(presign_config) + .await + .context("failed to generate presigned download URL")?; + + Ok(presigned.uri().to_string()) + } +} diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs new file mode 100644 index 0000000..db9d5bc --- /dev/null +++ b/backend/tests/auth_flow.rs @@ -0,0 +1,37 @@ +mod common; + +use anyhow::Result; +use axum::http::StatusCode; +use common::{acquire_db_lock, body_to_vec, TestApp}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct AuthenticatedUser { + username: String, + role: String, +} + +#[tokio::test] +async fn login_and_me_roundtrip() -> Result<()> { + let _lock = acquire_db_lock().await; + let Some(app) = TestApp::new().await? else { + eprintln!("skipping test: TEST_DATABASE_URL not set"); + return Ok(()); + }; + + let password = "s3cret"; + app.insert_user("alice", password, "admin").await?; + + let token = app.login_token("alice", password).await?; + + let response = app.get("/api/auth/me", Some(&token)).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let user: AuthenticatedUser = serde_json::from_slice(&body)?; + + assert_eq!(user.username, "alice"); + assert_eq!(user.role, "admin"); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs new file mode 100644 index 0000000..d7705d2 --- /dev/null +++ b/backend/tests/common/mod.rs @@ -0,0 +1,392 @@ +use std::collections::HashMap; +use std::env; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, ensure, Context, Result}; +use async_trait::async_trait; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use axum::Router; +use diesel::connection::SimpleConnection; +use diesel::prelude::*; +use diesel::PgConnection; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +use http_body_util::BodyExt; +use once_cell::sync::Lazy; +use paperless_backend::auth::jwt::JwtService; +use paperless_backend::config::AppConfig; +use paperless_backend::db::{self, PgPool}; +use paperless_backend::models::NewUser; +use paperless_backend::routes; +use paperless_backend::state::AppState; +use paperless_backend::storage::ObjectStorage; +use rand::rngs::OsRng; +use serde::Serialize; +use tokio::sync::Mutex; +use tower::util::ServiceExt; +use uuid::Uuid; + +const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); + +static DB_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +#[allow(dead_code)] +#[derive(Clone)] +pub struct StoredObject { + pub key: String, + pub bytes: Vec, + pub content_type: Option, +} + +#[derive(Default)] +pub struct FakeStorage { + objects: Mutex>, +} + +#[async_trait] +impl ObjectStorage for FakeStorage { + async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + ) -> Result<()> { + let stored = StoredObject { + key: key.to_string(), + bytes, + content_type, + }; + let mut guard = self.objects.lock().await; + guard.insert(stored.key.clone(), stored); + Ok(()) + } + + async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result { + let guard = self.objects.lock().await; + ensure!(guard.contains_key(key), "object {key} missing"); + Ok(format!( + "https://fake-storage/{key}?expires_in={}", + expires_in.as_secs() + )) + } +} + +impl FakeStorage { + #[allow(dead_code)] + pub async fn get(&self, key: &str) -> Option { + let guard = self.objects.lock().await; + guard.get(key).cloned() + } + + #[allow(dead_code)] + pub async fn object_count(&self) -> usize { + let guard = self.objects.lock().await; + guard.len() + } +} + +pub struct TestApp { + pub state: AppState, + router: Router, + storage: Arc, +} + +impl TestApp { + pub async fn new() -> Result> { + let database_url = match env::var("TEST_DATABASE_URL") { + Ok(url) => url, + Err(_) => return Ok(None), + }; + + let config = AppConfig { + database_url: database_url.clone(), + server_host: "127.0.0.1".to_string(), + server_port: 0, + jwt_secret: "test-secret".to_string(), + jwt_issuer: "test-issuer".to_string(), + jwt_audience: "test-audience".to_string(), + jwt_expiry_minutes: 60, + aws_endpoint_url: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_region: "us-east-1".to_string(), + s3_bucket: "test-bucket".to_string(), + }; + + let pool = db::init_pool(&config.database_url)?; + prepare_database(&pool).await?; + + let storage = Arc::new(FakeStorage::default()); + let storage_for_state: Arc = storage.clone(); + let jwt = JwtService::from_config(&config)?; + let state = AppState::new(pool.clone(), config, storage_for_state, jwt); + let router = routes::create_router(state.clone()); + + Ok(Some(Self { + state, + router, + storage, + })) + } + + pub async fn cleanup(&self) -> Result<()> { + let pool = self.state.pool.clone(); + tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = pool + .get() + .map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?; + truncate_all(&mut conn)?; + Ok(()) + }) + .await + .context("cleanup task panicked")? + } + + #[allow(dead_code)] + pub fn storage(&self) -> Arc { + self.storage.clone() + } + + pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result { + let username = username.to_string(); + let password = password.to_string(); + let role = role.to_string(); + self.with_conn(move |conn| { + let password_hash = hash_password(&password)?; + let user = NewUser { + id: Uuid::new_v4(), + username, + password_hash, + role, + }; + diesel::insert_into(paperless_backend::schema::users::table) + .values(&user) + .execute(conn) + .context("failed to insert user")?; + Ok(user.id) + }) + .await + } + + pub async fn login_token(&self, username: &str, password: &str) -> Result { + #[derive(Serialize)] + struct LoginPayload<'a> { + username: &'a str, + password: &'a str, + } + + let response = self + .post_json( + "/api/auth/login", + &LoginPayload { username, password }, + None, + ) + .await?; + + ensure!( + response.status() == StatusCode::OK, + "login failed with status {}", + response.status() + ); + + let body = body_to_vec(response.into_body()).await?; + #[derive(serde::Deserialize)] + struct LoginResponse { + access_token: String, + } + let parsed: LoginResponse = serde_json::from_slice(&body)?; + Ok(parsed.access_token) + } + + pub async fn post_json( + &self, + path: &str, + payload: &T, + token: Option<&str>, + ) -> Result> { + let body = serde_json::to_vec(payload)?; + let mut builder = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json"); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + let request = builder.body(Body::from(body))?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + #[allow(dead_code)] + pub async fn patch_json( + &self, + path: &str, + payload: &T, + token: Option<&str>, + ) -> Result> { + let body = serde_json::to_vec(payload)?; + let mut builder = Request::builder() + .method(Method::PATCH) + .uri(path) + .header("content-type", "application/json"); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + let request = builder.body(Body::from(body))?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn get(&self, path: &str, token: Option<&str>) -> Result> { + let mut builder = Request::builder().method(Method::GET).uri(path); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + let request = builder.body(Body::empty())?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + #[allow(dead_code)] + pub async fn delete(&self, path: &str, token: Option<&str>) -> Result> { + let builder = Request::builder().method(Method::DELETE).uri(path); + let builder = if let Some(token) = token { + builder.header("authorization", format!("Bearer {token}")) + } else { + builder + }; + let request = builder.body(Body::empty())?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + #[allow(dead_code)] + pub async fn upload_document( + &self, + path: &str, + filename: &str, + content_type: &str, + data: &[u8], + folder_id: Option, + token: &str, + ) -> Result> { + let boundary = format!("boundary-{}", Uuid::new_v4()); + let mut body = Vec::new(); + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend( + format!( + "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n", + filename + ) + .as_bytes(), + ); + body.extend(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes()); + body.extend(data); + body.extend(b"\r\n"); + + if let Some(folder) = folder_id { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"folder_id\"\r\n\r\n"); + body.extend(folder.to_string().as_bytes()); + body.extend(b"\r\n"); + } + + body.extend(format!("--{boundary}--\r\n").as_bytes()); + + let builder = Request::builder() + .method(Method::POST) + .uri(path) + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .header("authorization", format!("Bearer {token}")); + + let request = builder.body(Body::from(body))?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + async fn with_conn(&self, f: F) -> Result + where + F: FnOnce(&mut PgConnection) -> Result + Send + 'static, + T: Send + 'static, + { + let pool = self.state.pool.clone(); + tokio::task::spawn_blocking(move || { + let mut conn = pool + .get() + .map_err(|err| anyhow!("failed to get database connection: {err}"))?; + f(&mut conn) + }) + .await + .context("connection task panicked")? + } +} + +pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> { + DB_LOCK.lock().await +} + +pub async fn body_to_vec(body: Body) -> Result> { + let collected = body + .collect() + .await + .map_err(|err| anyhow!("failed to read response body: {err}"))?; + Ok(collected.to_bytes().to_vec()) +} + +async fn prepare_database(pool: &PgPool) -> Result<()> { + let pool = pool.clone(); + tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = pool + .get() + .map_err(|err| anyhow!("failed to acquire connection: {err}"))?; + conn.run_pending_migrations(MIGRATIONS) + .map_err(|err| anyhow!("failed to run migrations: {err}"))?; + truncate_all(&mut conn)?; + Ok(()) + }) + .await + .context("migration task panicked")? +} + +fn truncate_all(conn: &mut PgConnection) -> Result<()> { + conn.batch_execute( + "TRUNCATE TABLE document_tags, document_versions, documents, folders, tags, users RESTART IDENTITY CASCADE;", + ) + .context("failed to truncate tables")?; + Ok(()) +} + +fn hash_password(password: &str) -> Result { + use argon2::password_hash::{PasswordHasher, SaltString}; + use argon2::Argon2; + + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|err| anyhow!("failed to hash password: {err}"))? + .to_string()) +} diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs new file mode 100644 index 0000000..e318adb --- /dev/null +++ b/backend/tests/documents_flow.rs @@ -0,0 +1,177 @@ +mod common; + +use anyhow::Result; +use axum::http::StatusCode; +use common::{acquire_db_lock, body_to_vec, TestApp}; +use serde::Deserialize; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Deserialize)] +struct DocumentDetail { + document: DocumentInfo, + current_version: DocumentVersion, +} + +#[derive(Deserialize)] +struct DocumentInfo { + id: Uuid, + original_name: String, + current_version: i32, + deleted_at: Option, + tags: Vec, +} + +#[derive(Deserialize)] +struct DocumentVersion { + s3_key: String, + size_bytes: i64, +} + +#[derive(Deserialize)] +struct DocumentListItem { + id: Uuid, + current_version: i32, +} + +#[derive(Deserialize)] +struct DocumentDownload { + url: String, + filename: String, +} + +#[tokio::test] +async fn upload_and_list_document() -> Result<()> { + let _lock = acquire_db_lock().await; + let Some(app) = TestApp::new().await? else { + eprintln!("skipping test: TEST_DATABASE_URL not set"); + return Ok(()); + }; + + let password = "passw0rd"; + app.insert_user("dana", password, "admin").await?; + let token = app.login_token("dana", password).await?; + + let file_bytes = b"example document body".to_vec(); + let upload = app + .upload_document( + "/api/documents", + "doc.txt", + "text/plain", + &file_bytes, + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::OK); + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + assert_eq!(detail.document.original_name, "doc.txt"); + assert_eq!(detail.document.current_version, 1); + assert_eq!(detail.document.deleted_at, None); + assert!(detail.document.tags.is_empty()); + assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64); + + let stored = app + .storage() + .get(&detail.current_version.s3_key) + .await + .expect("object stored"); + assert_eq!(stored.bytes, file_bytes); + + let response = app.get("/api/documents", Some(&token)).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let mut list: Vec = serde_json::from_slice(&body)?; + assert_eq!(list.len(), 1); + let item = list.pop().unwrap(); + assert_eq!(item.id, detail.document.id); + assert_eq!(item.current_version, 1); + + let download = app + .get( + &format!("/api/documents/{}/download", detail.document.id), + Some(&token), + ) + .await?; + assert_eq!(download.status(), StatusCode::OK); + let body = body_to_vec(download.into_body()).await?; + let download_info: DocumentDownload = serde_json::from_slice(&body)?; + assert!(download_info.url.contains(&detail.current_version.s3_key)); + assert_eq!(download_info.filename, "doc.txt"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn duplicate_and_restore_document() -> Result<()> { + let _lock = acquire_db_lock().await; + let Some(app) = TestApp::new().await? else { + eprintln!("skipping test: TEST_DATABASE_URL not set"); + return Ok(()); + }; + + let password = "pass1234"; + app.insert_user("sam", password, "admin").await?; + let token = app.login_token("sam", password).await?; + + let payload = b"same bytes".to_vec(); + let first = app + .upload_document( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + &token, + ) + .await?; + let first_body = body_to_vec(first.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + &token, + ) + .await?; + assert_eq!(second.status(), StatusCode::OK); + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + assert_eq!(first_detail.document.id, second_detail.document.id); + assert_eq!(second_detail.document.deleted_at, None); + + let delete = app + .delete( + &format!("/api/documents/{}", first_detail.document.id), + Some(&token), + ) + .await?; + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + + let third = app + .upload_document( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + &token, + ) + .await?; + let third_body = body_to_vec(third.into_body()).await?; + let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?; + + assert_eq!(third_detail.document.id, first_detail.document.id); + assert_eq!(third_detail.document.deleted_at, None); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/folders_flow.rs b/backend/tests/folders_flow.rs new file mode 100644 index 0000000..b8c238a --- /dev/null +++ b/backend/tests/folders_flow.rs @@ -0,0 +1,137 @@ +mod common; + +use anyhow::Result; +use axum::http::StatusCode; +use common::{acquire_db_lock, body_to_vec, TestApp}; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Deserialize)] +struct FolderResponse { + folder: FolderInfo, +} + +#[derive(Deserialize)] +struct FolderInfo { + id: Uuid, +} + +#[derive(Deserialize)] +struct FolderContents { + documents: Vec, +} + +#[derive(Deserialize)] +struct DocSummary { + id: Uuid, +} + +#[derive(Serialize)] +struct CreateFolder<'a> { + name: &'a str, + parent_id: Option, +} + +#[derive(Serialize)] +struct MoveDocumentRequest { + folder_id: Option, +} + +#[derive(Deserialize)] +struct DocumentDetail { + document: DocSummary, +} + +#[tokio::test] +async fn folder_move_and_delete_flow() -> Result<()> { + let _lock = acquire_db_lock().await; + let Some(app) = TestApp::new().await? else { + eprintln!("skipping test: TEST_DATABASE_URL not set"); + return Ok(()); + }; + + let password = "folderpass"; + app.insert_user("folder-admin", password, "admin").await?; + let token = app.login_token("folder-admin", password).await?; + + let folder_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Projects", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(folder_resp.status(), StatusCode::OK); + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder: FolderResponse = serde_json::from_slice(&folder_body)?; + + let upload = app + .upload_document( + "/api/documents", + "plan.pdf", + "application/pdf", + b"dummy", + None, + &token, + ) + .await?; + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + + let move_resp = app + .patch_json( + &format!("/api/documents/{}/folder", detail.document.id), + &MoveDocumentRequest { + folder_id: Some(folder.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(move_resp.status(), StatusCode::NO_CONTENT); + + let contents = app + .get( + &format!("/api/folders/{}/contents", folder.folder.id), + Some(&token), + ) + .await?; + assert_eq!(contents.status(), StatusCode::OK); + let contents_body = body_to_vec(contents.into_body()).await?; + let contents: FolderContents = serde_json::from_slice(&contents_body)?; + assert_eq!(contents.documents.len(), 1); + assert_eq!(contents.documents[0].id, detail.document.id); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + assert!(root + .documents + .iter() + .all(|doc| doc.id != detail.document.id)); + + let delete_attempt = app + .delete(&format!("/api/folders/{}", folder.folder.id), Some(&token)) + .await?; + assert_eq!(delete_attempt.status(), StatusCode::BAD_REQUEST); + + let move_back = app + .patch_json( + &format!("/api/documents/{}/folder", detail.document.id), + &MoveDocumentRequest { folder_id: None }, + Some(&token), + ) + .await?; + assert_eq!(move_back.status(), StatusCode::NO_CONTENT); + + let delete = app + .delete(&format!("/api/folders/{}", folder.folder.id), Some(&token)) + .await?; + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs new file mode 100644 index 0000000..4d060ec --- /dev/null +++ b/backend/tests/tags_flow.rs @@ -0,0 +1,124 @@ +mod common; + +use anyhow::Result; +use axum::http::StatusCode; +use common::{acquire_db_lock, body_to_vec, TestApp}; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Deserialize)] +struct DocumentDetail { + document: DocumentInfo, +} + +#[derive(Deserialize)] +struct DocumentInfo { + id: Uuid, + tags: Vec, +} + +#[derive(Deserialize)] +struct TagInfo { + label: String, +} + +#[derive(Deserialize)] +struct TagResponse { + id: Uuid, +} + +#[derive(Serialize)] +struct AssignTagsRequest { + tag_ids: Vec, +} + +#[tokio::test] +async fn tag_assignment_flow() -> Result<()> { + let _lock = acquire_db_lock().await; + let Some(app) = TestApp::new().await? else { + eprintln!("skipping test: TEST_DATABASE_URL not set"); + return Ok(()); + }; + + let password = "tagpass"; + app.insert_user("tagger", password, "admin").await?; + let token = app.login_token("tagger", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "tagged.txt", + "text/plain", + b"tag me", + None, + &token, + ) + .await?; + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + + #[derive(Serialize)] + struct CreateTagPayload<'a> { + label: &'a str, + color: Option<&'a str>, + } + + let create_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Important", + color: Some("#FF0000"), + }, + Some(&token), + ) + .await?; + assert_eq!(create_tag.status(), StatusCode::OK); + let body = body_to_vec(create_tag.into_body()).await?; + let tag: TagResponse = serde_json::from_slice(&body)?; + + let assign = app + .post_json( + &format!("/api/documents/{}/tags", detail.document.id), + &AssignTagsRequest { + tag_ids: vec![tag.id], + }, + Some(&token), + ) + .await?; + assert_eq!(assign.status(), StatusCode::NO_CONTENT); + + let refreshed = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + assert_eq!(refreshed.status(), StatusCode::OK); + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let refreshed_detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + assert_eq!(refreshed_detail.document.tags.len(), 1); + assert_eq!(refreshed_detail.document.tags[0].label, "Important"); + + let remove = app + .delete( + &format!("/api/documents/{}/tags/{}", detail.document.id, tag.id), + Some(&token), + ) + .await?; + assert_eq!(remove.status(), StatusCode::NO_CONTENT); + + let final_check = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + let final_body = body_to_vec(final_check.into_body()).await?; + let final_detail: DocumentDetail = serde_json::from_slice(&final_body)?; + assert!(final_detail.document.tags.is_empty()); + + app.cleanup().await?; + Ok(()) +}