feat(db): enforce required-field completeness on publish (#16)

set_visibility now gates the transition to Public: every field definition
with required=true must have a value on the object (typed inventory-minimum
columns are already NOT NULL, so only flexible required fields are checked).
Missing values yield VisibilityError::MissingRequiredFields(keys); the admin
publish endpoint maps it to 422. The gate runs in db so every caller is
protected and the check is atomic with the transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 23:36:24 +02:00
parent 4921c73fa7
commit e96f74f47a
4 changed files with 181 additions and 0 deletions
+3
View File
@@ -166,6 +166,9 @@ pub(crate) async fn set_visibility(
}
Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT),
Err(db::catalog::VisibilityError::MissingRequiredFields(_)) => {
Err(StatusCode::UNPROCESSABLE_ENTITY)
}
Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
+77
View File
@@ -335,3 +335,80 @@ async fn illegal_visibility_transition_is_409(pool: PgPool) {
.unwrap();
assert_eq!(publish.status(), StatusCode::CONFLICT);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn publishing_without_required_field_is_422(pool: PgPool) {
use domain::{FieldType, LocalizedLabel, NewFieldDefinition};
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
let db = db::Db::from_pool(pool.clone());
let mut tx = db.pool().begin().await.unwrap();
db::fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "inscription".into(),
field_type: FieldType::Text,
required: true,
group_key: None,
labels: vec![LocalizedLabel {
lang: "en".into(),
label: "Inscription".into(),
}],
},
)
.await
.unwrap();
let id = catalog::create_object(
&mut tx,
AuditActor::System,
&ObjectInput {
object_number: "P-2".into(),
object_name: "vase".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Internal,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let app = build_app(state(pool.clone()));
let resp = app
.clone()
.oneshot(login_request("editor@example.com", "pw-editor-123"))
.await
.unwrap();
let cookie = session_cookie(&resp);
// publishing while a required field has no value -> 422, visibility unchanged
let publish = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/admin/objects/{id}/visibility"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"visibility":"public"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(publish.status(), StatusCode::UNPROCESSABLE_ENTITY);
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.visibility, Visibility::Internal);
}
+29
View File
@@ -348,6 +348,8 @@ pub enum VisibilityError {
ObjectNotFound,
#[error(transparent)]
Illegal(#[from] IllegalTransition),
#[error("missing required field(s): {}", .0.join(", "))]
MissingRequiredFields(Vec<String>),
#[error(transparent)]
Db(#[from] sqlx::Error),
}
@@ -368,6 +370,17 @@ pub async fn set_visibility(
let new_visibility = object.visibility.transition_to(target)?;
// The publish gate: a record may only become public once every required field
// has a value. The typed inventory-minimum columns are already NOT NULL, so only
// the flexible required fields need checking here.
if new_visibility == Visibility::Public {
let missing = missing_required_fields(&mut *conn, &object.fields).await?;
if !missing.is_empty() {
return Err(VisibilityError::MissingRequiredFields(missing));
}
}
let old_input = object.to_input();
let mut new_input = old_input.clone();
@@ -377,6 +390,22 @@ pub async fn set_visibility(
Ok(())
}
/// The keys of `required` field definitions that have no value on `fields` (absent or
/// null). Empty when every required field is present.
async fn missing_required_fields(
conn: &mut sqlx::PgConnection,
fields: &Value,
) -> Result<Vec<String>, sqlx::Error> {
let definitions = fields::list_field_definitions(&mut *conn).await?;
Ok(definitions
.into_iter()
.filter(|definition| definition.required)
.filter(|definition| fields.get(&definition.key).is_none_or(Value::is_null))
.map(|definition| definition.key)
.collect())
}
/// Delete an object and record a `deleted` audit entry, both on `conn`.
/// Returns `false` if the object did not exist.
pub async fn delete_object(
+72
View File
@@ -184,3 +184,75 @@ async fn public_reads_return_only_public_records(pool: PgPool) {
.is_none()
);
}
#[sqlx::test]
async fn publishing_requires_all_required_fields_present(pool: PgPool) {
use db::fields;
use domain::{FieldType, LocalizedLabel, NewFieldDefinition};
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
// a required flexible field
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "inscription".into(),
field_type: FieldType::Text,
required: true,
group_key: None,
labels: vec![LocalizedLabel {
lang: "en".into(),
label: "Inscription".into(),
}],
},
)
.await
.unwrap();
let id = catalog::create_object(
&mut tx,
AuditActor::System,
&object("LM-1", Visibility::Draft),
)
.await
.unwrap();
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Internal)
.await
.unwrap();
// publishing without the required field present is rejected
let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
.await
.unwrap_err();
assert!(
matches!(err, catalog::VisibilityError::MissingRequiredFields(ref keys) if keys == &["inscription"])
);
// the object is still not public
let still = catalog::object_by_id(&mut *tx, id).await.unwrap().unwrap();
assert_eq!(still.visibility, Visibility::Internal);
// set the required field, then publishing succeeds
catalog::set_object_fields(
&mut tx,
AuditActor::System,
id,
serde_json::json!({ "inscription": "To the gods" })
.as_object()
.unwrap(),
)
.await
.unwrap();
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
.await
.unwrap();
tx.commit().await.unwrap();
let published = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(published.visibility, Visibility::Public);
}