feat(db): audited stepwise set_visibility + public-only object readers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
//! on the caller's connection, so the change and its audit entry commit together.
|
//! on the caller's connection, so the change and its audit entry commit together.
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, NewAuditEvent, ObjectId,
|
AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition,
|
||||||
ObjectInput, Visibility,
|
NewAuditEvent, ObjectId, ObjectInput, Visibility,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
@@ -13,6 +13,9 @@ use crate::{audit, authority, fields, vocab};
|
|||||||
/// The entity_type recorded in the audit log for catalogue objects.
|
/// The entity_type recorded in the audit log for catalogue objects.
|
||||||
const ENTITY_TYPE: &str = "object";
|
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, \
|
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
|
||||||
brief_description, current_location, current_owner, recorder, recording_date, \
|
brief_description, current_location, current_owner, recorder, recording_date, \
|
||||||
visibility, fields, created_at, updated_at";
|
visibility, fields, created_at, updated_at";
|
||||||
@@ -93,6 +96,63 @@ where
|
|||||||
rows.into_iter().map(map_object).collect()
|
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<Option<CatalogueObject>, 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<Vec<CatalogueObject>, 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<i64, sqlx::Error>
|
||||||
|
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<CatalogueObject, sqlx::Error> {
|
fn map_object(row: sqlx::postgres::PgRow) -> Result<CatalogueObject, sqlx::Error> {
|
||||||
let visibility_str: String = row.try_get("visibility")?;
|
let visibility_str: String = row.try_get("visibility")?;
|
||||||
let visibility = Visibility::from_db(&visibility_str).ok_or_else(|| {
|
let visibility = Visibility::from_db(&visibility_str).ok_or_else(|| {
|
||||||
@@ -230,6 +290,41 @@ pub async fn update_object(
|
|||||||
Ok(true)
|
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`.
|
/// Delete an object and record a `deleted` audit entry, both on `conn`.
|
||||||
/// Returns `false` if the object did not exist.
|
/// Returns `false` if the object did not exist.
|
||||||
pub async fn delete_object(
|
pub async fn delete_object(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user