From 14cdd2a04ab631734fad1f8680730f46b93c3c10 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 13:24:29 +0200 Subject: [PATCH] feat(db): audited stepwise set_visibility + public-only object readers Co-Authored-By: Claude Sonnet 4.6 --- crates/db/src/catalog.rs | 99 +++++++++++++++++++- crates/db/tests/visibility.rs | 171 ++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 crates/db/tests/visibility.rs diff --git a/crates/db/src/catalog.rs b/crates/db/src/catalog.rs index e26bbe5..bd60672 100644 --- a/crates/db/src/catalog.rs +++ b/crates/db/src/catalog.rs @@ -2,8 +2,8 @@ //! on the caller's connection, so the change and its audit entry commit together. use domain::{ - AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, NewAuditEvent, ObjectId, - ObjectInput, Visibility, + AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition, + NewAuditEvent, ObjectId, ObjectInput, Visibility, }; use serde_json::{Value, json}; use sqlx::Row; @@ -13,6 +13,9 @@ use crate::{audit, authority, fields, vocab}; /// The entity_type recorded in the audit log for catalogue objects. const ENTITY_TYPE: &str = "object"; +/// The visibility value eligible for the public surface. +const PUBLIC_VISIBILITY: &str = "public"; + const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \ brief_description, current_location, current_owner, recorder, recording_date, \ visibility, fields, created_at, updated_at"; @@ -93,6 +96,63 @@ where rows.into_iter().map(map_object).collect() } +/// Fetch one **public** object by id. Returns `None` if the object is missing **or** +/// not public — callers map both to 404 so non-public existence isn't revealed. +pub async fn public_object_by_id<'e, E>( + executor: E, + id: ObjectId, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1 AND visibility = $2"); + + let row = sqlx::query(&sql) + .bind(id.to_uuid()) + .bind(PUBLIC_VISIBILITY) + .fetch_optional(executor) + .await?; + + row.map(map_object).transpose() +} + +/// List **public** objects ordered by object number, with `limit`/`offset` paging. +pub async fn list_public_objects<'e, E>( + executor: E, + limit: i64, + offset: i64, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!( + "SELECT {OBJECT_COLUMNS} FROM object WHERE visibility = $1 \ + ORDER BY object_number LIMIT $2 OFFSET $3" + ); + + let rows = sqlx::query(&sql) + .bind(PUBLIC_VISIBILITY) + .bind(limit) + .bind(offset) + .fetch_all(executor) + .await?; + + rows.into_iter().map(map_object).collect() +} + +/// Count all public objects (for pagination totals). +pub async fn count_public_objects<'e, E>(executor: E) -> Result +where + E: sqlx::PgExecutor<'e>, +{ + let row = sqlx::query("SELECT count(*) AS n FROM object WHERE visibility = $1") + .bind(PUBLIC_VISIBILITY) + .fetch_one(executor) + .await?; + + row.try_get("n") +} + fn map_object(row: sqlx::postgres::PgRow) -> Result { let visibility_str: String = row.try_get("visibility")?; let visibility = Visibility::from_db(&visibility_str).ok_or_else(|| { @@ -230,6 +290,41 @@ pub async fn update_object( Ok(true) } +/// Why changing an object's visibility failed. +#[derive(Debug, thiserror::Error)] +pub enum VisibilityError { + #[error("object not found")] + ObjectNotFound, + #[error(transparent)] + Illegal(#[from] IllegalTransition), + #[error(transparent)] + Db(#[from] sqlx::Error), +} + +/// Move an object to `target` visibility, enforcing the stepwise state machine, and +/// audit the change. Reuses [`update_object`]'s diff/audit path, so only `visibility` +/// appears in the audit entry — and setting to the current value is an idempotent no-op +/// (no row touch, no audit). Pass a transaction connection. +pub async fn set_visibility( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: ObjectId, + target: Visibility, +) -> Result<(), VisibilityError> { + let Some(object) = object_by_id(&mut *conn, id).await? else { + return Err(VisibilityError::ObjectNotFound); + }; + + let new_visibility = object.visibility.transition_to(target)?; + + let mut input = object.to_input(); + + input.visibility = new_visibility; + update_object(&mut *conn, actor, id, &input).await?; + + Ok(()) +} + /// 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( diff --git a/crates/db/tests/visibility.rs b/crates/db/tests/visibility.rs new file mode 100644 index 0000000..c020840 --- /dev/null +++ b/crates/db/tests/visibility.rs @@ -0,0 +1,171 @@ +use db::{Db, audit, catalog}; +use domain::{AuditAction, AuditActor, IllegalTransition, ObjectInput, Visibility}; +use sqlx::PgPool; + +fn object(number: &str, visibility: Visibility) -> ObjectInput { + ObjectInput { + object_number: number.into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility, + } +} + +#[sqlx::test] +async fn publish_steps_through_internal_and_audits(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().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(); + catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(obj.visibility, Visibility::Public); + + let history = audit::history_for(db.pool(), "object", id.to_uuid()) + .await + .unwrap(); + assert_eq!(history.len(), 3); // created + two visibility updates + assert_eq!(history[2].action, AuditAction::Updated); + let changed: Vec<&str> = history[2] + .changes + .iter() + .map(|c| c.field.as_str()) + .collect(); + assert_eq!(changed, vec!["visibility"]); +} + +#[sqlx::test] +async fn skipping_a_step_is_rejected_and_unchanged(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object( + &mut tx, + AuditActor::System, + &object("LM-1", Visibility::Draft), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mut tx = db.pool().begin().await.unwrap(); + let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public) + .await + .unwrap_err(); + tx.commit().await.unwrap(); + assert!(matches!( + err, + catalog::VisibilityError::Illegal(IllegalTransition { + from: Visibility::Draft, + to: Visibility::Public + }) + )); + + let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(obj.visibility, Visibility::Draft); // unchanged +} + +#[sqlx::test] +async fn set_visibility_on_missing_object_errors(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let err = catalog::set_visibility( + &mut tx, + AuditActor::System, + domain::ObjectId::new(), + Visibility::Internal, + ) + .await + .unwrap_err(); + tx.commit().await.unwrap(); + assert!(matches!(err, catalog::VisibilityError::ObjectNotFound)); +} + +#[sqlx::test] +async fn no_op_set_to_current_visibility_writes_no_audit(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().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::Draft) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let history = audit::history_for(db.pool(), "object", id.to_uuid()) + .await + .unwrap(); + assert_eq!(history.len(), 1); // only `created`; the no-op transition recorded nothing +} + +#[sqlx::test] +async fn public_reads_return_only_public_records(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let draft = catalog::create_object( + &mut tx, + AuditActor::System, + &object("D-1", Visibility::Draft), + ) + .await + .unwrap(); + let pub_id = catalog::create_object( + &mut tx, + AuditActor::System, + &object("P-1", Visibility::Public), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + assert!( + catalog::public_object_by_id(db.pool(), pub_id) + .await + .unwrap() + .is_some() + ); + assert!( + catalog::public_object_by_id(db.pool(), draft) + .await + .unwrap() + .is_none() + ); + + let listed = catalog::list_public_objects(db.pool(), 50, 0) + .await + .unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, pub_id); + assert_eq!(catalog::count_public_objects(db.pool()).await.unwrap(), 1); + + assert!( + catalog::list_public_objects(db.pool(), 50, 1) + .await + .unwrap() + .is_empty() + ); +}