This commit is contained in:
2025-10-31 01:52:51 +01:00
parent 722c2f9a5c
commit 17db827e3a
11 changed files with 220 additions and 334 deletions
+78 -160
View File
@@ -118,13 +118,55 @@ impl PasskeyService {
.execute(conn);
}
fn begin_registration(
&self,
conn: &mut PgConnection,
user_id: Uuid,
username: &str,
challenge_user_id: Option<Uuid>,
exclude: Option<Vec<CredentialID>>,
) -> AppResult<RegistrationChallengeResponse> {
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<u8> = 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<RegistrationChallengeResponse> {
self.prune_expired(conn);
let existing: Vec<UserPasskey> = 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<u8> = 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<RegistrationChallengeResponse> {
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<u8> = 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<String>,
) -> AppResult<UserPasskey> {
credential: &RegisterPublicKeyCredential,
expected_user: Option<Uuid>,
) -> AppResult<PreparedPasskey> {
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<String>,
) -> AppResult<UserPasskey> {
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<PreparedPasskey> {
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<u8> = credential_struct.cred_id.clone().into();
let duplicate = passkey_dsl::user_passkeys
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.first::<UserPasskey>(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<Option<String>> = 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(
+21 -20
View File
@@ -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<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
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<String> = 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::<Correspondent>(&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::<Correspondent>(&mut conn)
.optional()
},
|| AppError::bad_request("correspondent name already exists"),
)?;
new_name = Some(normalized);
}
}
+10 -9
View File
@@ -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<CreateDocumentTypeRequest>,
) -> AppResult<(StatusCode, Json<DocumentTypeResponse>)> {
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<UpdateDocumentTypeRequest>,
) -> AppResult<Json<DocumentTypeResponse>> {
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::<DocumentType>(&mut conn);
match update_result {
+20 -19
View File
@@ -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<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> {
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::<Tag>(&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::<Tag>(&mut conn)
.optional()
},
|| AppError::bad_request("tag label already exists"),
)?;
new_label = Some(normalized);
label_changed = true;
}
}
+1
View File
@@ -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;
+1
View File
@@ -13,6 +13,7 @@ use crate::{
};
pub mod analyze;
pub mod common;
pub mod index;
pub mod ocr;
pub mod tenants;
+27 -44
View File
@@ -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<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
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<DocumentAsset> = 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<DocumentAssetObject> = 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<AppState>, payload: &OcrPayload) -> Result<OcrCon
let skip = existing_asset.is_some() && !payload.force;
Ok(OcrContext {
document,
version,
document: base.document,
version: base.version,
existing_asset,
existing_objects,
skip,
+28 -56
View File
@@ -25,8 +25,9 @@ use crate::{
};
use super::{
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution,
JobHandler,
analyze::determine_thumbnail_support,
common::{load_document_version, load_version_assets},
fetch_version_object, handle_fetch_error, JobExecution, JobHandler,
};
const THUMBNAIL_WIDTH: u32 = 512;
@@ -406,68 +407,39 @@ fn load_thumbnail_context(
state: Arc<AppState>,
payload: &ThumbnailPayload,
) -> Result<ThumbnailContext, String> {
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<DocumentAsset> = 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<DocumentAssetObject> = Vec::new();
let mut existing_preview = None;
let mut existing_preview_objects: Vec<DocumentAssetObject> = 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,
+3 -8
View File
@@ -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?;
+16 -2
View File
@@ -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<FakeStorage>,
}
#[derive(Debug, Deserialize)]
pub struct ApiErrorResponse {
pub error: String,
#[serde(default)]
pub code: Option<String>,
}
impl TestApp {
pub async fn new() -> Result<Self> {
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<Uuid>,
}
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");
+15 -16
View File
@@ -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<String>,
}
#[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(())