cleanup
This commit is contained in:
+77
-159
@@ -118,13 +118,55 @@ impl PasskeyService {
|
|||||||
.execute(conn);
|
.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(
|
pub fn start_registration(
|
||||||
&self,
|
&self,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
user: &User,
|
user: &User,
|
||||||
) -> AppResult<RegistrationChallengeResponse> {
|
) -> AppResult<RegistrationChallengeResponse> {
|
||||||
self.prune_expired(conn);
|
|
||||||
|
|
||||||
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
||||||
.filter(passkey_dsl::user_id.eq(user.id))
|
.filter(passkey_dsl::user_id.eq(user.id))
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
.filter(passkey_dsl::revoked_at.is_null())
|
||||||
@@ -141,38 +183,7 @@ impl PasskeyService {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (challenge, state) = self
|
self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude)
|
||||||
.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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_signup_registration(
|
pub fn start_signup_registration(
|
||||||
@@ -181,50 +192,16 @@ impl PasskeyService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
username: &str,
|
username: &str,
|
||||||
) -> AppResult<RegistrationChallengeResponse> {
|
) -> AppResult<RegistrationChallengeResponse> {
|
||||||
self.prune_expired(conn);
|
self.begin_registration(conn, user_id, username, None, None)
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn finish_registration(
|
fn complete_registration(
|
||||||
&self,
|
&self,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
user: &User,
|
|
||||||
challenge_id: Uuid,
|
challenge_id: Uuid,
|
||||||
credential: RegisterPublicKeyCredential,
|
credential: &RegisterPublicKeyCredential,
|
||||||
nickname: Option<String>,
|
expected_user: Option<Uuid>,
|
||||||
) -> AppResult<UserPasskey> {
|
) -> AppResult<PreparedPasskey> {
|
||||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||||
.find(challenge_id)
|
.find(challenge_id)
|
||||||
.first(conn)
|
.first(conn)
|
||||||
@@ -240,9 +217,15 @@ impl PasskeyService {
|
|||||||
return Err(AppError::bad_request("challenge is not for registration"));
|
return Err(AppError::bad_request("challenge is not for registration"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.user_id != Some(user.id) {
|
if let Some(expected) = expected_user {
|
||||||
|
if record.user_id != Some(expected) {
|
||||||
return Err(AppError::unauthorized());
|
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() {
|
if record.expires_at < Utc::now().naive_utc() {
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||||
@@ -255,7 +238,7 @@ impl PasskeyService {
|
|||||||
|
|
||||||
let passkey = self
|
let passkey = self
|
||||||
.webauthn
|
.webauthn
|
||||||
.finish_passkey_registration(&credential, &state)
|
.finish_passkey_registration(credential, &state)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
tracing::warn!(error = %err, "passkey registration validation failed");
|
tracing::warn!(error = %err, "passkey registration validation failed");
|
||||||
AppError::bad_request("invalid passkey attestation")
|
AppError::bad_request("invalid passkey attestation")
|
||||||
@@ -296,24 +279,36 @@ impl PasskeyService {
|
|||||||
.context("failed to serialise passkey")
|
.context("failed to serialise passkey")
|
||||||
.map_err(AppError::internal)?;
|
.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(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
|
||||||
credential_id: credential_id_vec,
|
credential_id: credential_id_vec,
|
||||||
public_key: public_key_bytes,
|
public_key: public_key_bytes,
|
||||||
credential: credential_json,
|
credential: credential_json,
|
||||||
sign_count: credential_struct.counter as i64,
|
sign_count: credential_struct.counter as i64,
|
||||||
transports,
|
transports,
|
||||||
aaguid,
|
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)
|
diesel::insert_into(passkey_dsl::user_passkeys)
|
||||||
.values(&new_passkey)
|
.values(&new_passkey)
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
|
||||||
|
|
||||||
let created: UserPasskey = passkey_dsl::user_passkeys
|
let created: UserPasskey = passkey_dsl::user_passkeys
|
||||||
.find(new_passkey.id)
|
.find(new_passkey.id)
|
||||||
.select(UserPasskey::as_select())
|
.select(UserPasskey::as_select())
|
||||||
@@ -410,84 +405,7 @@ impl PasskeyService {
|
|||||||
challenge_id: Uuid,
|
challenge_id: Uuid,
|
||||||
credential: &RegisterPublicKeyCredential,
|
credential: &RegisterPublicKeyCredential,
|
||||||
) -> AppResult<PreparedPasskey> {
|
) -> AppResult<PreparedPasskey> {
|
||||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
self.complete_registration(conn, challenge_id, credential, None)
|
||||||
.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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn revoke_passkey(
|
pub fn revoke_passkey(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::{
|
|||||||
schema::{correspondents, document_correspondents},
|
schema::{correspondents, document_correspondents},
|
||||||
utils::{
|
utils::{
|
||||||
db::{no_content, EnsureEntity, IntoJsonResponse},
|
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||||
|
named_entity::{ensure_name_available, normalize_name},
|
||||||
time::to_iso,
|
time::to_iso,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -111,16 +112,15 @@ pub async fn create_correspondent(
|
|||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateCorrespondentRequest>,
|
Json(payload): Json<CreateCorrespondentRequest>,
|
||||||
) -> AppResult<Json<CorrespondentSummary>> {
|
) -> AppResult<Json<CorrespondentSummary>> {
|
||||||
let name = payload.name.trim();
|
let name = normalize_name(&payload.name, || {
|
||||||
if name.is_empty() {
|
AppError::bad_request("name must not be empty")
|
||||||
return Err(AppError::bad_request("name must not be empty"));
|
})?;
|
||||||
}
|
|
||||||
|
|
||||||
let metadata_value = normalize_metadata(payload.metadata);
|
let metadata_value = normalize_metadata(payload.metadata);
|
||||||
let new_id = Uuid::new_v4();
|
let new_id = Uuid::new_v4();
|
||||||
let new_correspondent = NewCorrespondent {
|
let new_correspondent = NewCorrespondent {
|
||||||
id: new_id,
|
id: new_id,
|
||||||
name: name.to_string(),
|
name: name.clone(),
|
||||||
metadata: metadata_value,
|
metadata: metadata_value,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
};
|
};
|
||||||
@@ -170,21 +170,22 @@ pub async fn update_correspondent(
|
|||||||
|
|
||||||
let mut new_name: Option<String> = None;
|
let mut new_name: Option<String> = None;
|
||||||
if let Some(ref candidate) = payload.name {
|
if let Some(ref candidate) = payload.name {
|
||||||
let trimmed = candidate.trim();
|
let normalized = normalize_name(candidate, || {
|
||||||
if trimmed.is_empty() {
|
AppError::bad_request("name must not be empty")
|
||||||
return Err(AppError::bad_request("name must not be empty"));
|
})?;
|
||||||
}
|
if normalized != existing.name {
|
||||||
if trimmed != existing.name {
|
ensure_name_available(
|
||||||
let duplicate = correspondents::table
|
|| {
|
||||||
.filter(correspondents::name.eq(trimmed))
|
correspondents::table
|
||||||
|
.filter(correspondents::name.eq(&normalized))
|
||||||
.filter(correspondents::id.ne(correspondent_id))
|
.filter(correspondents::id.ne(correspondent_id))
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.first::<Correspondent>(&mut conn)
|
.first::<Correspondent>(&mut conn)
|
||||||
.optional()?;
|
.optional()
|
||||||
if duplicate.is_some() {
|
},
|
||||||
return Err(AppError::bad_request("correspondent name already exists"));
|
|| AppError::bad_request("correspondent name already exists"),
|
||||||
}
|
)?;
|
||||||
new_name = Some(trimmed.to_string());
|
new_name = Some(normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{DocumentType, NewDocumentType},
|
models::{DocumentType, NewDocumentType},
|
||||||
schema::document_types,
|
schema::document_types,
|
||||||
|
utils::named_entity::normalize_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::documents::DocumentTypeResponse;
|
use super::documents::DocumentTypeResponse;
|
||||||
@@ -64,15 +65,14 @@ pub async fn create_document_type(
|
|||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateDocumentTypeRequest>,
|
Json(payload): Json<CreateDocumentTypeRequest>,
|
||||||
) -> AppResult<(StatusCode, Json<DocumentTypeResponse>)> {
|
) -> AppResult<(StatusCode, Json<DocumentTypeResponse>)> {
|
||||||
let name = payload.name.trim();
|
let name = normalize_name(&payload.name, || {
|
||||||
if name.is_empty() {
|
AppError::bad_request("name must not be empty")
|
||||||
return Err(AppError::bad_request("name must not be empty"));
|
})?;
|
||||||
}
|
|
||||||
|
|
||||||
let new_type = NewDocumentType {
|
let new_type = NewDocumentType {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
tenant_id,
|
tenant_id,
|
||||||
name: name.to_string(),
|
name: name.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match diesel::insert_into(document_types::table)
|
match diesel::insert_into(document_types::table)
|
||||||
@@ -111,19 +111,20 @@ pub async fn update_document_type(
|
|||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<UpdateDocumentTypeRequest>,
|
Json(payload): Json<UpdateDocumentTypeRequest>,
|
||||||
) -> AppResult<Json<DocumentTypeResponse>> {
|
) -> AppResult<Json<DocumentTypeResponse>> {
|
||||||
let name = payload
|
let name_raw = payload
|
||||||
.name
|
.name
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|value| value.trim())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.ok_or_else(|| AppError::bad_request("name must be provided and not 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
|
let target = document_types::table
|
||||||
.filter(document_types::tenant_id.eq(tenant_id))
|
.filter(document_types::tenant_id.eq(tenant_id))
|
||||||
.find(document_type_id);
|
.find(document_type_id);
|
||||||
|
|
||||||
let update_result = diesel::update(target.clone())
|
let update_result = diesel::update(target.clone())
|
||||||
.set(document_types::name.eq(name))
|
.set(document_types::name.eq(&name))
|
||||||
.get_result::<DocumentType>(&mut conn);
|
.get_result::<DocumentType>(&mut conn);
|
||||||
|
|
||||||
match update_result {
|
match update_result {
|
||||||
|
|||||||
+17
-16
@@ -12,6 +12,7 @@ use crate::schema::{document_tags, tags};
|
|||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
db::{no_content, EnsureEntity, IntoJsonResponse},
|
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||||
json::deserialize_patch_field,
|
json::deserialize_patch_field,
|
||||||
|
named_entity::{ensure_name_available, normalize_name},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -125,13 +126,13 @@ pub async fn create_tag(
|
|||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateTagRequest>,
|
Json(payload): Json<CreateTagRequest>,
|
||||||
) -> AppResult<Json<TagCatalogEntry>> {
|
) -> AppResult<Json<TagCatalogEntry>> {
|
||||||
if payload.label.trim().is_empty() {
|
let label = normalize_name(&payload.label, || {
|
||||||
return Err(AppError::bad_request("label must not be empty"));
|
AppError::bad_request("label must not be empty")
|
||||||
}
|
})?;
|
||||||
|
|
||||||
let new_tag = NewTag {
|
let new_tag = NewTag {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
label: payload.label.trim().to_string(),
|
label: label.clone(),
|
||||||
color: payload.color,
|
color: payload.color,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
};
|
};
|
||||||
@@ -211,21 +212,21 @@ pub async fn update_tag(
|
|||||||
return Err(AppError::bad_request("label cannot be null"));
|
return Err(AppError::bad_request("label cannot be null"));
|
||||||
}
|
}
|
||||||
Some(Some(value)) => {
|
Some(Some(value)) => {
|
||||||
let trimmed = value.trim();
|
let normalized =
|
||||||
if trimmed.is_empty() {
|
normalize_name(&value, || AppError::bad_request("label must not be empty"))?;
|
||||||
return Err(AppError::bad_request("label must not be empty"));
|
if normalized != existing.label {
|
||||||
}
|
ensure_name_available(
|
||||||
if trimmed != existing.label {
|
|| {
|
||||||
let duplicate = tags::table
|
tags::table
|
||||||
.filter(tags::label.eq(trimmed))
|
.filter(tags::label.eq(&normalized))
|
||||||
.filter(tags::id.ne(tag_id))
|
.filter(tags::id.ne(tag_id))
|
||||||
.filter(tags::tenant_id.eq(tenant_id))
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
.first::<Tag>(&mut conn)
|
.first::<Tag>(&mut conn)
|
||||||
.optional()?;
|
.optional()
|
||||||
if duplicate.is_some() {
|
},
|
||||||
return Err(AppError::bad_request("tag label already exists"));
|
|| AppError::bad_request("tag label already exists"),
|
||||||
}
|
)?;
|
||||||
new_label = Some(trimmed.to_string());
|
new_label = Some(normalized);
|
||||||
label_changed = true;
|
label_changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod db;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod json;
|
pub mod json;
|
||||||
|
pub mod named_entity;
|
||||||
pub mod storage_paths;
|
pub mod storage_paths;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
pub mod tracing;
|
pub mod tracing;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub mod analyze;
|
pub mod analyze;
|
||||||
|
pub mod common;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod ocr;
|
pub mod ocr;
|
||||||
pub mod tenants;
|
pub mod tenants;
|
||||||
|
|||||||
+27
-44
@@ -24,13 +24,16 @@ use crate::{
|
|||||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||||
NewDocumentAssetObject,
|
NewDocumentAssetObject,
|
||||||
},
|
},
|
||||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
schema::{document_asset_objects, document_assets, documents},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
storage::TenantStorage,
|
storage::TenantStorage,
|
||||||
utils::storage_paths::document_asset_object_prefix,
|
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";
|
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
|
||||||
const MIN_TEXT_LENGTH: usize = 50;
|
const MIN_TEXT_LENGTH: usize = 50;
|
||||||
@@ -234,54 +237,34 @@ struct OcrGeneration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
|
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 base = load_document_version(
|
||||||
|
state.as_ref(),
|
||||||
let version: DocumentVersion = document_versions::table
|
payload.document_id,
|
||||||
.find(payload.document_version_id)
|
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 mut conn = state
|
let mut conn = state
|
||||||
.db_for_tenant(tenant_id)
|
.db_for_tenant(base.tenant_id)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let existing_asset: Option<DocumentAsset> = document_assets::table
|
let mut assets = load_version_assets(
|
||||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
&mut conn,
|
||||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
base.tenant_id,
|
||||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
base.version.id,
|
||||||
.first(&mut conn)
|
&[OCR_TEXT_ASSET_TYPE],
|
||||||
.optional()
|
)?;
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
|
||||||
|
|
||||||
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
|
let (existing_asset, existing_objects) = assets
|
||||||
document_asset_objects::table
|
.remove(OCR_TEXT_ASSET_TYPE)
|
||||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
.map(|entry| (Some(entry.asset), entry.objects))
|
||||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
.unwrap_or((None, Vec::new()));
|
||||||
.order(document_asset_objects::ordinal.asc())
|
|
||||||
.load(&mut conn)
|
|
||||||
.map_err(|err| format!("{err:?}"))?
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_pdf = document_is_pdf(&document);
|
let is_pdf = document_is_pdf(&base.document);
|
||||||
if !is_pdf {
|
if !is_pdf {
|
||||||
return Ok(OcrContext {
|
return Ok(OcrContext {
|
||||||
document,
|
document: base.document,
|
||||||
version,
|
version: base.version,
|
||||||
existing_asset: existing_asset,
|
existing_asset,
|
||||||
existing_objects,
|
existing_objects,
|
||||||
skip: true,
|
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;
|
let skip = existing_asset.is_some() && !payload.force;
|
||||||
|
|
||||||
Ok(OcrContext {
|
Ok(OcrContext {
|
||||||
document,
|
document: base.document,
|
||||||
version,
|
version: base.version,
|
||||||
existing_asset,
|
existing_asset,
|
||||||
existing_objects,
|
existing_objects,
|
||||||
skip,
|
skip,
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution,
|
analyze::determine_thumbnail_support,
|
||||||
JobHandler,
|
common::{load_document_version, load_version_assets},
|
||||||
|
fetch_version_object, handle_fetch_error, JobExecution, JobHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH: u32 = 512;
|
const THUMBNAIL_WIDTH: u32 = 512;
|
||||||
@@ -406,68 +407,39 @@ fn load_thumbnail_context(
|
|||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
payload: &ThumbnailPayload,
|
payload: &ThumbnailPayload,
|
||||||
) -> Result<ThumbnailContext, String> {
|
) -> 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
|
let mut conn = state
|
||||||
.find(payload.document_version_id)
|
.db_for_tenant(base.tenant_id)
|
||||||
.first(&mut conn)
|
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
if version.document_id != payload.document_id {
|
let mut assets = load_version_assets(
|
||||||
return Err("document/version mismatch".into());
|
&mut conn,
|
||||||
}
|
base.tenant_id,
|
||||||
|
base.version.id,
|
||||||
|
&[THUMBNAIL_ASSET_TYPE, PREVIEW_ASSET_TYPE],
|
||||||
|
)?;
|
||||||
|
|
||||||
let document: Document = documents::table
|
let (existing_thumbnail, existing_thumbnail_objects) = assets
|
||||||
.find(payload.document_id)
|
.remove(THUMBNAIL_ASSET_TYPE)
|
||||||
.first(&mut conn)
|
.map(|entry| (Some(entry.asset), entry.objects))
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.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
|
let (supported, _) = determine_thumbnail_support(&base.document);
|
||||||
.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);
|
|
||||||
if !supported {
|
if !supported {
|
||||||
return Err("thumbnail generation not supported for this document".into());
|
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
|
let preview_cardinality = existing_preview
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|asset| asset.cardinality)
|
.and_then(|asset| asset.cardinality)
|
||||||
@@ -488,8 +460,8 @@ fn load_thumbnail_context(
|
|||||||
&& !needs_regeneration;
|
&& !needs_regeneration;
|
||||||
|
|
||||||
Ok(ThumbnailContext {
|
Ok(ThumbnailContext {
|
||||||
document,
|
document: base.document,
|
||||||
version,
|
version: base.version,
|
||||||
existing_thumbnail,
|
existing_thumbnail,
|
||||||
existing_thumbnail_objects,
|
existing_thumbnail_objects,
|
||||||
existing_preview,
|
existing_preview,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ mod common;
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
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 diesel::prelude::*;
|
||||||
use papercrate::auth::passkeys::{
|
use papercrate::auth::passkeys::{
|
||||||
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||||
@@ -28,11 +28,6 @@ struct AuthenticatedUser {
|
|||||||
username: String,
|
username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct ErrorResponse {
|
|
||||||
error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct LoginTenant {
|
struct LoginTenant {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -100,7 +95,7 @@ async fn login_rejects_unknown_user() -> Result<()> {
|
|||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
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");
|
assert_eq!(err.error, "password authentication is no longer supported");
|
||||||
|
|
||||||
app.cleanup().await?;
|
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?;
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
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");
|
assert_eq!(err.error, "password authentication is no longer supported");
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use papercrate::state::AppState;
|
|||||||
use papercrate::storage::ObjectStorage;
|
use papercrate::storage::ObjectStorage;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{self, json};
|
use serde_json::{self, json};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -122,6 +122,13 @@ pub struct TestApp {
|
|||||||
storage: Arc<FakeStorage>,
|
storage: Arc<FakeStorage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ApiErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl TestApp {
|
impl TestApp {
|
||||||
pub async fn new() -> Result<Self> {
|
pub async fn new() -> Result<Self> {
|
||||||
let database_url = env::var("TEST_DATABASE_URL")
|
let database_url = env::var("TEST_DATABASE_URL")
|
||||||
@@ -538,6 +545,7 @@ impl TestApp {
|
|||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: false,
|
skip_existing: false,
|
||||||
|
document_type_id: None,
|
||||||
};
|
};
|
||||||
self.upload_document_with_extras(
|
self.upload_document_with_extras(
|
||||||
path,
|
path,
|
||||||
@@ -666,6 +674,7 @@ pub struct UploadExtras<'a> {
|
|||||||
pub correspondents_json: Option<&'a str>,
|
pub correspondents_json: Option<&'a str>,
|
||||||
pub issued_at: Option<&'a str>,
|
pub issued_at: Option<&'a str>,
|
||||||
pub skip_existing: bool,
|
pub skip_existing: bool,
|
||||||
|
pub document_type_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> UploadExtras<'a> {
|
impl<'a> UploadExtras<'a> {
|
||||||
@@ -677,6 +686,7 @@ impl<'a> UploadExtras<'a> {
|
|||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: false,
|
skip_existing: false,
|
||||||
|
document_type_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,7 +719,11 @@ mod helper_tests {
|
|||||||
let (access, refresh, refresh_id) = app.create_session(username).await?;
|
let (access, refresh, refresh_id) = app.create_session(username).await?;
|
||||||
assert!(!access.is_empty(), "access token should not be empty");
|
assert!(!access.is_empty(), "access token should not be empty");
|
||||||
assert!(!refresh.is_empty(), "refresh 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?;
|
let bearer = app.login_token(username, password).await?;
|
||||||
assert!(!bearer.is_empty(), "login_token must yield bearer");
|
assert!(!bearer.is_empty(), "login_token must yield bearer");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ mod common;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::http::StatusCode;
|
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::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -97,13 +97,6 @@ struct AnalyzeJobPayload {
|
|||||||
force: bool,
|
force: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct ErrorResponse {
|
|
||||||
error: String,
|
|
||||||
#[serde(default)]
|
|
||||||
code: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct FolderResponse {
|
struct FolderResponse {
|
||||||
folder: FolderInfo,
|
folder: FolderInfo,
|
||||||
@@ -1062,7 +1055,7 @@ async fn patch_document_updates_title_and_handles_conflict() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(conflict.status(), StatusCode::CONFLICT);
|
assert_eq!(conflict.status(), StatusCode::CONFLICT);
|
||||||
let conflict_body = body_to_vec(conflict.into_body()).await?;
|
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"));
|
assert_eq!(conflict_json.code.as_deref(), Some("duplicate_filename"));
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
@@ -1244,7 +1237,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST);
|
||||||
let title_body = body_to_vec(empty_title.into_body()).await?;
|
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");
|
assert_eq!(title_error.error, "title must not be empty");
|
||||||
|
|
||||||
let empty_issued = app
|
let empty_issued = app
|
||||||
@@ -1256,7 +1249,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST);
|
||||||
let issued_body = body_to_vec(empty_issued.into_body()).await?;
|
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");
|
assert_eq!(issued_error.error, "issued_at must not be empty");
|
||||||
|
|
||||||
let invalid_merge = app
|
let invalid_merge = app
|
||||||
@@ -1272,7 +1265,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST);
|
||||||
let merge_body = body_to_vec(invalid_merge.into_body()).await?;
|
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!(
|
assert_eq!(
|
||||||
merge_error.error,
|
merge_error.error,
|
||||||
"metadata value must be a JSON object when replace is false"
|
"metadata value must be a JSON object when replace is false"
|
||||||
@@ -1308,7 +1301,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(merge_after_scalar.status(), StatusCode::BAD_REQUEST);
|
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_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!(
|
assert_eq!(
|
||||||
merge_after_error.error,
|
merge_after_error.error,
|
||||||
"existing metadata is not an object; set replace=true to overwrite"
|
"existing metadata is not an object; set replace=true to overwrite"
|
||||||
@@ -1323,7 +1316,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST);
|
||||||
let malformed_body = body_to_vec(malformed_timestamp.into_body()).await?;
|
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!(
|
assert!(
|
||||||
malformed_error
|
malformed_error
|
||||||
.error
|
.error
|
||||||
@@ -1591,7 +1584,10 @@ async fn list_documents_filtered_by_document_type() -> Result<()> {
|
|||||||
assert!(refreshed_a.status().is_success());
|
assert!(refreshed_a.status().is_success());
|
||||||
let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?;
|
let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?;
|
||||||
let refreshed_a_detail: DocumentDetail = serde_json::from_slice(&refreshed_a_body)?;
|
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
|
let refreshed_b = app
|
||||||
.get(
|
.get(
|
||||||
@@ -1602,7 +1598,10 @@ async fn list_documents_filtered_by_document_type() -> Result<()> {
|
|||||||
assert!(refreshed_b.status().is_success());
|
assert!(refreshed_b.status().is_success());
|
||||||
let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?;
|
let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?;
|
||||||
let refreshed_b_detail: DocumentDetail = serde_json::from_slice(&refreshed_b_body)?;
|
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?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user