use serde::Serialize;
use sqlx::FromRow;
use crate::error::{Error, Result};
use crate::writer::rfc3339_from_epoch_ms;
#[derive(Debug, FromRow)]
pub(super) struct AuditRow {
pub id: i64,
pub created_at: i64,
pub action: String,
pub actor_did: String,
pub target: Option<String>,
pub target_cid: Option<String>,
pub outcome: String,
pub reason: Option<String>,
pub prev_hash: Option<Vec<u8>>,
pub row_hash: Option<Vec<u8>>,
}
#[derive(Debug, Serialize)]
pub(super) struct AuditEntry {
pub id: i64,
#[serde(rename = "createdAt")]
pub created_at: String,
pub action: String,
#[serde(rename = "actorDid")]
pub actor_did: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "targetCid")]
pub target_cid: Option<String>,
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "prevHash")]
pub prev_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "rowHash")]
pub row_hash: Option<String>,
}
pub(super) fn project(row: AuditRow) -> Result<AuditEntry> {
let created_at = rfc3339_from_epoch_ms(row.created_at).map_err(|e| {
Error::Signing(format!(
"audit_log row {id} created_at out of range: {e}",
id = row.id
))
})?;
Ok(AuditEntry {
id: row.id,
created_at,
action: row.action,
actor_did: row.actor_did,
target: row.target,
target_cid: row.target_cid,
outcome: row.outcome,
reason: row.reason,
prev_hash: row.prev_hash.as_deref().map(hex::encode),
row_hash: row.row_hash.as_deref().map(hex::encode),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn row(reason: Option<&str>) -> AuditRow {
AuditRow {
id: 42,
created_at: 1_776_902_400_000,
action: "label_applied".into(),
actor_did: "did:plc:mod".into(),
target: Some("at://did:plc:target/col/r".into()),
target_cid: Some("bafy".into()),
outcome: "success".into(),
reason: reason.map(str::to_string),
prev_hash: Some(vec![0u8; 32]),
row_hash: Some(vec![0xAB; 32]),
}
}
fn row_pre_attestation() -> AuditRow {
AuditRow {
id: 7,
created_at: 1_776_902_400_000,
action: "label_applied".into(),
actor_did: "did:plc:mod".into(),
target: None,
target_cid: None,
outcome: "success".into(),
reason: None,
prev_hash: None,
row_hash: None,
}
}
#[test]
fn projection_converts_epoch_ms_to_rfc3339_z() {
let entry = project(row(None)).expect("project");
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["createdAt"], "2026-04-23T00:00:00.000Z");
}
#[test]
fn reason_passthrough_preserves_json_bytes() {
let stored = r#"{"val":"spam","neg":false,"moderator_reason":null}"#;
let entry = project(row(Some(stored))).expect("project");
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["reason"], stored);
}
#[test]
fn absent_reason_serializes_without_field() {
let entry = project(row(None)).expect("project");
let json = serde_json::to_value(&entry).unwrap();
assert!(
json.get("reason").is_none(),
"absent reason must be field-omitted, not null: {json}"
);
}
#[test]
fn required_fields_always_present() {
let entry = project(row(None)).expect("project");
let json = serde_json::to_value(&entry).unwrap();
for k in &["id", "createdAt", "action", "actorDid", "outcome"] {
assert!(json.get(k).is_some(), "required field {k} missing: {json}");
}
}
#[test]
fn hash_columns_hex_encoded_on_attested_row() {
let entry = project(row(None)).expect("project");
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["prevHash"].as_str().unwrap(), "0".repeat(64));
assert_eq!(json["rowHash"].as_str().unwrap(), "ab".repeat(32));
}
#[test]
fn hash_columns_field_absent_on_pre_attestation_row() {
let entry = project(row_pre_attestation()).expect("project");
let json = serde_json::to_value(&entry).unwrap();
assert!(
json.get("prevHash").is_none(),
"prevHash must be field-absent for pre-attestation rows: {json}"
);
assert!(
json.get("rowHash").is_none(),
"rowHash must be field-absent for pre-attestation rows: {json}"
);
}
}