diff --git a/backend/src/auth/passkeys.rs b/backend/src/auth/passkeys.rs index 7917541..8de961f 100644 --- a/backend/src/auth/passkeys.rs +++ b/backend/src/auth/passkeys.rs @@ -118,13 +118,55 @@ impl PasskeyService { .execute(conn); } + fn begin_registration( + &self, + conn: &mut PgConnection, + user_id: Uuid, + username: &str, + challenge_user_id: Option, + exclude: Option>, + ) -> AppResult { + self.prune_expired(conn); + + let (challenge, state) = self + .webauthn + .start_passkey_registration(user_id, username, username, exclude) + .map_err(|err| { + tracing::error!(error = %err, "failed to start passkey registration"); + AppError::internal("failed to start passkey registration") + })?; + + let challenge_id = Uuid::new_v4(); + let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); + let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); + let state_bytes = serde_json::to_vec(&state) + .context("failed to encode passkey registration state") + .map_err(AppError::internal)?; + + let record = NewWebauthnChallenge { + id: challenge_id, + user_id: challenge_user_id, + purpose: PURPOSE_REGISTRATION.to_string(), + challenge: challenge_bytes, + state: state_bytes, + expires_at, + }; + + diesel::insert_into(challenge_dsl::webauthn_challenges) + .values(&record) + .execute(conn)?; + + Ok(RegistrationChallengeResponse { + challenge_id, + challenge, + }) + } + pub fn start_registration( &self, conn: &mut PgConnection, user: &User, ) -> AppResult { - self.prune_expired(conn); - let existing: Vec = passkey_dsl::user_passkeys .filter(passkey_dsl::user_id.eq(user.id)) .filter(passkey_dsl::revoked_at.is_null()) @@ -141,38 +183,7 @@ impl PasskeyService { ) }; - let (challenge, state) = self - .webauthn - .start_passkey_registration(user.id, &user.username, &user.username, exclude) - .map_err(|err| { - tracing::error!(error = %err, "failed to start passkey registration"); - AppError::internal("failed to start passkey registration") - })?; - - let challenge_id = Uuid::new_v4(); - let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); - let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); - let state_bytes = serde_json::to_vec(&state) - .context("failed to encode passkey registration state") - .map_err(AppError::internal)?; - - let record = NewWebauthnChallenge { - id: challenge_id, - user_id: Some(user.id), - purpose: PURPOSE_REGISTRATION.to_string(), - challenge: challenge_bytes, - state: state_bytes, - expires_at, - }; - - diesel::insert_into(challenge_dsl::webauthn_challenges) - .values(&record) - .execute(conn)?; - - Ok(RegistrationChallengeResponse { - challenge_id, - challenge, - }) + self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude) } pub fn start_signup_registration( @@ -181,50 +192,16 @@ impl PasskeyService { user_id: Uuid, username: &str, ) -> AppResult { - self.prune_expired(conn); - - let (challenge, state) = self - .webauthn - .start_passkey_registration(user_id, username, username, None) - .map_err(|err| { - tracing::error!(error = %err, "failed to start passkey registration"); - AppError::internal("failed to start passkey registration") - })?; - - let challenge_id = Uuid::new_v4(); - let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); - let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); - let state_bytes = serde_json::to_vec(&state) - .context("failed to encode passkey registration state") - .map_err(AppError::internal)?; - - let record = NewWebauthnChallenge { - id: challenge_id, - user_id: None, - purpose: PURPOSE_REGISTRATION.to_string(), - challenge: challenge_bytes, - state: state_bytes, - expires_at, - }; - - diesel::insert_into(challenge_dsl::webauthn_challenges) - .values(&record) - .execute(conn)?; - - Ok(RegistrationChallengeResponse { - challenge_id, - challenge, - }) + self.begin_registration(conn, user_id, username, None, None) } - pub fn finish_registration( + fn complete_registration( &self, conn: &mut PgConnection, - user: &User, challenge_id: Uuid, - credential: RegisterPublicKeyCredential, - nickname: Option, - ) -> AppResult { + credential: &RegisterPublicKeyCredential, + expected_user: Option, + ) -> AppResult { let record: WebauthnChallenge = challenge_dsl::webauthn_challenges .find(challenge_id) .first(conn) @@ -240,8 +217,14 @@ impl PasskeyService { return Err(AppError::bad_request("challenge is not for registration")); } - if record.user_id != Some(user.id) { - return Err(AppError::unauthorized()); + if let Some(expected) = expected_user { + if record.user_id != Some(expected) { + return Err(AppError::unauthorized()); + } + } else if record.user_id.is_some() { + return Err(AppError::bad_request( + "unexpected user context for signup registration", + )); } if record.expires_at < Utc::now().naive_utc() { @@ -255,7 +238,7 @@ impl PasskeyService { let passkey = self .webauthn - .finish_passkey_registration(&credential, &state) + .finish_passkey_registration(credential, &state) .map_err(|err| { tracing::warn!(error = %err, "passkey registration validation failed"); AppError::bad_request("invalid passkey attestation") @@ -296,24 +279,36 @@ impl PasskeyService { .context("failed to serialise passkey") .map_err(AppError::internal)?; - let new_passkey = NewUserPasskey { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + + Ok(PreparedPasskey { id: Uuid::new_v4(), - user_id: user.id, credential_id: credential_id_vec, public_key: public_key_bytes, credential: credential_json, sign_count: credential_struct.counter as i64, transports, aaguid, - nickname, - }; + }) + } + + pub fn finish_registration( + &self, + conn: &mut PgConnection, + user: &User, + challenge_id: Uuid, + credential: RegisterPublicKeyCredential, + nickname: Option, + ) -> AppResult { + let prepared = + self.complete_registration(conn, challenge_id, &credential, Some(user.id))?; + + let new_passkey = prepared.into_new_user_passkey(user.id, nickname); diesel::insert_into(passkey_dsl::user_passkeys) .values(&new_passkey) .execute(conn)?; - diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; - let created: UserPasskey = passkey_dsl::user_passkeys .find(new_passkey.id) .select(UserPasskey::as_select()) @@ -410,84 +405,7 @@ impl PasskeyService { challenge_id: Uuid, credential: &RegisterPublicKeyCredential, ) -> AppResult { - let record: WebauthnChallenge = challenge_dsl::webauthn_challenges - .find(challenge_id) - .first(conn) - .map_err(|err| { - if matches!(err, diesel::result::Error::NotFound) { - AppError::bad_request("challenge not found") - } else { - AppError::from(err) - } - })?; - - if record.purpose != PURPOSE_REGISTRATION { - return Err(AppError::bad_request("challenge is not for registration")); - } - - if record.expires_at < Utc::now().naive_utc() { - diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; - return Err(AppError::bad_request("challenge expired")); - } - - let state: PasskeyRegistration = serde_json::from_slice(&record.state) - .context("failed to decode registration state") - .map_err(AppError::internal)?; - - let passkey = self - .webauthn - .finish_passkey_registration(credential, &state) - .map_err(|err| { - tracing::warn!(error = %err, "passkey registration validation failed"); - AppError::bad_request("invalid passkey attestation") - })?; - - let credential_struct: Credential = passkey.clone().into(); - let credential_id_vec: Vec = credential_struct.cred_id.clone().into(); - - let duplicate = passkey_dsl::user_passkeys - .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) - .first::(conn) - .optional()?; - if duplicate.is_some() { - diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; - return Err(AppError::conflict("credential already registered")); - } - - let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) - .context("failed to encode credential public key") - .map_err(AppError::internal)?; - - let transports: Vec> = credential_struct - .transports - .clone() - .unwrap_or_default() - .into_iter() - .map(|transport| Some(transport.as_ref().to_string())) - .collect(); - - let aaguid = match credential_struct.attestation.metadata { - AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => { - Some(aaguid) - } - _ => None, - }; - - let credential_json = serde_json::to_value(&passkey) - .context("failed to serialise passkey") - .map_err(AppError::internal)?; - - diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; - - Ok(PreparedPasskey { - id: Uuid::new_v4(), - credential_id: credential_id_vec, - public_key: public_key_bytes, - credential: credential_json, - sign_count: credential_struct.counter as i64, - transports, - aaguid, - }) + self.complete_registration(conn, challenge_id, credential, None) } pub fn revoke_passkey( diff --git a/backend/src/routes/correspondents.rs b/backend/src/routes/correspondents.rs index 306b029..6aa1f09 100644 --- a/backend/src/routes/correspondents.rs +++ b/backend/src/routes/correspondents.rs @@ -15,6 +15,7 @@ use crate::{ schema::{correspondents, document_correspondents}, utils::{ db::{no_content, EnsureEntity, IntoJsonResponse}, + named_entity::{ensure_name_available, normalize_name}, time::to_iso, }, }; @@ -111,16 +112,15 @@ pub async fn create_correspondent( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - let name = payload.name.trim(); - if name.is_empty() { - return Err(AppError::bad_request("name must not be empty")); - } + let name = normalize_name(&payload.name, || { + AppError::bad_request("name must not be empty") + })?; let metadata_value = normalize_metadata(payload.metadata); let new_id = Uuid::new_v4(); let new_correspondent = NewCorrespondent { id: new_id, - name: name.to_string(), + name: name.clone(), metadata: metadata_value, tenant_id, }; @@ -170,21 +170,22 @@ pub async fn update_correspondent( let mut new_name: Option = None; if let Some(ref candidate) = payload.name { - let trimmed = candidate.trim(); - if trimmed.is_empty() { - return Err(AppError::bad_request("name must not be empty")); - } - if trimmed != existing.name { - let duplicate = correspondents::table - .filter(correspondents::name.eq(trimmed)) - .filter(correspondents::id.ne(correspondent_id)) - .filter(correspondents::tenant_id.eq(tenant_id)) - .first::(&mut conn) - .optional()?; - if duplicate.is_some() { - return Err(AppError::bad_request("correspondent name already exists")); - } - new_name = Some(trimmed.to_string()); + let normalized = normalize_name(candidate, || { + AppError::bad_request("name must not be empty") + })?; + if normalized != existing.name { + ensure_name_available( + || { + correspondents::table + .filter(correspondents::name.eq(&normalized)) + .filter(correspondents::id.ne(correspondent_id)) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first::(&mut conn) + .optional() + }, + || AppError::bad_request("correspondent name already exists"), + )?; + new_name = Some(normalized); } } diff --git a/backend/src/routes/document_types.rs b/backend/src/routes/document_types.rs index 7f614c6..a355ef8 100644 --- a/backend/src/routes/document_types.rs +++ b/backend/src/routes/document_types.rs @@ -12,6 +12,7 @@ use crate::{ error::{AppError, AppResult}, models::{DocumentType, NewDocumentType}, schema::document_types, + utils::named_entity::normalize_name, }; use super::documents::DocumentTypeResponse; @@ -64,15 +65,14 @@ pub async fn create_document_type( }: TenantScopedConn, Json(payload): Json, ) -> AppResult<(StatusCode, Json)> { - let name = payload.name.trim(); - if name.is_empty() { - return Err(AppError::bad_request("name must not be empty")); - } + let name = normalize_name(&payload.name, || { + AppError::bad_request("name must not be empty") + })?; let new_type = NewDocumentType { id: Uuid::new_v4(), tenant_id, - name: name.to_string(), + name: name.clone(), }; match diesel::insert_into(document_types::table) @@ -111,19 +111,20 @@ pub async fn update_document_type( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - let name = payload + let name_raw = payload .name .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) .ok_or_else(|| AppError::bad_request("name must be provided and not empty"))?; + let name = normalize_name(name_raw, || { + AppError::bad_request("name must be provided and not empty") + })?; let target = document_types::table .filter(document_types::tenant_id.eq(tenant_id)) .find(document_type_id); let update_result = diesel::update(target.clone()) - .set(document_types::name.eq(name)) + .set(document_types::name.eq(&name)) .get_result::(&mut conn); match update_result { diff --git a/backend/src/routes/tags.rs b/backend/src/routes/tags.rs index f81af22..6a6121a 100644 --- a/backend/src/routes/tags.rs +++ b/backend/src/routes/tags.rs @@ -12,6 +12,7 @@ use crate::schema::{document_tags, tags}; use crate::utils::{ db::{no_content, EnsureEntity, IntoJsonResponse}, json::deserialize_patch_field, + named_entity::{ensure_name_available, normalize_name}, }; #[derive(Deserialize, ToSchema)] @@ -125,13 +126,13 @@ pub async fn create_tag( }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { - if payload.label.trim().is_empty() { - return Err(AppError::bad_request("label must not be empty")); - } + let label = normalize_name(&payload.label, || { + AppError::bad_request("label must not be empty") + })?; let new_tag = NewTag { id: Uuid::new_v4(), - label: payload.label.trim().to_string(), + label: label.clone(), color: payload.color, tenant_id, }; @@ -211,21 +212,21 @@ pub async fn update_tag( return Err(AppError::bad_request("label cannot be null")); } Some(Some(value)) => { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(AppError::bad_request("label must not be empty")); - } - if trimmed != existing.label { - let duplicate = tags::table - .filter(tags::label.eq(trimmed)) - .filter(tags::id.ne(tag_id)) - .filter(tags::tenant_id.eq(tenant_id)) - .first::(&mut conn) - .optional()?; - if duplicate.is_some() { - return Err(AppError::bad_request("tag label already exists")); - } - new_label = Some(trimmed.to_string()); + let normalized = + normalize_name(&value, || AppError::bad_request("label must not be empty"))?; + if normalized != existing.label { + ensure_name_available( + || { + tags::table + .filter(tags::label.eq(&normalized)) + .filter(tags::id.ne(tag_id)) + .filter(tags::tenant_id.eq(tenant_id)) + .first::(&mut conn) + .optional() + }, + || AppError::bad_request("tag label already exists"), + )?; + new_label = Some(normalized); label_changed = true; } } diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs index 045e291..3fc9a62 100644 --- a/backend/src/utils/mod.rs +++ b/backend/src/utils/mod.rs @@ -3,6 +3,7 @@ pub mod db; pub mod error; pub mod http; pub mod json; +pub mod named_entity; pub mod storage_paths; pub mod time; pub mod tracing; diff --git a/backend/src/workers/mod.rs b/backend/src/workers/mod.rs index fe3e391..a0a42c4 100644 --- a/backend/src/workers/mod.rs +++ b/backend/src/workers/mod.rs @@ -13,6 +13,7 @@ use crate::{ }; pub mod analyze; +pub mod common; pub mod index; pub mod ocr; pub mod tenants; diff --git a/backend/src/workers/ocr.rs b/backend/src/workers/ocr.rs index 605da98..87b180b 100644 --- a/backend/src/workers/ocr.rs +++ b/backend/src/workers/ocr.rs @@ -24,13 +24,16 @@ use crate::{ Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, NewDocumentAssetObject, }, - schema::{document_asset_objects, document_assets, document_versions, documents}, + schema::{document_asset_objects, document_assets, documents}, state::AppState, storage::TenantStorage, utils::storage_paths::document_asset_object_prefix, }; -use super::{fetch_version_object, handle_fetch_error, JobExecution, JobHandler}; +use super::{ + common::{load_document_version, load_version_assets}, + fetch_version_object, handle_fetch_error, JobExecution, JobHandler, +}; pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text"; const MIN_TEXT_LENGTH: usize = 50; @@ -234,54 +237,34 @@ struct OcrGeneration { } fn load_ocr_context(state: Arc, payload: &OcrPayload) -> Result { - let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; - - let version: DocumentVersion = document_versions::table - .find(payload.document_version_id) - .first(&mut base_conn) - .map_err(|err| format!("{err:?}"))?; - - if version.document_id != payload.document_id { - return Err("document/version mismatch".into()); - } - - let document: Document = documents::table - .find(payload.document_id) - .first(&mut base_conn) - .map_err(|err| format!("{err:?}"))?; - - let tenant_id = document.tenant_id; - drop(base_conn); + let base = load_document_version( + state.as_ref(), + payload.document_id, + payload.document_version_id, + )?; let mut conn = state - .db_for_tenant(tenant_id) + .db_for_tenant(base.tenant_id) .map_err(|err| format!("{err:?}"))?; - let existing_asset: Option = document_assets::table - .filter(document_assets::document_version_id.eq(payload.document_version_id)) - .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) - .filter(document_assets::tenant_id.eq(tenant_id)) - .first(&mut conn) - .optional() - .map_err(|err| format!("{err:?}"))?; + let mut assets = load_version_assets( + &mut conn, + base.tenant_id, + base.version.id, + &[OCR_TEXT_ASSET_TYPE], + )?; - let existing_objects: Vec = if let Some(asset) = &existing_asset { - document_asset_objects::table - .filter(document_asset_objects::asset_id.eq(asset.id)) - .filter(document_asset_objects::tenant_id.eq(tenant_id)) - .order(document_asset_objects::ordinal.asc()) - .load(&mut conn) - .map_err(|err| format!("{err:?}"))? - } else { - Vec::new() - }; + let (existing_asset, existing_objects) = assets + .remove(OCR_TEXT_ASSET_TYPE) + .map(|entry| (Some(entry.asset), entry.objects)) + .unwrap_or((None, Vec::new())); - let is_pdf = document_is_pdf(&document); + let is_pdf = document_is_pdf(&base.document); if !is_pdf { return Ok(OcrContext { - document, - version, - existing_asset: existing_asset, + document: base.document, + version: base.version, + existing_asset, existing_objects, skip: true, }); @@ -290,8 +273,8 @@ fn load_ocr_context(state: Arc, payload: &OcrPayload) -> Result, payload: &ThumbnailPayload, ) -> Result { - let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; + let base = load_document_version( + state.as_ref(), + payload.document_id, + payload.document_version_id, + )?; - let version: DocumentVersion = document_versions::table - .find(payload.document_version_id) - .first(&mut conn) + let mut conn = state + .db_for_tenant(base.tenant_id) .map_err(|err| format!("{err:?}"))?; - if version.document_id != payload.document_id { - return Err("document/version mismatch".into()); - } + let mut assets = load_version_assets( + &mut conn, + base.tenant_id, + base.version.id, + &[THUMBNAIL_ASSET_TYPE, PREVIEW_ASSET_TYPE], + )?; - let document: Document = documents::table - .find(payload.document_id) - .first(&mut conn) - .map_err(|err| format!("{err:?}"))?; + let (existing_thumbnail, existing_thumbnail_objects) = assets + .remove(THUMBNAIL_ASSET_TYPE) + .map(|entry| (Some(entry.asset), entry.objects)) + .unwrap_or((None, Vec::new())); - let tenant_id = document.tenant_id; + let (existing_preview, existing_preview_objects) = assets + .remove(PREVIEW_ASSET_TYPE) + .map(|entry| (Some(entry.asset), entry.objects)) + .unwrap_or((None, Vec::new())); - let existing_assets: Vec = document_assets::table - .filter(document_assets::document_version_id.eq(payload.document_version_id)) - .filter(document_assets::asset_type.eq_any(vec![ - THUMBNAIL_ASSET_TYPE.to_string(), - PREVIEW_ASSET_TYPE.to_string(), - ])) - .filter(document_assets::tenant_id.eq(tenant_id)) - .load(&mut conn) - .map_err(|err| format!("{err:?}"))?; - - let mut existing_thumbnail = None; - let mut existing_thumbnail_objects: Vec = Vec::new(); - let mut existing_preview = None; - let mut existing_preview_objects: Vec = Vec::new(); - for asset in existing_assets { - match asset.asset_type.as_str() { - THUMBNAIL_ASSET_TYPE => { - existing_thumbnail_objects = document_asset_objects::table - .filter(document_asset_objects::asset_id.eq(asset.id)) - .filter(document_asset_objects::tenant_id.eq(tenant_id)) - .order(document_asset_objects::ordinal.asc()) - .load(&mut conn) - .map_err(|err| format!("{err:?}"))?; - existing_thumbnail = Some(asset); - } - PREVIEW_ASSET_TYPE => { - existing_preview_objects = document_asset_objects::table - .filter(document_asset_objects::asset_id.eq(asset.id)) - .filter(document_asset_objects::tenant_id.eq(tenant_id)) - .order(document_asset_objects::ordinal.asc()) - .load(&mut conn) - .map_err(|err| format!("{err:?}"))?; - existing_preview = Some(asset); - } - _ => {} - } - } - - let (supported, _) = determine_thumbnail_support(&document); + let (supported, _) = determine_thumbnail_support(&base.document); if !supported { return Err("thumbnail generation not supported for this document".into()); } - let expected_cardinality = expected_asset_cardinality(&document, &version); + let expected_cardinality = expected_asset_cardinality(&base.document, &base.version); let preview_cardinality = existing_preview .as_ref() .and_then(|asset| asset.cardinality) @@ -488,8 +460,8 @@ fn load_thumbnail_context( && !needs_regeneration; Ok(ThumbnailContext { - document, - version, + document: base.document, + version: base.version, existing_thumbnail, existing_thumbnail_objects, existing_preview, diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 9194032..3225ee6 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -3,7 +3,7 @@ mod common; use anyhow::{anyhow, Context, Result}; use axum::http::{header::SET_COOKIE, StatusCode}; use chrono::{Duration as ChronoDuration, Utc}; -use common::{acquire_db_lock, body_to_vec, TestApp}; +use common::{acquire_db_lock, body_to_vec, ApiErrorResponse, TestApp}; use diesel::prelude::*; use papercrate::auth::passkeys::{ PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, @@ -28,11 +28,6 @@ struct AuthenticatedUser { username: String, } -#[derive(Deserialize)] -struct ErrorResponse { - error: String, -} - #[derive(Deserialize)] struct LoginTenant { id: Uuid, @@ -100,7 +95,7 @@ async fn login_rejects_unknown_user() -> Result<()> { let response = app.post_json("/api/auth/login", &payload, None).await?; assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = body_to_vec(response.into_body()).await?; - let err: ErrorResponse = serde_json::from_slice(&body)?; + let err: ApiErrorResponse = serde_json::from_slice(&body)?; assert_eq!(err.error, "password authentication is no longer supported"); app.cleanup().await?; @@ -411,7 +406,7 @@ async fn login_rejects_invalid_password() -> Result<()> { let response = app.post_json("/api/auth/login", &payload, None).await?; assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = body_to_vec(response.into_body()).await?; - let err: ErrorResponse = serde_json::from_slice(&body)?; + let err: ApiErrorResponse = serde_json::from_slice(&body)?; assert_eq!(err.error, "password authentication is no longer supported"); app.cleanup().await?; diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 6bbcd0c..593aa1e 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -29,7 +29,7 @@ use papercrate::state::AppState; use papercrate::storage::ObjectStorage; use rand::rngs::OsRng; use rand::RngCore; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{self, json}; use sha2::{Digest, Sha256}; use tokio::sync::Mutex; @@ -122,6 +122,13 @@ pub struct TestApp { storage: Arc, } +#[derive(Debug, Deserialize)] +pub struct ApiErrorResponse { + pub error: String, + #[serde(default)] + pub code: Option, +} + impl TestApp { pub async fn new() -> Result { let database_url = env::var("TEST_DATABASE_URL") @@ -538,6 +545,7 @@ impl TestApp { correspondents_json: None, issued_at: None, skip_existing: false, + document_type_id: None, }; self.upload_document_with_extras( path, @@ -666,6 +674,7 @@ pub struct UploadExtras<'a> { pub correspondents_json: Option<&'a str>, pub issued_at: Option<&'a str>, pub skip_existing: bool, + pub document_type_id: Option, } impl<'a> UploadExtras<'a> { @@ -677,6 +686,7 @@ impl<'a> UploadExtras<'a> { correspondents_json: None, issued_at: None, skip_existing: false, + document_type_id: None, } } } @@ -709,7 +719,11 @@ mod helper_tests { let (access, refresh, refresh_id) = app.create_session(username).await?; assert!(!access.is_empty(), "access token should not be empty"); assert!(!refresh.is_empty(), "refresh token should not be empty"); - assert_ne!(refresh_id, Uuid::nil(), "refresh token id should be assigned"); + assert_ne!( + refresh_id, + Uuid::nil(), + "refresh token id should be assigned" + ); let bearer = app.login_token(username, password).await?; assert!(!bearer.is_empty(), "login_token must yield bearer"); diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index 829f899..4ff9fec 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -2,7 +2,7 @@ mod common; use anyhow::Result; use axum::http::StatusCode; -use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras}; +use common::{acquire_db_lock, body_to_vec, ApiErrorResponse, TestApp, UploadExtras}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use uuid::Uuid; @@ -97,13 +97,6 @@ struct AnalyzeJobPayload { force: bool, } -#[derive(Deserialize)] -struct ErrorResponse { - error: String, - #[serde(default)] - code: Option, -} - #[derive(Deserialize)] struct FolderResponse { folder: FolderInfo, @@ -1062,7 +1055,7 @@ async fn patch_document_updates_title_and_handles_conflict() -> Result<()> { .await?; assert_eq!(conflict.status(), StatusCode::CONFLICT); let conflict_body = body_to_vec(conflict.into_body()).await?; - let conflict_json: ErrorResponse = serde_json::from_slice(&conflict_body)?; + let conflict_json: ApiErrorResponse = serde_json::from_slice(&conflict_body)?; assert_eq!(conflict_json.code.as_deref(), Some("duplicate_filename")); app.cleanup().await?; @@ -1244,7 +1237,7 @@ async fn patch_document_validation_errors() -> Result<()> { .await?; assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST); let title_body = body_to_vec(empty_title.into_body()).await?; - let title_error: ErrorResponse = serde_json::from_slice(&title_body)?; + let title_error: ApiErrorResponse = serde_json::from_slice(&title_body)?; assert_eq!(title_error.error, "title must not be empty"); let empty_issued = app @@ -1256,7 +1249,7 @@ async fn patch_document_validation_errors() -> Result<()> { .await?; assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST); let issued_body = body_to_vec(empty_issued.into_body()).await?; - let issued_error: ErrorResponse = serde_json::from_slice(&issued_body)?; + let issued_error: ApiErrorResponse = serde_json::from_slice(&issued_body)?; assert_eq!(issued_error.error, "issued_at must not be empty"); let invalid_merge = app @@ -1272,7 +1265,7 @@ async fn patch_document_validation_errors() -> Result<()> { .await?; assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST); let merge_body = body_to_vec(invalid_merge.into_body()).await?; - let merge_error: ErrorResponse = serde_json::from_slice(&merge_body)?; + let merge_error: ApiErrorResponse = serde_json::from_slice(&merge_body)?; assert_eq!( merge_error.error, "metadata value must be a JSON object when replace is false" @@ -1308,7 +1301,7 @@ async fn patch_document_validation_errors() -> Result<()> { .await?; assert_eq!(merge_after_scalar.status(), StatusCode::BAD_REQUEST); let merge_after_body = body_to_vec(merge_after_scalar.into_body()).await?; - let merge_after_error: ErrorResponse = serde_json::from_slice(&merge_after_body)?; + let merge_after_error: ApiErrorResponse = serde_json::from_slice(&merge_after_body)?; assert_eq!( merge_after_error.error, "existing metadata is not an object; set replace=true to overwrite" @@ -1323,7 +1316,7 @@ async fn patch_document_validation_errors() -> Result<()> { .await?; assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST); let malformed_body = body_to_vec(malformed_timestamp.into_body()).await?; - let malformed_error: ErrorResponse = serde_json::from_slice(&malformed_body)?; + let malformed_error: ApiErrorResponse = serde_json::from_slice(&malformed_body)?; assert!( malformed_error .error @@ -1591,7 +1584,10 @@ async fn list_documents_filtered_by_document_type() -> Result<()> { assert!(refreshed_a.status().is_success()); let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?; let refreshed_a_detail: DocumentDetail = serde_json::from_slice(&refreshed_a_body)?; - assert_eq!(refreshed_a_detail.document.document_type_id, Some(invoices.id)); + assert_eq!( + refreshed_a_detail.document.document_type_id, + Some(invoices.id) + ); let refreshed_b = app .get( @@ -1602,7 +1598,10 @@ async fn list_documents_filtered_by_document_type() -> Result<()> { assert!(refreshed_b.status().is_success()); let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?; let refreshed_b_detail: DocumentDetail = serde_json::from_slice(&refreshed_b_body)?; - assert_eq!(refreshed_b_detail.document.document_type_id, Some(receipts.id)); + assert_eq!( + refreshed_b_detail.document.document_type_id, + Some(receipts.id) + ); app.cleanup().await?; Ok(())