use crate::audit_chain::{
self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent, EventRow,
VerifyOutcome, CURRENT_KEY_ID,
};
use crate::audit_watermark::emit_chain_tip_watermark;
use crate::auth::CallerPrincipal;
use rusqlite::{params, Connection, TransactionBehavior};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use subtle::ConstantTimeEq;
use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RiskLevel, TransactionRecord};
use uuid::Uuid;
pub(crate) const APPROVAL_RECEIPT_TTL_MINUTES: i64 = 15;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JobHistoryEntry {
pub transaction_id: String,
pub action_name: String,
pub risk_level: RiskLevel,
pub status: JobState,
pub summary: String,
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NewTransaction {
pub request_id: String,
pub request_hash: String,
pub action_name: String,
pub risk_level: RiskLevel,
pub summary: String,
pub warnings: Vec<String>,
pub caller_role: CallerRole,
pub caller_principal: CallerPrincipal,
}
struct SqliteMigration {
version: i64,
name: &'static str,
sql: &'static str,
}
const SQLITE_MIGRATIONS: &[SqliteMigration] = &[
SqliteMigration {
version: 1,
name: "initial_audit_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS transactions (
transaction_id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
request_hash TEXT NOT NULL,
action_name TEXT NOT NULL,
risk_level TEXT NOT NULL,
status TEXT NOT NULL,
approval_id TEXT,
summary TEXT NOT NULL,
warnings_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
seq INTEGER NOT NULL UNIQUE,
key_id TEXT NOT NULL,
chain_hash TEXT NOT NULL,
prev_chain_hash TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS transaction_previews (
transaction_id TEXT PRIMARY KEY,
preview_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS transaction_approvals (
transaction_id TEXT PRIMARY KEY,
receipt_digest TEXT NOT NULL,
approved_at TEXT NOT NULL DEFAULT (datetime('now')),
consumed_at TEXT
);
CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
"#,
},
SqliteMigration {
version: 2,
name: "caller_identity_and_approval_events",
sql: r#"
ALTER TABLE transactions ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE transactions ADD COLUMN caller_role TEXT;
ALTER TABLE transactions ADD COLUMN event_tip TEXT;
CREATE TABLE IF NOT EXISTS audit_events (
seq INTEGER PRIMARY KEY,
key_id TEXT NOT NULL,
kind TEXT NOT NULL,
transaction_id TEXT NOT NULL,
receipt_digest TEXT NOT NULL,
created_at TEXT NOT NULL,
chain_hash TEXT NOT NULL,
prev_chain_hash TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS audit_events_transaction_idx
ON audit_events(transaction_id);
"#,
},
SqliteMigration {
version: 3,
name: "caller_principal",
sql: r#"
ALTER TABLE transactions ADD COLUMN caller_principal TEXT;
"#,
},
];
use crate::store::CHAIN_ROW_COLUMNS;
fn chain_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainRow> {
Ok(ChainRow {
seq: row.get::<_, i64>(0)? as u64,
key_id: row.get(1)?,
transaction_id: row.get(2)?,
request_id: row.get(3)?,
request_hash: row.get(4)?,
action_name: row.get(5)?,
risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
})?,
summary: row.get(7)?,
approval_id: row.get(8)?,
warnings_json: row.get(9)?,
created_at: row.get(10)?,
prev_chain_hash: row.get(11)?,
chain_hash: row.get(12)?,
chain_version: row.get::<_, i64>(13)? as u32,
caller_role: row.get(14)?,
event_tip: row.get(15)?,
caller_principal: row.get(16)?,
})
}
fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<EventRow> {
Ok(EventRow {
seq: row.get::<_, i64>(0)? as u64,
key_id: row.get(1)?,
kind: row.get(2)?,
transaction_id: row.get(3)?,
receipt_digest: row.get(4)?,
created_at: row.get(5)?,
prev_chain_hash: row.get(6)?,
chain_hash: row.get(7)?,
})
}
fn query_optional<T>(
conn: &Connection,
sql: &str,
params: impl rusqlite::Params,
f: impl FnOnce(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
) -> rusqlite::Result<Option<T>> {
match conn.query_row(sql, params, f) {
Ok(v) => Ok(Some(v)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
#[derive(Clone, Debug)]
pub struct TransactionStore {
path: PathBuf,
audit_key: Option<Arc<AuditKey>>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RecordedPreviewedTransaction {
pub transaction: TransactionRecord,
pub preview: PreviewEnvelope,
}
struct InsertedTransaction {
record: TransactionRecord,
seq: u64,
chain_hash: String,
}
#[derive(Debug, thiserror::Error)]
pub enum TransactionStoreError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("database invariant violation: {0}")]
DatabaseInvariant(String),
#[error("transaction not found: {0}")]
NotFound(String),
#[error("invalid transition from {from:?} to {to:?}")]
InvalidTransition { from: JobState, to: JobState },
#[error("audit chain misconfiguration: {0}")]
AuditChainMissing(&'static str),
#[error("transaction {0} status changed concurrently; retry")]
ConcurrentStatusChange(String),
}
impl TransactionStore {
pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
ensure_private_dir(parent)?;
}
let store = Self {
path,
audit_key: None,
};
store.initialize()?;
Ok(store)
}
pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
let db_path = path.as_ref();
let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| {
db_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("audit-key")
});
let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
TransactionStoreError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("audit key load failed: {e}"),
))
})?;
Self::open_with_key(path, Arc::new(key))
}
pub fn open_with_key(
path: impl AsRef<Path>,
audit_key: Arc<AuditKey>,
) -> Result<Self, TransactionStoreError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
ensure_private_dir(parent)?;
}
let store = Self {
path,
audit_key: Some(audit_key),
};
store.initialize()?;
restrict_db_file_mode(&store.path);
Ok(store)
}
fn record_with(
&self,
transaction: NewTransaction,
in_tx: impl FnOnce(&Connection, &InsertedTransaction) -> Result<(), TransactionStoreError>,
) -> Result<InsertedTransaction, TransactionStoreError> {
let key = self
.audit_key
.as_ref()
.ok_or(TransactionStoreError::AuditChainMissing(
"this TransactionStore was opened read-only; cannot record",
))?;
let mut conn = self.connection()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let transaction_id = Uuid::new_v4().to_string();
let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
in_tx(&tx, &inserted)?;
tx.commit()?;
emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
Ok(inserted)
}
pub fn record(
&self,
transaction: NewTransaction,
) -> Result<TransactionRecord, TransactionStoreError> {
let inserted = self.record_with(transaction, |_, _| Ok(()))?;
Ok(inserted.record)
}
pub fn record_previewed(
&self,
transaction: NewTransaction,
preview: PreviewEnvelope,
) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
let inserted = self.record_with(transaction, |conn, inserted| {
Self::insert_preview(conn, &inserted.record.transaction_id, &preview)
})?;
Ok(RecordedPreviewedTransaction {
transaction: inserted.record,
preview,
})
}
pub fn get(
&self,
transaction_id: &str,
) -> Result<Option<TransactionRecord>, TransactionStoreError> {
let conn = self.connection()?;
let mut stmt = conn.prepare(
"SELECT
transaction_id,
request_id,
request_hash,
action_name,
risk_level,
status,
approval_id,
summary,
warnings_json
FROM transactions
WHERE transaction_id = ?1",
)?;
let mut rows = stmt.query(params![transaction_id])?;
if let Some(row) = rows.next()? {
Ok(Some(row_to_record(row)?))
} else {
Ok(None)
}
}
pub fn get_preview(
&self,
transaction_id: &str,
) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
let conn = self.connection()?;
let mut stmt = conn.prepare(
"SELECT preview_json
FROM transaction_previews
WHERE transaction_id = ?1",
)?;
let mut rows = stmt.query(params![transaction_id])?;
if let Some(row) = rows.next()? {
let preview_json: String = row.get(0)?;
Ok(Some(serde_json::from_str(&preview_json)?))
} else {
Ok(None)
}
}
pub fn update_status(
&self,
transaction_id: &str,
new_status: JobState,
) -> Result<(), TransactionStoreError> {
let conn = self.connection()?;
let current_status: String = conn
.query_row(
"SELECT status FROM transactions WHERE transaction_id = ?1",
params![transaction_id],
|row| row.get(0),
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => {
TransactionStoreError::NotFound(transaction_id.to_string())
}
other => TransactionStoreError::Sqlite(other),
})?;
let current: JobState = deserialize_field(¤t_status)?;
if !crate::jobs::allowed_transition(¤t, &new_status) {
return Err(TransactionStoreError::InvalidTransition {
from: current,
to: new_status,
});
}
let rows_affected = conn.execute(
"UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
params![
serialize_field(&new_status)?,
transaction_id,
current_status
],
)?;
if rows_affected == 0 {
return Err(TransactionStoreError::ConcurrentStatusChange(
transaction_id.to_string(),
));
}
Ok(())
}
pub fn approve_transaction(
&self,
transaction_id: &str,
) -> Result<Option<String>, TransactionStoreError> {
let key = self
.audit_key
.as_ref()
.ok_or(TransactionStoreError::AuditChainMissing(
"this TransactionStore was opened read-only; cannot approve",
))?;
let Some(record) = self.get(transaction_id)? else {
return Ok(None);
};
let receipt = key.approval_receipt(transaction_id, &record.request_hash);
let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
let Some(committed_digest) = record.approval_id.as_deref() else {
return Err(TransactionStoreError::DatabaseInvariant(format!(
"transaction {transaction_id} has no signed approval commitment"
)));
};
if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
return Err(TransactionStoreError::DatabaseInvariant(format!(
"transaction {transaction_id} approval commitment does not match its signed preview"
)));
}
let mut conn = self.connection()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let queued_json = serialize_field(&JobState::Queued)?;
let rows_affected = tx.execute(
&format!(
"INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
SELECT transaction_id, ?1 FROM transactions \
WHERE transaction_id = ?2 \
AND status = ?3 \
AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
AND NOT EXISTS ( \
SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
)"
),
params![receipt_digest, transaction_id, queued_json],
)?;
if rows_affected > 0 {
Self::append_event(
&tx,
key,
AuditEventKind::ApprovalGranted,
transaction_id,
&receipt_digest,
)?;
}
tx.commit()?;
Ok((rows_affected > 0).then_some(receipt))
}
pub fn revoke_unconsumed_approval(
&self,
transaction_id: &str,
) -> Result<bool, TransactionStoreError> {
let key = self
.audit_key
.as_ref()
.ok_or(TransactionStoreError::AuditChainMissing(
"this TransactionStore was opened read-only; cannot revoke",
))?;
let mut conn = self.connection()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let revoked = Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?;
tx.commit()?;
Ok(revoked)
}
fn revoke_unconsumed_approval_in_tx(
conn: &Connection,
key: &AuditKey,
transaction_id: &str,
) -> Result<bool, TransactionStoreError> {
let digest: Option<String> = query_optional(
conn,
"SELECT receipt_digest FROM transaction_approvals \
WHERE transaction_id = ?1 AND consumed_at IS NULL",
params![transaction_id],
|row| row.get(0),
)?;
let rows_affected = conn.execute(
"DELETE FROM transaction_approvals \
WHERE transaction_id = ?1 AND consumed_at IS NULL",
params![transaction_id],
)?;
if rows_affected > 0 {
let digest = digest.ok_or_else(|| {
TransactionStoreError::DatabaseInvariant(format!(
"revoked an approval for {transaction_id} that had no receipt digest"
))
})?;
Self::append_event(
conn,
key,
AuditEventKind::ApprovalRevoked,
transaction_id,
&digest,
)?;
}
Ok(rows_affected > 0)
}
pub fn claim_approved_for_execution(
&self,
transaction_id: &str,
receipt_digest: &str,
) -> Result<bool, TransactionStoreError> {
let key = self
.audit_key
.as_ref()
.ok_or(TransactionStoreError::AuditChainMissing(
"this TransactionStore was opened read-only; cannot claim",
))?;
let mut conn = self.connection()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let queued_json = serialize_field(&JobState::Queued)?;
let running_json = serialize_field(&JobState::Running)?;
let rows_affected = tx.execute(
&format!(
"UPDATE transactions SET status = ?1 \
WHERE transaction_id = ?2 \
AND status = ?3 \
AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
AND EXISTS ( \
SELECT 1 FROM transaction_approvals \
WHERE transaction_id = ?2 \
AND receipt_digest = ?4 \
AND consumed_at IS NULL \
)"
),
params![running_json, transaction_id, queued_json, receipt_digest],
)?;
if rows_affected > 0 {
tx.execute(
"UPDATE transaction_approvals \
SET consumed_at = datetime('now') \
WHERE transaction_id = ?1 AND consumed_at IS NULL",
params![transaction_id],
)?;
Self::append_event(
&tx,
key,
AuditEventKind::ApprovalConsumed,
transaction_id,
receipt_digest,
)?;
}
tx.commit()?;
Ok(rows_affected > 0)
}
pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
let key = self
.audit_key
.as_ref()
.ok_or(TransactionStoreError::AuditChainMissing(
"this TransactionStore was opened read-only; cannot clean up",
))?;
let mut conn = self.connection()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let canceled_json = serialize_field(&JobState::Canceled)?;
let queued_json = serialize_field(&JobState::Queued)?;
let stale_ids: Vec<String> = {
let mut statement = tx.prepare(&format!(
"SELECT transaction_id FROM transactions \
WHERE status = ?1 \
AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
))?;
let stale_ids = statement
.query_map(params![queued_json], |row| row.get(0))?
.collect::<Result<_, _>>()?;
stale_ids
};
let mut canceled = 0;
for transaction_id in stale_ids {
let rows_affected = tx.execute(
"UPDATE transactions SET status = ?1 \
WHERE transaction_id = ?2 AND status = ?3",
params![canceled_json, transaction_id, queued_json],
)?;
if rows_affected > 0 {
Self::revoke_unconsumed_approval_in_tx(&tx, key, &transaction_id)?;
canceled += rows_affected;
}
}
tx.commit()?;
Ok(canceled as u64)
}
pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
let key = self
.audit_key
.as_ref()
.ok_or(TransactionStoreError::AuditChainMissing(
"this TransactionStore was opened read-only; cannot cancel",
))?;
let mut conn = self.connection()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let canceled_json = serialize_field(&JobState::Canceled)?;
let queued_json = serialize_field(&JobState::Queued)?;
let rows_affected = tx.execute(
"UPDATE transactions SET status = ?1 \
WHERE transaction_id = ?2 AND status = ?3",
params![canceled_json, transaction_id, queued_json],
)?;
if rows_affected > 0 {
Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?;
}
tx.commit()?;
Ok(rows_affected > 0)
}
pub fn list_transactions(
&self,
limit: u32,
status_filter: Option<&str>,
action_filter: Option<&str>,
since_hours: Option<u32>,
) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
let conn = self.connection()?;
let (filter_sql, param_values) =
Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
let sql = format!(
"SELECT transaction_id, request_id, request_hash, action_name, \
risk_level, status, approval_id, summary, warnings_json \
FROM transactions WHERE 1=1{filter_sql}"
);
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
let mut results = Vec::new();
for row in rows {
results.push(row??);
}
Ok(results)
}
pub fn list_history(
&self,
limit: u32,
status_filter: Option<&str>,
action_filter: Option<&str>,
since_hours: Option<u32>,
) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
let conn = self.connection()?;
let (filter_sql, param_values) =
Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
let sql = format!(
"SELECT transaction_id, action_name, risk_level, status, summary, created_at \
FROM transactions WHERE 1=1{filter_sql}"
);
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_ref.as_slice(), |row| {
Ok((|| {
Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
transaction_id: row.get(0)?,
action_name: row.get(1)?,
risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
status: deserialize_field(&row.get::<_, String>(3)?)?,
summary: row.get(4)?,
created_at: row.get(5)?,
})
})())
})?;
let mut results = Vec::new();
for row in rows {
results.push(row??);
}
Ok(results)
}
fn build_history_filter(
limit: u32,
status_filter: Option<&str>,
action_filter: Option<&str>,
since_hours: Option<u32>,
) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
let mut sql = String::new();
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(status) = status_filter {
let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
let status_json = serialize_field(&validated)?;
sql.push_str(" AND status = ?");
param_values.push(Box::new(status_json));
}
if let Some(action) = action_filter {
sql.push_str(" AND action_name = ?");
param_values.push(Box::new(action.to_string()));
}
if let Some(hours) = since_hours {
sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
param_values.push(Box::new(hours));
}
sql.push_str(" ORDER BY seq DESC LIMIT ?");
param_values.push(Box::new(limit.min(100)));
Ok((sql, param_values))
}
fn connection(&self) -> Result<Connection, TransactionStoreError> {
let conn = Connection::open(&self.path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
Ok(conn)
}
fn initialize(&self) -> Result<(), TransactionStoreError> {
let mut conn = self.connection()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_migrations (\
version INTEGER PRIMARY KEY,\
name TEXT NOT NULL,\
applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
);",
)?;
let current: i64 = tx.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
[],
|row| row.get(0),
)?;
let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
if current > latest {
return Err(TransactionStoreError::DatabaseInvariant(format!(
"sqlite schema version {current} is newer than this binary supports ({latest})"
)));
}
for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
tx.execute_batch(migration.sql)?;
tx.execute(
"INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
params![migration.version, migration.name],
)?;
}
tx.commit()?;
Ok(())
}
pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
let conn = self.connection()?;
let mut stmt = conn.prepare(&format!(
"SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
))?;
let rows = stmt.query_map([], chain_row_from_sqlite)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
let conn = self.connection()?;
let mut stmt = conn.prepare(
"SELECT seq, key_id, kind, transaction_id, receipt_digest, \
created_at, prev_chain_hash, chain_hash \
FROM audit_events ORDER BY seq ASC",
)?;
let rows = stmt.query_map([], event_row_from_sqlite)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
pub fn verify_audit_chain(
&self,
key: &AuditKey,
) -> Result<VerifyOutcome, TransactionStoreError> {
let rows = self.fetch_chain_rows()?;
Ok(audit_chain::verify_chain(key, &rows))
}
pub fn verify_event_chain(
&self,
key: &AuditKey,
) -> Result<VerifyOutcome, TransactionStoreError> {
let rows = self.fetch_event_rows()?;
Ok(audit_chain::verify_event_chain(key, &rows))
}
pub fn verify_event_binding(
&self,
) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
let tx_rows = self.fetch_chain_rows()?;
let event_rows = self.fetch_event_rows()?;
Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
}
pub fn verify_audit_chain_with_pubkey(
&self,
verifying_key_hex: &str,
) -> Result<VerifyOutcome, TransactionStoreError> {
let rows = self.fetch_chain_rows()?;
Ok(audit_chain::verify_chain_with_pubkey(
verifying_key_hex,
&rows,
))
}
pub fn fetch_chain_row(
&self,
transaction_id: &str,
) -> Result<Option<ChainRow>, TransactionStoreError> {
let conn = self.connection()?;
let mut stmt = conn.prepare(&format!(
"SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
))?;
let mut rows = stmt.query(params![transaction_id])?;
if let Some(row) = rows.next()? {
Ok(Some(chain_row_from_sqlite(row)?))
} else {
Ok(None)
}
}
fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
let prev: Option<(i64, String)> = query_optional(
conn,
"SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
Ok(match prev {
Some((seq, hash)) => ((seq as u64) + 1, hash),
None => (1, String::new()),
})
}
fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
Ok(query_optional(
conn,
"SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
[],
|row| row.get(0),
)?)
}
fn append_event(
conn: &Connection,
key: &AuditKey,
kind: AuditEventKind,
transaction_id: &str,
receipt_digest: &str,
) -> Result<(), TransactionStoreError> {
let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
let seq: i64 = conn.query_row(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
[],
|row| row.get(0),
)?;
let created_at: String =
conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
row.get(0)
})?;
let key_id = CURRENT_KEY_ID.to_string();
let chain_hash = key.event_hash(
&EventContent {
seq: seq as u64,
key_id: &key_id,
kind,
transaction_id,
receipt_digest,
created_at: &created_at,
},
&prev_chain_hash,
);
conn.execute(
"INSERT INTO audit_events (
seq, key_id, kind, transaction_id, receipt_digest,
created_at, chain_hash, prev_chain_hash
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
seq,
key_id,
kind.as_str(),
transaction_id,
receipt_digest,
created_at,
chain_hash,
prev_chain_hash,
],
)?;
Ok(())
}
fn insert_transaction(
conn: &Connection,
key: &AuditKey,
transaction_id: &str,
transaction: NewTransaction,
) -> Result<InsertedTransaction, TransactionStoreError> {
let request_id = transaction.request_id;
let request_hash = transaction.request_hash;
let action_name = transaction.action_name;
let risk_level = transaction.risk_level;
let status = JobState::Queued;
let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
let summary = transaction.summary;
let warnings = transaction.warnings;
let warnings_json = serde_json::to_string(&warnings)?;
let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
let created_at: String =
conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
row.get(0)
})?;
let key_id = CURRENT_KEY_ID.to_string();
let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
let caller_role = transaction.caller_role.as_str();
let caller_principal = transaction.caller_principal.as_signed_str();
let identity = ChainIdentity::V3 {
caller_role,
event_tip: &event_tip,
caller_principal: &caller_principal,
};
let chain_hash = key.chain_hash(
&ChainContent {
seq,
key_id: &key_id,
transaction_id,
request_id: &request_id,
request_hash: &request_hash,
action_name: &action_name,
risk_level,
summary: &summary,
approval_id: approval_id.as_deref(),
warnings_json: &warnings_json,
created_at: &created_at,
identity,
},
&prev_chain_hash,
);
let record = TransactionRecord {
transaction_id: transaction_id.to_string(),
request_id: request_id.clone(),
request_hash: request_hash.clone(),
action_name: action_name.clone(),
risk_level,
status,
approval_id: approval_id.clone(),
summary: summary.clone(),
warnings: warnings.clone(),
};
conn.execute(
"INSERT INTO transactions (
transaction_id,
request_id,
request_hash,
action_name,
risk_level,
status,
approval_id,
summary,
warnings_json,
created_at,
seq,
key_id,
chain_hash,
prev_chain_hash,
chain_version,
caller_role,
event_tip,
caller_principal
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![
transaction_id,
request_id,
request_hash,
action_name,
serialize_field(&risk_level)?,
serialize_field(&status)?,
approval_id,
summary,
warnings_json,
created_at,
seq as i64,
key_id,
chain_hash,
prev_chain_hash,
identity.version() as i64,
caller_role,
event_tip,
caller_principal,
],
)?;
Ok(InsertedTransaction {
record,
seq,
chain_hash,
})
}
fn insert_preview(
conn: &Connection,
transaction_id: &str,
preview: &PreviewEnvelope,
) -> Result<(), TransactionStoreError> {
conn.execute(
"INSERT INTO transaction_previews (transaction_id, preview_json)
VALUES (?1, ?2)",
params![transaction_id, serde_json::to_string(preview)?],
)?;
Ok(())
}
}
fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
if dir.exists() {
return Ok(());
}
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
}
fn restrict_db_file_mode(db_path: &Path) {
use std::os::unix::fs::PermissionsExt;
let sidecars = [
db_path.to_path_buf(),
with_suffix(db_path, "-wal"),
with_suffix(db_path, "-shm"),
];
for path in sidecars {
let Ok(metadata) = std::fs::metadata(&path) else {
continue; };
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 == 0 {
continue; }
if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
eprintln!(
"[sysknife-daemon] WARNING: {} is mode {mode:o} and could not be \
narrowed to 0600 ({err}); it is readable by anyone who can reach \
it. The parent directory should be 0700 — check it.",
path.display()
);
}
}
}
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
Ok(TransactionRecord {
transaction_id: row.get(0)?,
request_id: row.get(1)?,
request_hash: row.get(2)?,
action_name: row.get(3)?,
risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
status: deserialize_field(&row.get::<_, String>(5)?)?,
approval_id: row.get(6)?,
summary: row.get(7)?,
warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
})
}
fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
serde_json::to_string(value)
}
fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
serde_json::from_str(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit_chain::CHAIN_VERSION_CURRENT;
use crate::audit_chain::CHAIN_VERSION_LEGACY;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
fn test_store(path: impl AsRef<Path>) -> TransactionStore {
let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
TransactionStore::open_with_key(path, key).unwrap()
}
fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
)
.unwrap();
conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
conn.execute(
"INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
params![SQLITE_MIGRATIONS[0].name],
)
.unwrap();
let transaction_id = "tx-legacy";
let created_at = "2026-04-24T12:00:00.000Z";
let chain_hash = key.chain_hash(
&ChainContent {
seq: 1,
key_id: CURRENT_KEY_ID,
transaction_id,
request_id: "req-legacy",
request_hash: "hash-legacy",
action_name: "UpdateSystem",
risk_level: RiskLevel::High,
summary: "Upgrade the system",
approval_id: None,
warnings_json: "[]",
created_at,
identity: ChainIdentity::LegacyV1,
},
"",
);
conn.execute(
"INSERT INTO transactions (
transaction_id, request_id, request_hash, action_name, risk_level,
status, approval_id, summary, warnings_json, created_at,
seq, key_id, chain_hash, prev_chain_hash
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
params![
transaction_id,
"req-legacy",
"hash-legacy",
"UpdateSystem",
serialize_field(&RiskLevel::High).unwrap(),
serialize_field(&JobState::Queued).unwrap(),
"Upgrade the system",
"[]",
created_at,
CURRENT_KEY_ID,
chain_hash,
],
)
.unwrap();
transaction_id.to_string()
}
fn v2_database(path: &Path, key: &AuditKey) -> String {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
)
.unwrap();
for migration in &SQLITE_MIGRATIONS[0..=1] {
conn.execute_batch(migration.sql).unwrap();
conn.execute(
"INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
params![migration.version, migration.name],
)
.unwrap();
}
let transaction_id = "tx-v2";
let created_at = "2026-07-01T12:00:00.000Z";
let chain_hash = key.chain_hash(
&ChainContent {
seq: 1,
key_id: CURRENT_KEY_ID,
transaction_id,
request_id: "req-v2",
request_hash: "hash-v2",
action_name: "UpdateSystem",
risk_level: RiskLevel::High,
summary: "Upgrade the system",
approval_id: None,
warnings_json: "[]",
created_at,
identity: ChainIdentity::V2 {
caller_role: "admin",
event_tip: "",
},
},
"",
);
conn.execute(
"INSERT INTO transactions (
transaction_id, request_id, request_hash, action_name, risk_level,
status, approval_id, summary, warnings_json, created_at,
seq, key_id, chain_hash, prev_chain_hash,
chain_version, caller_role, event_tip
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
params![
transaction_id,
"req-v2",
"hash-v2",
"UpdateSystem",
serialize_field(&RiskLevel::High).unwrap(),
serialize_field(&JobState::Queued).unwrap(),
"Upgrade the system",
"[]",
created_at,
CURRENT_KEY_ID,
chain_hash,
],
)
.unwrap();
transaction_id.to_string()
}
#[test]
fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("tx.db");
let key = AuditKey::from_bytes(vec![0x42; 32]);
let old_transaction = v2_database(&db_path, &key);
let store = test_store(&db_path);
let version: i64 = store
.connection()
.unwrap()
.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(version, 3, "opening a v2 database must apply migration 3");
let rows = store.fetch_chain_rows().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].transaction_id, old_transaction);
assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
assert_eq!(
rows[0].caller_principal, None,
"migration 3 must not invent a principal for a row signed without one"
);
assert!(matches!(
store.verify_audit_chain(&key).unwrap(),
audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
));
let mut fresh = queued_transaction();
fresh.caller_principal = CallerPrincipal::Uid(4242);
store.record(fresh).unwrap();
let rows = store.fetch_chain_rows().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
assert_eq!(
rows[1].prev_chain_hash, rows[0].chain_hash,
"the v3 row must chain onto the v2 row, not start a new chain"
);
assert!(matches!(
store.verify_audit_chain(&key).unwrap(),
audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
));
let census = audit_chain::AttributionCensus::of(&rows);
assert_eq!(census.rows(), 2);
assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
assert_eq!(
census.not_recorded(),
1,
"the v2 row's encoding never signed a principal"
);
assert_eq!(census.attribution_failed(), 0);
assert_eq!(census.unattested(), 0);
assert_eq!(census.unnamed(), 1);
}
#[test]
fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("tx.db");
let key = AuditKey::from_bytes(vec![0x42; 32]);
legacy_v1_database(&db_path, &key);
let store = test_store(&db_path);
assert_eq!(
store.verify_audit_chain(&key).unwrap(),
VerifyOutcome::Intact { rows_checked: 1 }
);
let rows = store.fetch_chain_rows().unwrap();
assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
assert_eq!(rows[0].caller_role, None);
}
#[test]
fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("tx.db");
let key = AuditKey::from_bytes(vec![0x42; 32]);
legacy_v1_database(&db_path, &key);
let store = test_store(&db_path);
store.record(queued_transaction()).unwrap();
let rows = store.fetch_chain_rows().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
assert_eq!(
store.verify_audit_chain(&key).unwrap(),
VerifyOutcome::Intact { rows_checked: 2 }
);
}
#[test]
fn a_schema_newer_than_this_binary_is_refused() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("tx.db");
let key = AuditKey::from_bytes(vec![0x42; 32]);
legacy_v1_database(&db_path, &key);
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
[],
)
.unwrap();
drop(conn);
let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
assert!(
matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
"expected a refusal naming the unsupported version, got {err:?}"
);
}
#[test]
fn the_signed_row_records_which_role_asked() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let mut tx = queued_transaction();
tx.caller_role = CallerRole::Admin;
store.record(tx).unwrap();
let rows = store.fetch_chain_rows().unwrap();
assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
}
#[test]
fn approving_consuming_and_revoking_each_append_a_chained_event() {
let dir = tempdir().unwrap();
let key = AuditKey::from_bytes(vec![0x42; 32]);
let store = test_store(dir.path().join("tx.db"));
let a = store.record(queued_transaction()).unwrap();
let receipt = store
.approve_transaction(&a.transaction_id)
.unwrap()
.expect("queued transaction approves");
let digest = audit_chain::approval_receipt_digest(&receipt);
assert!(store
.claim_approved_for_execution(&a.transaction_id, &digest)
.unwrap());
let b = store.record(queued_transaction()).unwrap();
store.approve_transaction(&b.transaction_id).unwrap();
assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap());
let events = store.fetch_event_rows().unwrap();
let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
assert_eq!(
kinds,
vec![
"approval_granted",
"approval_consumed",
"approval_granted",
"approval_revoked",
]
);
assert_eq!(
store.verify_event_chain(&key).unwrap(),
VerifyOutcome::Intact { rows_checked: 4 }
);
}
#[test]
fn a_failed_approval_appends_no_event() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store.approve_transaction(&tx.transaction_id).unwrap();
store.approve_transaction(&tx.transaction_id).unwrap();
assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap());
assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
}
#[test]
fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("tx.db");
let key = AuditKey::from_bytes(vec![0x42; 32]);
let store = test_store(&db_path);
let a = store.record(queued_transaction()).unwrap();
store.approve_transaction(&a.transaction_id).unwrap();
store.record(queued_transaction()).unwrap();
assert_eq!(
store.verify_event_chain(&key).unwrap(),
VerifyOutcome::Intact { rows_checked: 1 }
);
assert_eq!(
store.verify_event_binding().unwrap(),
audit_chain::BindingOutcome::Consistent {
bindings_checked: 1
}
);
let conn = Connection::open(&db_path).unwrap();
conn.execute("DELETE FROM transaction_approvals", [])
.unwrap();
conn.execute("DELETE FROM audit_events", []).unwrap();
drop(conn);
assert_eq!(
store.verify_event_chain(&key).unwrap(),
VerifyOutcome::Intact { rows_checked: 0 }
);
match store.verify_event_binding().unwrap() {
audit_chain::BindingOutcome::MissingEvent {
transaction_seq, ..
} => assert_eq!(transaction_seq, 2),
other => panic!("expected MissingEvent, got {other:?}"),
}
}
#[test]
fn a_read_only_store_cannot_revoke_or_claim() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("tx.db");
let store = test_store(&db_path);
store.record(queued_transaction()).unwrap();
let read_only = TransactionStore::open_read_only(&db_path).unwrap();
assert!(matches!(
read_only.revoke_unconsumed_approval("tx"),
Err(TransactionStoreError::AuditChainMissing(_))
));
assert!(matches!(
read_only.claim_approved_for_execution("tx", "digest"),
Err(TransactionStoreError::AuditChainMissing(_))
));
}
#[test]
fn record_persists_the_caller_principal_under_the_current_encoding() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let mut tx = queued_transaction();
tx.caller_principal = CallerPrincipal::Uid(4242);
store.record(tx).unwrap();
let conn = store.connection().unwrap();
let (version, role, principal): (i64, Option<String>, Option<String>) = conn
.query_row(
"SELECT chain_version, caller_role, caller_principal FROM transactions",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
assert_eq!(role.as_deref(), Some("dev"));
assert_eq!(
principal.as_deref(),
Some("uid:4242"),
"the row must name the account the dispatcher resolved"
);
let key = AuditKey::from_bytes(vec![0x42; 32]);
assert!(matches!(
store.verify_audit_chain(&key).unwrap(),
audit_chain::VerifyOutcome::Intact { .. }
));
}
#[test]
fn record_writes_audit_chain_columns() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let _record = store.record(queued_transaction()).unwrap();
let conn = store.connection().unwrap();
let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
.query_row(
"SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.unwrap();
assert_eq!(seq, 1, "first row gets seq=1");
assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
assert_eq!(prev, "", "first row has empty prev_chain_hash");
assert_eq!(
chain_hash.len(),
audit_chain::HASH_HEX_LEN,
"chain_hash is a hex-encoded Ed25519 signature"
);
}
#[test]
fn sequential_records_produce_chained_hashes() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
store.record(queued_transaction()).unwrap();
store.record(queued_transaction()).unwrap();
store.record(queued_transaction()).unwrap();
let rows = store.fetch_chain_rows().unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].seq, 1);
assert_eq!(rows[1].seq, 2);
assert_eq!(rows[2].seq, 3);
assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
}
#[test]
fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
const WORKERS: usize = 8;
const RECORDS_PER_WORKER: usize = 10;
const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
let dir = tempdir().unwrap();
let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
let mut handles = Vec::with_capacity(WORKERS);
for w in 0..WORKERS {
let store = std::sync::Arc::clone(&store);
handles.push(std::thread::spawn(move || {
for r in 0..RECORDS_PER_WORKER {
let tx = NewTransaction {
request_id: format!("worker-{w}-record-{r}"),
request_hash: format!("hash-{w}-{r}"),
action_name: "GetSystemState".to_string(),
risk_level: RiskLevel::Low,
summary: format!("worker {w} record {r}"),
warnings: vec![],
caller_role: CallerRole::Dev,
caller_principal: CallerPrincipal::Uid(3000 + w as u32),
};
store
.record(tx)
.expect("record must succeed under contention");
}
}));
}
for h in handles {
h.join().expect("worker thread did not panic");
}
let key = AuditKey::from_bytes(vec![0x42; 32]);
let outcome = store.verify_audit_chain(&key).unwrap();
match outcome {
VerifyOutcome::Intact { rows_checked } => {
assert_eq!(
rows_checked, TOTAL as u64,
"expected {TOTAL} rows checked, got {rows_checked}"
);
}
other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
}
{
let conn = store.connection().unwrap();
let mut stmt = conn
.prepare("SELECT summary, caller_principal FROM transactions")
.unwrap();
let pairs: Vec<(String, Option<String>)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(pairs.len(), TOTAL);
for (summary, principal) in pairs {
let worker: u32 = summary
.split_whitespace()
.nth(1)
.and_then(|w| w.parse().ok())
.unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
assert_eq!(
principal.as_deref(),
Some(format!("uid:{}", 3000 + worker).as_str()),
"row {summary:?} was attributed to the wrong account"
);
}
}
let conn = store.connection().unwrap();
let mut stmt = conn
.prepare("SELECT seq FROM transactions ORDER BY seq ASC")
.unwrap();
let seqs: Vec<i64> = stmt
.query_map([], |row| row.get::<_, i64>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(seqs.len(), TOTAL, "row count mismatch");
for (i, s) in seqs.iter().enumerate() {
assert_eq!(
*s,
(i as i64) + 1,
"seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
);
}
}
#[test]
fn verify_audit_chain_intact_after_inserts() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
for _ in 0..3 {
store.record(queued_transaction()).unwrap();
}
let key = AuditKey::from_bytes(vec![0x42; 32]);
let outcome = store.verify_audit_chain(&key).unwrap();
assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
}
#[test]
fn verify_audit_chain_with_pubkey_intact_after_inserts() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
for _ in 0..3 {
store.record(queued_transaction()).unwrap();
}
let key = AuditKey::from_bytes(vec![0x42; 32]);
let outcome = store
.verify_audit_chain_with_pubkey(&key.verifying_key_hex())
.unwrap();
assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
}
#[test]
fn verify_audit_chain_with_wrong_pubkey_is_broken() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
store.record(queued_transaction()).unwrap();
let other = AuditKey::from_bytes(vec![0x99; 32]);
let outcome = store
.verify_audit_chain_with_pubkey(&other.verifying_key_hex())
.unwrap();
assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
}
#[test]
fn verify_detects_tampered_summary() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
let conn = store.connection().unwrap();
conn.execute(
"UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
params!["EVIL CHANGE", tx.transaction_id],
)
.unwrap();
let key = AuditKey::from_bytes(vec![0x42; 32]);
let outcome = store.verify_audit_chain(&key).unwrap();
assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
}
#[test]
fn status_update_does_not_break_chain() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store
.update_status(&tx.transaction_id, JobState::Running)
.unwrap();
store
.update_status(&tx.transaction_id, JobState::Succeeded)
.unwrap();
let key = AuditKey::from_bytes(vec![0x42; 32]);
let outcome = store.verify_audit_chain(&key).unwrap();
assert!(
matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
"status mutation must not break the chain (status not in chain content): {outcome:?}"
);
}
#[test]
fn open_read_only_rejects_record() {
let dir = tempdir().unwrap();
let key_path = dir.path().join("audit-key");
std::fs::write(&key_path, vec![0x42; 32]).unwrap();
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
let result = store.record(queued_transaction());
assert!(matches!(
result,
Err(TransactionStoreError::AuditChainMissing(_))
));
}
#[test]
fn ensure_private_dir_creates_with_0700_mode() {
let root = tempdir().unwrap();
let target = root.path().join("a/b/c");
ensure_private_dir(&target).unwrap();
assert!(target.is_dir());
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
}
#[test]
fn the_audit_database_is_created_owner_only() {
let dir = tempdir().unwrap();
let db = dir.path().join("daemon.sqlite");
let store = TransactionStore::open(&db).unwrap();
store
.record(NewTransaction {
request_id: "mode-probe".to_string(),
request_hash: "hash-mode-probe".to_string(),
action_name: "GetSystemState".to_string(),
risk_level: RiskLevel::Low,
summary: "probe".to_string(),
warnings: vec![],
caller_role: CallerRole::Dev,
caller_principal: CallerPrincipal::Uid(3000),
})
.unwrap();
for path in [db.clone(), db.with_extension("sqlite-wal")] {
if !path.exists() {
continue;
}
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode,
0o600,
"{} must be owner-only, got {mode:o}",
path.display()
);
}
}
#[test]
fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
let root = tempdir().unwrap();
let target = root.path().join("preexisting");
std::fs::create_dir(&target).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
ensure_private_dir(&target).unwrap();
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755);
}
#[test]
fn open_creates_parent_with_private_mode() {
let root = tempdir().unwrap();
let db_path = root.path().join("nested/dirs/daemon.sqlite");
let _store = test_store(&db_path);
let parent = db_path.parent().unwrap();
let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
}
fn queued_transaction() -> NewTransaction {
NewTransaction {
request_id: "req-1".to_string(),
request_hash: "hash-abc".to_string(),
action_name: "UpdateSystem".to_string(),
risk_level: RiskLevel::High,
summary: "Upgrade the system".to_string(),
warnings: vec![],
caller_role: CallerRole::Dev,
caller_principal: CallerPrincipal::Uid(1000),
}
}
#[test]
fn update_status_transitions_queued_to_running() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store
.update_status(&tx.transaction_id, JobState::Running)
.unwrap();
let updated = store.get(&tx.transaction_id).unwrap().unwrap();
assert_eq!(updated.status, JobState::Running);
}
#[test]
fn update_status_transitions_running_to_succeeded() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store
.update_status(&tx.transaction_id, JobState::Running)
.unwrap();
store
.update_status(&tx.transaction_id, JobState::Succeeded)
.unwrap();
let updated = store.get(&tx.transaction_id).unwrap().unwrap();
assert_eq!(updated.status, JobState::Succeeded);
}
#[test]
fn update_status_for_unknown_id_returns_not_found() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let result = store.update_status("does-not-exist", JobState::Running);
assert!(
matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
"expected NotFound, got: {result:?}"
);
}
#[test]
fn update_status_leaves_other_fields_intact() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store
.update_status(&tx.transaction_id, JobState::Running)
.unwrap();
store
.update_status(&tx.transaction_id, JobState::Failed)
.unwrap();
let updated = store.get(&tx.transaction_id).unwrap().unwrap();
assert_eq!(updated.action_name, "UpdateSystem");
assert_eq!(updated.risk_level, RiskLevel::High);
assert_eq!(updated.status, JobState::Failed);
}
#[test]
fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
let dir = tempdir().unwrap();
let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
let tx = store.record(queued_transaction()).unwrap();
store
.update_status(&tx.transaction_id, JobState::Running)
.unwrap();
let store_a = std::sync::Arc::clone(&store);
let id_a = tx.transaction_id.clone();
let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
let store_b = std::sync::Arc::clone(&store);
let id_b = tx.transaction_id.clone();
let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
let succeed_result = succeed.join().unwrap();
let fail_result = fail.join().unwrap();
let oks = [succeed_result.is_ok(), fail_result.is_ok()]
.into_iter()
.filter(|ok| *ok)
.count();
assert_eq!(
oks, 1,
"exactly one concurrent transition should succeed: succeed={succeed_result:?} \
fail={fail_result:?}"
);
let loser = if succeed_result.is_ok() {
&fail_result
} else {
&succeed_result
};
match loser {
Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
assert_eq!(id, &tx.transaction_id);
}
Err(TransactionStoreError::InvalidTransition { from, to }) => {
let winner = if succeed_result.is_ok() {
JobState::Succeeded
} else {
JobState::Failed
};
assert_eq!(
*from, winner,
"the loser must have observed the winner's committed status"
);
assert_ne!(*to, winner, "the loser must be the other transition");
}
other => panic!(
"the losing transition must be refused with either \
ConcurrentStatusChange or InvalidTransition, got: {other:?}"
),
}
let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
if succeed_result.is_ok() {
assert_eq!(final_status, JobState::Succeeded);
} else {
assert_eq!(final_status, JobState::Failed);
}
}
#[test]
fn revoke_unconsumed_approval_removes_an_unused_receipt() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
let receipt = store
.approve_transaction(&tx.transaction_id)
.unwrap()
.expect("approved");
let digest = audit_chain::approval_receipt_digest(&receipt);
assert!(
store
.revoke_unconsumed_approval(&tx.transaction_id)
.unwrap(),
"an unconsumed approval must be revocable"
);
assert!(
!store
.claim_approved_for_execution(&tx.transaction_id, &digest)
.unwrap(),
"a revoked receipt must no longer be claimable"
);
}
#[test]
fn revoke_cannot_retract_an_approval_that_was_already_used() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
let receipt = store
.approve_transaction(&tx.transaction_id)
.unwrap()
.expect("approved");
let digest = audit_chain::approval_receipt_digest(&receipt);
assert!(store
.claim_approved_for_execution(&tx.transaction_id, &digest)
.unwrap());
assert!(
!store
.revoke_unconsumed_approval(&tx.transaction_id)
.unwrap(),
"a consumed approval must not be revocable"
);
assert_eq!(
store.get(&tx.transaction_id).unwrap().unwrap().status,
JobState::Running,
"a failed revocation must not disturb the running transaction"
);
}
#[test]
fn approve_refuses_a_transaction_that_is_no_longer_queued() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
let receipt = store
.approve_transaction(&tx.transaction_id)
.unwrap()
.expect("approved");
let digest = audit_chain::approval_receipt_digest(&receipt);
assert!(store
.claim_approved_for_execution(&tx.transaction_id, &digest)
.unwrap());
assert!(
store
.approve_transaction(&tx.transaction_id)
.unwrap()
.is_none(),
"a Running transaction must not be approvable"
);
store
.update_status(&tx.transaction_id, JobState::Succeeded)
.unwrap();
assert!(
store
.approve_transaction(&tx.transaction_id)
.unwrap()
.is_none(),
"a completed transaction must not be approvable"
);
}
#[test]
fn only_one_of_two_concurrent_claims_can_execute() {
let dir = tempdir().unwrap();
let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
let tx = store.record(queued_transaction()).unwrap();
let receipt = store
.approve_transaction(&tx.transaction_id)
.unwrap()
.expect("approved");
let digest = audit_chain::approval_receipt_digest(&receipt);
let mut handles = Vec::new();
for _ in 0..2 {
let store = std::sync::Arc::clone(&store);
let id = tx.transaction_id.clone();
let digest = digest.clone();
handles.push(std::thread::spawn(move || {
store.claim_approved_for_execution(&id, &digest)
}));
}
let claims: Vec<bool> = handles
.into_iter()
.map(|h| h.join().unwrap().unwrap_or(false))
.collect();
assert_eq!(
claims.iter().filter(|c| **c).count(),
1,
"exactly one claim may win: {claims:?}"
);
assert_eq!(
store.get(&tx.transaction_id).unwrap().unwrap().status,
JobState::Running
);
}
#[test]
fn cancel_queued_cancels_a_queued_transaction_once() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
assert!(
store.cancel_queued(&tx.transaction_id).unwrap(),
"a queued transaction is cancelable"
);
assert_eq!(
store.get(&tx.transaction_id).unwrap().unwrap().status,
JobState::Canceled
);
assert!(
!store.cancel_queued(&tx.transaction_id).unwrap(),
"an already-canceled transaction is not cancelable again"
);
assert!(
!store.cancel_queued("no-such-transaction").unwrap(),
"a missing transaction is not cancelable"
);
assert!(
store.fetch_event_rows().unwrap().is_empty(),
"canceling an unapproved transaction must not append an approval event"
);
}
#[test]
fn cancel_queued_revokes_an_unconsumed_approval_and_appends_event() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
let receipt = store
.approve_transaction(&tx.transaction_id)
.unwrap()
.expect("queued transaction is approvable");
let digest = audit_chain::approval_receipt_digest(&receipt);
assert!(store.cancel_queued(&tx.transaction_id).unwrap());
assert_eq!(
store.get(&tx.transaction_id).unwrap().unwrap().status,
JobState::Canceled
);
assert!(
!store
.claim_approved_for_execution(&tx.transaction_id, &digest)
.unwrap(),
"canceling must revoke the unconsumed receipt"
);
assert_eq!(
store
.fetch_event_rows()
.unwrap()
.iter()
.map(|event| event.kind.as_str())
.collect::<Vec<_>>(),
vec!["approval_granted", "approval_revoked"]
);
}
#[test]
fn cancel_queued_refuses_a_running_transaction() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
let receipt = store
.approve_transaction(&tx.transaction_id)
.unwrap()
.expect("approved");
let digest = audit_chain::approval_receipt_digest(&receipt);
assert!(store
.claim_approved_for_execution(&tx.transaction_id, &digest)
.unwrap());
assert!(
!store.cancel_queued(&tx.transaction_id).unwrap(),
"a running transaction must not be cancelable"
);
assert_eq!(
store.get(&tx.transaction_id).unwrap().unwrap().status,
JobState::Running,
"cancel must not disturb a running transaction"
);
}
#[test]
fn approved_receipt_is_required_and_consumed_once() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
assert!(
!store
.claim_approved_for_execution(&tx.transaction_id, "digest-a")
.unwrap(),
"an unapproved preview must not execute"
);
let receipt = store
.approve_transaction(&tx.transaction_id)
.unwrap()
.expect("first approval must succeed");
let digest = audit_chain::approval_receipt_digest(&receipt);
assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
assert!(
store
.approve_transaction(&tx.transaction_id)
.unwrap()
.is_none(),
"approval is immutable once issued"
);
assert!(
!store
.claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
.unwrap(),
"a forged receipt must not execute"
);
assert!(
store
.claim_approved_for_execution(&tx.transaction_id, &digest)
.unwrap(),
"the exact approved receipt must execute"
);
assert!(
!store
.claim_approved_for_execution(&tx.transaction_id, &digest)
.unwrap(),
"the receipt must be one-time"
);
}
#[test]
fn approval_commitment_is_covered_by_the_signed_chain() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
assert!(tx.approval_id.is_some());
store
.connection()
.unwrap()
.execute(
"UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
params![tx.transaction_id],
)
.unwrap();
let key = AuditKey::from_bytes(vec![0x42; 32]);
assert!(matches!(
store.verify_audit_chain(&key).unwrap(),
VerifyOutcome::Broken { .. }
));
}
#[test]
fn approve_rejects_a_forged_commitment_at_runtime() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store
.connection()
.unwrap()
.execute(
"UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
params![tx.transaction_id],
)
.unwrap();
let err = store
.approve_transaction(&tx.transaction_id)
.expect_err("a forged commitment must be rejected, not approved");
assert!(
matches!(err, TransactionStoreError::DatabaseInvariant(_)),
"forged commitment must surface as a DatabaseInvariant, got {err:?}"
);
}
#[test]
fn stale_iso_timestamp_cannot_be_approved() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
let conn = store.connection().unwrap();
conn.execute(
"UPDATE transactions \
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
WHERE transaction_id = ?1",
params![tx.transaction_id],
)
.unwrap();
assert!(
store
.approve_transaction(&tx.transaction_id)
.unwrap()
.is_none(),
"a production-format timestamp outside the TTL must not be approved"
);
}
#[test]
fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
let receipt = store
.approve_transaction(&tx.transaction_id)
.unwrap()
.expect("a fresh approval succeeds");
let digest = audit_chain::approval_receipt_digest(&receipt);
let conn = store.connection().unwrap();
conn.execute(
"UPDATE transactions \
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
WHERE transaction_id = ?1",
params![tx.transaction_id],
)
.unwrap();
assert!(
!store
.claim_approved_for_execution(&tx.transaction_id, &digest)
.unwrap(),
"an approval aged past the TTL must not be claimable at execute time"
);
assert_eq!(
store.get(&tx.transaction_id).unwrap().unwrap().status,
JobState::Queued,
"a TTL-expired claim must leave the transaction Queued, never Running"
);
}
#[test]
fn cleanup_stale_queued_cancels_old_records() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let fresh = store.record(queued_transaction()).unwrap();
let stale = store.record(queued_transaction()).unwrap();
let conn = store.connection().unwrap();
conn.execute(
"UPDATE transactions \
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
WHERE transaction_id = ?1",
params![stale.transaction_id],
)
.unwrap();
let canceled = store.cleanup_stale_queued().unwrap();
assert_eq!(canceled, 1, "only the stale record should be canceled");
let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
assert_eq!(stale_record.status, JobState::Canceled);
let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
assert_eq!(fresh_record.status, JobState::Queued);
}
#[test]
fn cleanup_stale_queued_revokes_every_unconsumed_approval() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let transactions: Vec<_> = (0..3)
.map(|_| {
let transaction = store.record(queued_transaction()).unwrap();
store
.approve_transaction(&transaction.transaction_id)
.unwrap()
.expect("stale transaction is approvable");
transaction
})
.collect();
let conn = store.connection().unwrap();
for transaction in &transactions {
conn.execute(
"UPDATE transactions \
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
WHERE transaction_id = ?1",
params![transaction.transaction_id],
)
.unwrap();
}
assert_eq!(store.cleanup_stale_queued().unwrap(), 3);
for transaction in &transactions {
assert_eq!(
store
.get(&transaction.transaction_id)
.unwrap()
.unwrap()
.status,
JobState::Canceled
);
assert!(!store
.revoke_unconsumed_approval(&transaction.transaction_id)
.unwrap());
}
assert_eq!(
store
.fetch_event_rows()
.unwrap()
.iter()
.map(|event| event.kind.as_str())
.collect::<Vec<_>>(),
vec![
"approval_granted",
"approval_granted",
"approval_granted",
"approval_revoked",
"approval_revoked",
"approval_revoked"
]
);
}
#[test]
fn update_status_rejects_queued_to_succeeded() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
assert!(
matches!(
result,
Err(TransactionStoreError::InvalidTransition {
from: JobState::Queued,
to: JobState::Succeeded,
})
),
"Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
);
}
#[test]
fn update_status_rejects_succeeded_to_running() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store
.update_status(&tx.transaction_id, JobState::Running)
.unwrap();
store
.update_status(&tx.transaction_id, JobState::Succeeded)
.unwrap();
let result = store.update_status(&tx.transaction_id, JobState::Running);
assert!(
matches!(
result,
Err(TransactionStoreError::InvalidTransition {
from: JobState::Succeeded,
to: JobState::Running,
})
),
"Succeeded -> Running must be rejected (terminal state): {result:?}"
);
}
#[test]
fn update_status_accepts_running_to_failed() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store
.update_status(&tx.transaction_id, JobState::Running)
.unwrap();
store
.update_status(&tx.transaction_id, JobState::Failed)
.unwrap();
let updated = store.get(&tx.transaction_id).unwrap().unwrap();
assert_eq!(updated.status, JobState::Failed);
}
#[test]
fn update_status_accepts_running_to_rolled_back() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store
.update_status(&tx.transaction_id, JobState::Running)
.unwrap();
store
.update_status(&tx.transaction_id, JobState::RolledBack)
.unwrap();
let updated = store.get(&tx.transaction_id).unwrap().unwrap();
assert_eq!(updated.status, JobState::RolledBack);
}
#[test]
fn list_transactions_returns_empty_for_fresh_store() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let results = store.list_transactions(10, None, None, None).unwrap();
assert!(results.is_empty());
}
#[test]
fn list_transactions_returns_all_records_ordered_by_newest_first() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
store.record(queued_transaction()).unwrap();
let mut second = queued_transaction();
second.action_name = "GetDiskUsage".to_string();
second.risk_level = RiskLevel::Low;
store.record(second).unwrap();
let results = store.list_transactions(10, None, None, None).unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].action_name, "GetDiskUsage");
assert_eq!(results[1].action_name, "UpdateSystem");
}
#[test]
fn list_history_populates_created_at_and_risk_level() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
store.record(queued_transaction()).unwrap();
let entries = store.list_history(10, None, None, None).unwrap();
assert_eq!(entries.len(), 1);
let entry = &entries[0];
assert_eq!(entry.action_name, "UpdateSystem");
assert_eq!(entry.risk_level, RiskLevel::High);
assert_eq!(entry.status, JobState::Queued);
assert!(
!entry.created_at.is_empty(),
"created_at must be populated from the stored row, not left blank"
);
}
#[test]
fn list_history_applies_the_same_filters_as_list_transactions() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
store.record(queued_transaction()).unwrap();
let mut low = queued_transaction();
low.action_name = "GetDiskUsage".to_string();
low.risk_level = RiskLevel::Low;
store.record(low).unwrap();
let only = store
.list_history(10, None, Some("GetDiskUsage"), None)
.unwrap();
assert_eq!(only.len(), 1);
assert_eq!(only[0].action_name, "GetDiskUsage");
}
#[test]
fn list_transactions_respects_limit() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
for _ in 0..5 {
store.record(queued_transaction()).unwrap();
}
let results = store.list_transactions(3, None, None, None).unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn list_transactions_filters_by_status() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let tx = store.record(queued_transaction()).unwrap();
store
.update_status(&tx.transaction_id, JobState::Running)
.unwrap();
store
.update_status(&tx.transaction_id, JobState::Succeeded)
.unwrap();
store.record(queued_transaction()).unwrap();
let succeeded = store
.list_transactions(10, Some("succeeded"), None, None)
.unwrap();
assert_eq!(succeeded.len(), 1);
assert_eq!(succeeded[0].status, JobState::Succeeded);
let queued = store
.list_transactions(10, Some("queued"), None, None)
.unwrap();
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].status, JobState::Queued);
}
#[test]
fn list_transactions_filters_by_action_name() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
store.record(queued_transaction()).unwrap();
let mut disk = queued_transaction();
disk.action_name = "GetDiskUsage".to_string();
store.record(disk).unwrap();
let results = store
.list_transactions(10, None, Some("GetDiskUsage"), None)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].action_name, "GetDiskUsage");
}
#[test]
fn list_transactions_filters_by_since_hours() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let old = store.record(queued_transaction()).unwrap();
let conn = store.connection().unwrap();
conn.execute(
"UPDATE transactions \
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
WHERE transaction_id = ?1",
params![old.transaction_id],
)
.unwrap();
store.record(queued_transaction()).unwrap();
let results = store.list_transactions(10, None, None, Some(24)).unwrap();
assert_eq!(results.len(), 1);
let results = store.list_transactions(10, None, None, Some(72)).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn list_transactions_rejects_invalid_status_filter() {
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
store.record(queued_transaction()).unwrap();
let result = store.list_transactions(10, Some("bogus"), None, None);
assert!(result.is_err(), "invalid status filter should return error");
}
#[test]
fn record_emits_one_watermark_per_entry() {
let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
store.record(queued_transaction()).unwrap();
let calls = crate::audit_watermark::take_watermarks(&sink);
assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
}
#[test]
fn record_previewed_emits_one_watermark() {
let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
let preview = PreviewEnvelope {
summary: "Upgrade the system".to_string(),
risk_level: RiskLevel::High,
current_state: serde_json::Value::Null,
proposed_change: serde_json::Value::Null,
expected_side_effects: vec![],
reboot_required: false,
rollback_available: false,
warnings: vec![],
request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
};
store
.record_previewed(queued_transaction(), preview)
.unwrap();
let calls = crate::audit_watermark::take_watermarks(&sink);
assert_eq!(
calls.len(),
1,
"expected exactly 1 watermark per record_previewed()"
);
}
#[test]
fn watermark_seq_and_hash_match_chain_row() {
let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
store.record(queued_transaction()).unwrap();
let rows = store.fetch_chain_rows().unwrap();
assert_eq!(rows.len(), 1);
let row = &rows[0];
let calls = crate::audit_watermark::take_watermarks(&sink);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
assert_eq!(
calls[0].chain_hash_hex, row.chain_hash,
"watermark chain_hash_hex must match stored chain_hash"
);
}
#[test]
fn multiple_records_produce_one_watermark_each() {
let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
let dir = tempdir().unwrap();
let store = test_store(dir.path().join("tx.db"));
for _ in 0..3 {
store.record(queued_transaction()).unwrap();
}
let calls = crate::audit_watermark::take_watermarks(&sink);
assert_eq!(calls.len(), 3, "one watermark per record call");
assert_eq!(calls[0].seq, 1);
assert_eq!(calls[1].seq, 2);
assert_eq!(calls[2].seq, 3);
}
#[test]
fn no_watermark_emitted_before_any_record() {
let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
let dir = tempdir().unwrap();
let _store = test_store(dir.path().join("tx.db"));
let calls = crate::audit_watermark::take_watermarks(&sink);
assert!(
calls.is_empty(),
"no watermark must be emitted without a record() call"
);
}
}