1use crate::audit_chain::{
2 self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent, EventRow,
3 VerifyOutcome, CHAIN_VERSION_CURRENT, CURRENT_KEY_ID,
4};
5use crate::audit_watermark::emit_chain_tip_watermark;
6use rusqlite::{params, Connection, TransactionBehavior};
7use serde::{de::DeserializeOwned, Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use subtle::ConstantTimeEq;
11use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RiskLevel, TransactionRecord};
12use uuid::Uuid;
13
14pub(crate) const APPROVAL_RECEIPT_TTL_MINUTES: i64 = 15;
25
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34pub struct JobHistoryEntry {
35 pub transaction_id: String,
36 pub action_name: String,
37 pub risk_level: RiskLevel,
38 pub status: JobState,
39 pub summary: String,
40 pub created_at: String,
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct NewTransaction {
50 pub request_id: String,
51 pub request_hash: String,
52 pub action_name: String,
53 pub risk_level: RiskLevel,
54 pub summary: String,
74 pub warnings: Vec<String>,
75 pub caller_role: CallerRole,
84}
85
86struct SqliteMigration {
95 version: i64,
96 name: &'static str,
97 sql: &'static str,
98}
99
100const SQLITE_MIGRATIONS: &[SqliteMigration] = &[
118 SqliteMigration {
119 version: 1,
120 name: "initial_audit_schema",
121 sql: r#"
122 CREATE TABLE IF NOT EXISTS transactions (
123 transaction_id TEXT PRIMARY KEY,
124 request_id TEXT NOT NULL,
125 request_hash TEXT NOT NULL,
126 action_name TEXT NOT NULL,
127 risk_level TEXT NOT NULL,
128 status TEXT NOT NULL,
129 approval_id TEXT,
130 summary TEXT NOT NULL,
131 warnings_json TEXT NOT NULL,
132 created_at TEXT NOT NULL DEFAULT (datetime('now')),
133 seq INTEGER NOT NULL UNIQUE,
134 key_id TEXT NOT NULL,
135 chain_hash TEXT NOT NULL,
136 prev_chain_hash TEXT NOT NULL DEFAULT ''
137 );
138
139 CREATE TABLE IF NOT EXISTS transaction_previews (
140 transaction_id TEXT PRIMARY KEY,
141 preview_json TEXT NOT NULL
142 );
143
144 CREATE TABLE IF NOT EXISTS transaction_approvals (
145 transaction_id TEXT PRIMARY KEY,
146 receipt_digest TEXT NOT NULL,
147 approved_at TEXT NOT NULL DEFAULT (datetime('now')),
148 consumed_at TEXT
149 );
150
151 CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
152 "#,
153 },
154 SqliteMigration {
163 version: 2,
164 name: "caller_identity_and_approval_events",
165 sql: r#"
166 ALTER TABLE transactions ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
167 ALTER TABLE transactions ADD COLUMN caller_role TEXT;
168 ALTER TABLE transactions ADD COLUMN event_tip TEXT;
169
170 CREATE TABLE IF NOT EXISTS audit_events (
171 seq INTEGER PRIMARY KEY,
172 key_id TEXT NOT NULL,
173 kind TEXT NOT NULL,
174 transaction_id TEXT NOT NULL,
175 receipt_digest TEXT NOT NULL,
176 created_at TEXT NOT NULL,
177 chain_hash TEXT NOT NULL,
178 prev_chain_hash TEXT NOT NULL DEFAULT ''
179 );
180
181 CREATE INDEX IF NOT EXISTS audit_events_transaction_idx
182 ON audit_events(transaction_id);
183 "#,
184 },
185];
186
187const CHAIN_ROW_COLUMNS: &str = "seq, key_id, transaction_id, request_id, request_hash, \
194 action_name, risk_level, summary, approval_id, warnings_json, \
195 created_at, prev_chain_hash, chain_hash, chain_version, caller_role, event_tip";
196
197fn chain_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainRow> {
198 Ok(ChainRow {
199 seq: row.get::<_, i64>(0)? as u64,
200 key_id: row.get(1)?,
201 transaction_id: row.get(2)?,
202 request_id: row.get(3)?,
203 request_hash: row.get(4)?,
204 action_name: row.get(5)?,
205 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
206 rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
207 })?,
208 summary: row.get(7)?,
209 approval_id: row.get(8)?,
210 warnings_json: row.get(9)?,
211 created_at: row.get(10)?,
212 prev_chain_hash: row.get(11)?,
213 chain_hash: row.get(12)?,
214 chain_version: row.get::<_, i64>(13)? as u32,
215 caller_role: row.get(14)?,
216 event_tip: row.get(15)?,
217 })
218}
219
220fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<EventRow> {
221 Ok(EventRow {
222 seq: row.get::<_, i64>(0)? as u64,
223 key_id: row.get(1)?,
224 kind: row.get(2)?,
225 transaction_id: row.get(3)?,
226 receipt_digest: row.get(4)?,
227 created_at: row.get(5)?,
228 prev_chain_hash: row.get(6)?,
229 chain_hash: row.get(7)?,
230 })
231}
232
233#[derive(Clone, Debug)]
234pub struct TransactionStore {
235 path: PathBuf,
236 audit_key: Option<Arc<AuditKey>>,
239}
240
241#[derive(Clone, Debug, PartialEq)]
242pub struct RecordedPreviewedTransaction {
243 pub transaction: TransactionRecord,
244 pub preview: PreviewEnvelope,
245}
246
247struct InsertedTransaction {
248 record: TransactionRecord,
249 seq: u64,
250 chain_hash: String,
251}
252
253#[derive(Debug, thiserror::Error)]
254pub enum TransactionStoreError {
255 #[error("io error: {0}")]
256 Io(#[from] std::io::Error),
257
258 #[error("sqlite error: {0}")]
259 Sqlite(#[from] rusqlite::Error),
260
261 #[error("json error: {0}")]
262 Json(#[from] serde_json::Error),
263
264 #[error("database invariant violation: {0}")]
265 DatabaseInvariant(String),
266
267 #[error("transaction not found: {0}")]
268 NotFound(String),
269
270 #[error("invalid transition from {from:?} to {to:?}")]
271 InvalidTransition { from: JobState, to: JobState },
272
273 #[error("audit chain misconfiguration: {0}")]
274 AuditChainMissing(&'static str),
275
276 #[error("transaction {0} status changed concurrently; retry")]
284 ConcurrentStatusChange(String),
285}
286
287impl TransactionStore {
288 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
292 let path = path.as_ref().to_path_buf();
293 if let Some(parent) = path.parent() {
294 ensure_private_dir(parent)?;
295 }
296
297 let store = Self {
298 path,
299 audit_key: None,
300 };
301 store.initialize()?;
302 Ok(store)
303 }
304
305 pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
312 let db_path = path.as_ref();
313 let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
314 .map(PathBuf::from)
315 .unwrap_or_else(|_| {
316 db_path
317 .parent()
318 .unwrap_or_else(|| Path::new("."))
319 .join("audit-key")
320 });
321 let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
322 TransactionStoreError::Io(std::io::Error::new(
323 std::io::ErrorKind::InvalidData,
324 format!("audit key load failed: {e}"),
325 ))
326 })?;
327 Self::open_with_key(path, Arc::new(key))
328 }
329
330 pub fn open_with_key(
333 path: impl AsRef<Path>,
334 audit_key: Arc<AuditKey>,
335 ) -> Result<Self, TransactionStoreError> {
336 let path = path.as_ref().to_path_buf();
337 if let Some(parent) = path.parent() {
338 ensure_private_dir(parent)?;
339 }
340
341 let store = Self {
342 path,
343 audit_key: Some(audit_key),
344 };
345 store.initialize()?;
346 Ok(store)
347 }
348
349 pub fn record(
350 &self,
351 transaction: NewTransaction,
352 ) -> Result<TransactionRecord, TransactionStoreError> {
353 let key = self
354 .audit_key
355 .as_ref()
356 .ok_or(TransactionStoreError::AuditChainMissing(
357 "this TransactionStore was opened read-only; cannot record",
358 ))?;
359 let mut conn = self.connection()?;
360 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
365 let transaction_id = Uuid::new_v4().to_string();
366 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
367 tx.commit()?;
368 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
369 Ok(inserted.record)
370 }
371
372 pub fn record_previewed(
373 &self,
374 transaction: NewTransaction,
375 preview: PreviewEnvelope,
376 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
377 let key = self
378 .audit_key
379 .as_ref()
380 .ok_or(TransactionStoreError::AuditChainMissing(
381 "this TransactionStore was opened read-only; cannot record",
382 ))?;
383 let mut conn = self.connection()?;
384 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
389 let transaction_id = Uuid::new_v4().to_string();
390 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
391 Self::insert_preview(&tx, &inserted.record.transaction_id, &preview)?;
392 tx.commit()?;
393 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
394
395 Ok(RecordedPreviewedTransaction {
396 transaction: inserted.record,
397 preview,
398 })
399 }
400
401 pub fn get(
402 &self,
403 transaction_id: &str,
404 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
405 let conn = self.connection()?;
406 let mut stmt = conn.prepare(
407 "SELECT
408 transaction_id,
409 request_id,
410 request_hash,
411 action_name,
412 risk_level,
413 status,
414 approval_id,
415 summary,
416 warnings_json
417 FROM transactions
418 WHERE transaction_id = ?1",
419 )?;
420 let mut rows = stmt.query(params![transaction_id])?;
421 if let Some(row) = rows.next()? {
422 Ok(Some(row_to_record(row)?))
423 } else {
424 Ok(None)
425 }
426 }
427
428 pub fn get_preview(
429 &self,
430 transaction_id: &str,
431 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
432 let conn = self.connection()?;
433 let mut stmt = conn.prepare(
434 "SELECT preview_json
435 FROM transaction_previews
436 WHERE transaction_id = ?1",
437 )?;
438 let mut rows = stmt.query(params![transaction_id])?;
439 if let Some(row) = rows.next()? {
440 let preview_json: String = row.get(0)?;
441 Ok(Some(serde_json::from_str(&preview_json)?))
442 } else {
443 Ok(None)
444 }
445 }
446
447 pub fn update_status(
448 &self,
449 transaction_id: &str,
450 new_status: JobState,
451 ) -> Result<(), TransactionStoreError> {
452 let conn = self.connection()?;
453
454 let current_status: String = conn
456 .query_row(
457 "SELECT status FROM transactions WHERE transaction_id = ?1",
458 params![transaction_id],
459 |row| row.get(0),
460 )
461 .map_err(|e| match e {
462 rusqlite::Error::QueryReturnedNoRows => {
463 TransactionStoreError::NotFound(transaction_id.to_string())
464 }
465 other => TransactionStoreError::Sqlite(other),
466 })?;
467
468 let current: JobState = deserialize_field(¤t_status)?;
469 if !crate::jobs::allowed_transition(¤t, &new_status) {
470 return Err(TransactionStoreError::InvalidTransition {
471 from: current,
472 to: new_status,
473 });
474 }
475
476 let rows_affected = conn.execute(
488 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
489 params![
490 serialize_field(&new_status)?,
491 transaction_id,
492 current_status
493 ],
494 )?;
495 if rows_affected == 0 {
496 return Err(TransactionStoreError::ConcurrentStatusChange(
497 transaction_id.to_string(),
498 ));
499 }
500 Ok(())
501 }
502
503 pub fn approve_transaction(
505 &self,
506 transaction_id: &str,
507 ) -> Result<Option<String>, TransactionStoreError> {
508 let key = self
509 .audit_key
510 .as_ref()
511 .ok_or(TransactionStoreError::AuditChainMissing(
512 "this TransactionStore was opened read-only; cannot approve",
513 ))?;
514 let Some(record) = self.get(transaction_id)? else {
515 return Ok(None);
516 };
517 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
518 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
519 let Some(committed_digest) = record.approval_id.as_deref() else {
520 return Err(TransactionStoreError::DatabaseInvariant(format!(
521 "transaction {transaction_id} has no signed approval commitment"
522 )));
523 };
524 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
525 return Err(TransactionStoreError::DatabaseInvariant(format!(
526 "transaction {transaction_id} approval commitment does not match its signed preview"
527 )));
528 }
529
530 let mut conn = self.connection()?;
533 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
534 let queued_json = serialize_field(&JobState::Queued)?;
535 let rows_affected = tx.execute(
536 &format!(
537 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
538 SELECT transaction_id, ?1 FROM transactions \
539 WHERE transaction_id = ?2 \
540 AND status = ?3 \
541 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
542 AND NOT EXISTS ( \
543 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
544 )"
545 ),
546 params![receipt_digest, transaction_id, queued_json],
547 )?;
548 if rows_affected > 0 {
549 Self::append_event(
550 &tx,
551 key,
552 AuditEventKind::ApprovalGranted,
553 transaction_id,
554 &receipt_digest,
555 )?;
556 }
557 tx.commit()?;
558 Ok((rows_affected > 0).then_some(receipt))
559 }
560
561 pub fn revoke_unconsumed_approval(
564 &self,
565 transaction_id: &str,
566 ) -> Result<bool, TransactionStoreError> {
567 let key = self
568 .audit_key
569 .as_ref()
570 .ok_or(TransactionStoreError::AuditChainMissing(
571 "this TransactionStore was opened read-only; cannot revoke",
572 ))?;
573 let mut conn = self.connection()?;
574 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
575 let digest: Option<String> = tx
578 .query_row(
579 "SELECT receipt_digest FROM transaction_approvals \
580 WHERE transaction_id = ?1 AND consumed_at IS NULL",
581 params![transaction_id],
582 |row| row.get(0),
583 )
584 .map(Some)
585 .or_else(|e| match e {
586 rusqlite::Error::QueryReturnedNoRows => Ok(None),
587 other => Err(other),
588 })?;
589 let rows_affected = tx.execute(
590 "DELETE FROM transaction_approvals \
591 WHERE transaction_id = ?1 AND consumed_at IS NULL",
592 params![transaction_id],
593 )?;
594 if rows_affected > 0 {
595 let digest = digest.ok_or_else(|| {
596 TransactionStoreError::DatabaseInvariant(format!(
597 "revoked an approval for {transaction_id} that had no receipt digest"
598 ))
599 })?;
600 Self::append_event(
601 &tx,
602 key,
603 AuditEventKind::ApprovalRevoked,
604 transaction_id,
605 &digest,
606 )?;
607 }
608 tx.commit()?;
609 Ok(rows_affected > 0)
610 }
611
612 pub fn claim_approved_for_execution(
614 &self,
615 transaction_id: &str,
616 receipt_digest: &str,
617 ) -> Result<bool, TransactionStoreError> {
618 let key = self
619 .audit_key
620 .as_ref()
621 .ok_or(TransactionStoreError::AuditChainMissing(
622 "this TransactionStore was opened read-only; cannot claim",
623 ))?;
624 let mut conn = self.connection()?;
625 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
626 let queued_json = serialize_field(&JobState::Queued)?;
627 let running_json = serialize_field(&JobState::Running)?;
628 let rows_affected = tx.execute(
629 &format!(
630 "UPDATE transactions SET status = ?1 \
631 WHERE transaction_id = ?2 \
632 AND status = ?3 \
633 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
634 AND EXISTS ( \
635 SELECT 1 FROM transaction_approvals \
636 WHERE transaction_id = ?2 \
637 AND receipt_digest = ?4 \
638 AND consumed_at IS NULL \
639 )"
640 ),
641 params![running_json, transaction_id, queued_json, receipt_digest],
642 )?;
643 if rows_affected > 0 {
644 tx.execute(
645 "UPDATE transaction_approvals \
646 SET consumed_at = datetime('now') \
647 WHERE transaction_id = ?1 AND consumed_at IS NULL",
648 params![transaction_id],
649 )?;
650 Self::append_event(
651 &tx,
652 key,
653 AuditEventKind::ApprovalConsumed,
654 transaction_id,
655 receipt_digest,
656 )?;
657 }
658 tx.commit()?;
659 Ok(rows_affected > 0)
660 }
661
662 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
677 let conn = self.connection()?;
678 let canceled_json = serialize_field(&JobState::Canceled)?;
679 let queued_json = serialize_field(&JobState::Queued)?;
680 let rows_affected = conn.execute(
681 &format!(
682 "UPDATE transactions SET status = ?1 \
683 WHERE status = ?2 \
684 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
685 ),
686 params![canceled_json, queued_json],
687 )?;
688 Ok(rows_affected as u64)
689 }
690
691 pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
700 let conn = self.connection()?;
701 let canceled_json = serialize_field(&JobState::Canceled)?;
702 let queued_json = serialize_field(&JobState::Queued)?;
703 let rows_affected = conn.execute(
704 "UPDATE transactions SET status = ?1 \
705 WHERE transaction_id = ?2 AND status = ?3",
706 params![canceled_json, transaction_id, queued_json],
707 )?;
708 Ok(rows_affected > 0)
709 }
710
711 pub fn list_transactions(
720 &self,
721 limit: u32,
722 status_filter: Option<&str>,
723 action_filter: Option<&str>,
724 since_hours: Option<u32>,
725 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
726 let conn = self.connection()?;
727 let (filter_sql, param_values) =
728 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
729 let sql = format!(
730 "SELECT transaction_id, request_id, request_hash, action_name, \
731 risk_level, status, approval_id, summary, warnings_json \
732 FROM transactions WHERE 1=1{filter_sql}"
733 );
734 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
735 param_values.iter().map(|b| b.as_ref()).collect();
736
737 let mut stmt = conn.prepare(&sql)?;
738 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
739
740 let mut results = Vec::new();
741 for row in rows {
742 results.push(row??);
743 }
744 Ok(results)
745 }
746
747 pub fn list_history(
752 &self,
753 limit: u32,
754 status_filter: Option<&str>,
755 action_filter: Option<&str>,
756 since_hours: Option<u32>,
757 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
758 let conn = self.connection()?;
759 let (filter_sql, param_values) =
760 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
761 let sql = format!(
762 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
763 FROM transactions WHERE 1=1{filter_sql}"
764 );
765 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
766 param_values.iter().map(|b| b.as_ref()).collect();
767
768 let mut stmt = conn.prepare(&sql)?;
769 let rows = stmt.query_map(params_ref.as_slice(), |row| {
770 Ok((|| {
771 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
772 transaction_id: row.get(0)?,
773 action_name: row.get(1)?,
774 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
775 status: deserialize_field(&row.get::<_, String>(3)?)?,
776 summary: row.get(4)?,
777 created_at: row.get(5)?,
778 })
779 })())
780 })?;
781
782 let mut results = Vec::new();
783 for row in rows {
784 results.push(row??);
785 }
786 Ok(results)
787 }
788
789 fn build_history_filter(
794 limit: u32,
795 status_filter: Option<&str>,
796 action_filter: Option<&str>,
797 since_hours: Option<u32>,
798 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
799 let mut sql = String::new();
800 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
801
802 if let Some(status) = status_filter {
803 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
807 let status_json = serialize_field(&validated)?;
808 sql.push_str(" AND status = ?");
809 param_values.push(Box::new(status_json));
810 }
811
812 if let Some(action) = action_filter {
813 sql.push_str(" AND action_name = ?");
814 param_values.push(Box::new(action.to_string()));
815 }
816
817 if let Some(hours) = since_hours {
818 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
819 param_values.push(Box::new(hours));
820 }
821
822 sql.push_str(" ORDER BY seq DESC LIMIT ?");
823 param_values.push(Box::new(limit.min(100)));
824
825 Ok((sql, param_values))
826 }
827
828 fn connection(&self) -> Result<Connection, TransactionStoreError> {
829 let conn = Connection::open(&self.path)?;
830 conn.pragma_update(None, "journal_mode", "WAL")?;
841 conn.pragma_update(None, "synchronous", "NORMAL")?;
842 conn.pragma_update(None, "foreign_keys", "ON")?;
843 conn.busy_timeout(std::time::Duration::from_secs(5))?;
844 Ok(conn)
845 }
846
847 fn initialize(&self) -> Result<(), TransactionStoreError> {
848 let mut conn = self.connection()?;
849 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
850 tx.execute_batch(
851 "CREATE TABLE IF NOT EXISTS schema_migrations (\
852 version INTEGER PRIMARY KEY,\
853 name TEXT NOT NULL,\
854 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
855 );",
856 )?;
857 let current: i64 = tx.query_row(
858 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
859 [],
860 |row| row.get(0),
861 )?;
862 let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
863 if current > latest {
864 return Err(TransactionStoreError::DatabaseInvariant(format!(
865 "sqlite schema version {current} is newer than this binary supports ({latest})"
866 )));
867 }
868 for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
869 tx.execute_batch(migration.sql)?;
870 tx.execute(
871 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
872 params![migration.version, migration.name],
873 )?;
874 }
875 tx.commit()?;
876 Ok(())
877 }
878
879 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
881 let conn = self.connection()?;
882 let mut stmt = conn.prepare(&format!(
883 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
884 ))?;
885 let rows = stmt.query_map([], chain_row_from_sqlite)?;
886 let mut out = Vec::new();
887 for row in rows {
888 out.push(row?);
889 }
890 Ok(out)
891 }
892
893 pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
895 let conn = self.connection()?;
896 let mut stmt = conn.prepare(
897 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
898 created_at, prev_chain_hash, chain_hash \
899 FROM audit_events ORDER BY seq ASC",
900 )?;
901 let rows = stmt.query_map([], event_row_from_sqlite)?;
902 let mut out = Vec::new();
903 for row in rows {
904 out.push(row?);
905 }
906 Ok(out)
907 }
908
909 pub fn verify_audit_chain(
911 &self,
912 key: &AuditKey,
913 ) -> Result<VerifyOutcome, TransactionStoreError> {
914 let rows = self.fetch_chain_rows()?;
915 Ok(audit_chain::verify_chain(key, &rows))
916 }
917
918 pub fn verify_event_chain(
920 &self,
921 key: &AuditKey,
922 ) -> Result<VerifyOutcome, TransactionStoreError> {
923 let rows = self.fetch_event_rows()?;
924 Ok(audit_chain::verify_event_chain(key, &rows))
925 }
926
927 pub fn verify_event_binding(
930 &self,
931 ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
932 let tx_rows = self.fetch_chain_rows()?;
933 let event_rows = self.fetch_event_rows()?;
934 Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
935 }
936
937 pub fn verify_audit_chain_with_pubkey(
940 &self,
941 verifying_key_hex: &str,
942 ) -> Result<VerifyOutcome, TransactionStoreError> {
943 let rows = self.fetch_chain_rows()?;
944 Ok(audit_chain::verify_chain_with_pubkey(
945 verifying_key_hex,
946 &rows,
947 ))
948 }
949
950 pub fn fetch_chain_row(
953 &self,
954 transaction_id: &str,
955 ) -> Result<Option<ChainRow>, TransactionStoreError> {
956 let conn = self.connection()?;
957 let mut stmt = conn.prepare(&format!(
958 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
959 ))?;
960 let mut rows = stmt.query(params![transaction_id])?;
961 if let Some(row) = rows.next()? {
962 Ok(Some(chain_row_from_sqlite(row)?))
963 } else {
964 Ok(None)
965 }
966 }
967
968 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
972 let prev: Option<(i64, String)> = conn
973 .query_row(
974 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
975 [],
976 |row| Ok((row.get(0)?, row.get(1)?)),
977 )
978 .map(Some)
979 .or_else(|e| match e {
980 rusqlite::Error::QueryReturnedNoRows => Ok(None),
981 other => Err(other),
982 })?;
983 Ok(match prev {
984 Some((seq, hash)) => ((seq as u64) + 1, hash),
985 None => (1, String::new()),
986 })
987 }
988
989 fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
992 conn.query_row(
993 "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
994 [],
995 |row| row.get(0),
996 )
997 .map(Some)
998 .or_else(|e| match e {
999 rusqlite::Error::QueryReturnedNoRows => Ok(None),
1000 other => Err(other.into()),
1001 })
1002 }
1003
1004 fn append_event(
1009 conn: &Connection,
1010 key: &AuditKey,
1011 kind: AuditEventKind,
1012 transaction_id: &str,
1013 receipt_digest: &str,
1014 ) -> Result<(), TransactionStoreError> {
1015 let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1016 let seq: i64 = conn.query_row(
1017 "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1018 [],
1019 |row| row.get(0),
1020 )?;
1021 let created_at: String =
1022 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1023 row.get(0)
1024 })?;
1025 let key_id = CURRENT_KEY_ID.to_string();
1026 let chain_hash = key.event_hash(
1027 &EventContent {
1028 seq: seq as u64,
1029 key_id: &key_id,
1030 kind,
1031 transaction_id,
1032 receipt_digest,
1033 created_at: &created_at,
1034 },
1035 &prev_chain_hash,
1036 );
1037 conn.execute(
1038 "INSERT INTO audit_events (
1039 seq, key_id, kind, transaction_id, receipt_digest,
1040 created_at, chain_hash, prev_chain_hash
1041 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1042 params![
1043 seq,
1044 key_id,
1045 kind.as_str(),
1046 transaction_id,
1047 receipt_digest,
1048 created_at,
1049 chain_hash,
1050 prev_chain_hash,
1051 ],
1052 )?;
1053 Ok(())
1054 }
1055
1056 fn insert_transaction(
1057 conn: &Connection,
1058 key: &AuditKey,
1059 transaction_id: &str,
1060 transaction: NewTransaction,
1061 ) -> Result<InsertedTransaction, TransactionStoreError> {
1062 let request_id = transaction.request_id;
1063 let request_hash = transaction.request_hash;
1064 let action_name = transaction.action_name;
1065 let risk_level = transaction.risk_level;
1066 let status = JobState::Queued;
1068 let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1075 let summary = transaction.summary;
1076 let warnings = transaction.warnings;
1077 let warnings_json = serde_json::to_string(&warnings)?;
1078
1079 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1082
1083 let created_at: String =
1087 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1088 row.get(0)
1089 })?;
1090
1091 let key_id = CURRENT_KEY_ID.to_string();
1092 let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1096 let caller_role = transaction.caller_role.as_str();
1097 let chain_hash = key.chain_hash(
1098 &ChainContent {
1099 seq,
1100 key_id: &key_id,
1101 transaction_id,
1102 request_id: &request_id,
1103 request_hash: &request_hash,
1104 action_name: &action_name,
1105 risk_level,
1106 summary: &summary,
1107 approval_id: approval_id.as_deref(),
1108 warnings_json: &warnings_json,
1109 created_at: &created_at,
1110 identity: ChainIdentity::V2 {
1111 caller_role,
1112 event_tip: &event_tip,
1113 },
1114 },
1115 &prev_chain_hash,
1116 );
1117
1118 let record = TransactionRecord {
1119 transaction_id: transaction_id.to_string(),
1120 request_id: request_id.clone(),
1121 request_hash: request_hash.clone(),
1122 action_name: action_name.clone(),
1123 risk_level,
1124 status,
1125 approval_id: approval_id.clone(),
1126 summary: summary.clone(),
1127 warnings: warnings.clone(),
1128 };
1129
1130 conn.execute(
1131 "INSERT INTO transactions (
1132 transaction_id,
1133 request_id,
1134 request_hash,
1135 action_name,
1136 risk_level,
1137 status,
1138 approval_id,
1139 summary,
1140 warnings_json,
1141 created_at,
1142 seq,
1143 key_id,
1144 chain_hash,
1145 prev_chain_hash,
1146 chain_version,
1147 caller_role,
1148 event_tip
1149 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
1150 params![
1151 transaction_id,
1152 request_id,
1153 request_hash,
1154 action_name,
1155 serialize_field(&risk_level)?,
1156 serialize_field(&status)?,
1157 approval_id,
1158 summary,
1159 warnings_json,
1160 created_at,
1161 seq as i64,
1162 key_id,
1163 chain_hash,
1164 prev_chain_hash,
1165 CHAIN_VERSION_CURRENT as i64,
1166 caller_role,
1167 event_tip,
1168 ],
1169 )?;
1170
1171 Ok(InsertedTransaction {
1172 record,
1173 seq,
1174 chain_hash,
1175 })
1176 }
1177
1178 fn insert_preview(
1179 conn: &Connection,
1180 transaction_id: &str,
1181 preview: &PreviewEnvelope,
1182 ) -> Result<(), TransactionStoreError> {
1183 conn.execute(
1184 "INSERT INTO transaction_previews (transaction_id, preview_json)
1185 VALUES (?1, ?2)",
1186 params![transaction_id, serde_json::to_string(preview)?],
1187 )?;
1188 Ok(())
1189 }
1190}
1191
1192fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1199 use std::os::unix::fs::DirBuilderExt;
1200 if dir.exists() {
1201 return Ok(());
1202 }
1203 std::fs::DirBuilder::new()
1204 .recursive(true)
1205 .mode(0o700)
1206 .create(dir)
1207}
1208
1209fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1210 Ok(TransactionRecord {
1211 transaction_id: row.get(0)?,
1212 request_id: row.get(1)?,
1213 request_hash: row.get(2)?,
1214 action_name: row.get(3)?,
1215 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1216 status: deserialize_field(&row.get::<_, String>(5)?)?,
1217 approval_id: row.get(6)?,
1218 summary: row.get(7)?,
1219 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1220 })
1221}
1222
1223fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1224 serde_json::to_string(value)
1225}
1226
1227fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1228 serde_json::from_str(value)
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233 use super::*;
1234 use crate::audit_chain::CHAIN_VERSION_LEGACY;
1235 use std::os::unix::fs::PermissionsExt;
1236 use tempfile::tempdir;
1237
1238 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1242 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1243 TransactionStore::open_with_key(path, key).unwrap()
1244 }
1245
1246 fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1253 let conn = Connection::open(path).unwrap();
1254 conn.execute_batch(
1255 "CREATE TABLE schema_migrations (
1256 version INTEGER PRIMARY KEY,
1257 name TEXT NOT NULL,
1258 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1259 );",
1260 )
1261 .unwrap();
1262 conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1263 conn.execute(
1264 "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1265 params![SQLITE_MIGRATIONS[0].name],
1266 )
1267 .unwrap();
1268
1269 let transaction_id = "tx-legacy";
1270 let created_at = "2026-04-24T12:00:00.000Z";
1271 let chain_hash = key.chain_hash(
1272 &ChainContent {
1273 seq: 1,
1274 key_id: CURRENT_KEY_ID,
1275 transaction_id,
1276 request_id: "req-legacy",
1277 request_hash: "hash-legacy",
1278 action_name: "UpdateSystem",
1279 risk_level: RiskLevel::High,
1280 summary: "Upgrade the system",
1281 approval_id: None,
1282 warnings_json: "[]",
1283 created_at,
1284 identity: ChainIdentity::LegacyV1,
1285 },
1286 "",
1287 );
1288 conn.execute(
1289 "INSERT INTO transactions (
1290 transaction_id, request_id, request_hash, action_name, risk_level,
1291 status, approval_id, summary, warnings_json, created_at,
1292 seq, key_id, chain_hash, prev_chain_hash
1293 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1294 params![
1295 transaction_id,
1296 "req-legacy",
1297 "hash-legacy",
1298 "UpdateSystem",
1299 serialize_field(&RiskLevel::High).unwrap(),
1300 serialize_field(&JobState::Queued).unwrap(),
1301 "Upgrade the system",
1302 "[]",
1303 created_at,
1304 CURRENT_KEY_ID,
1305 chain_hash,
1306 ],
1307 )
1308 .unwrap();
1309 transaction_id.to_string()
1310 }
1311
1312 #[test]
1313 fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1314 let dir = tempdir().unwrap();
1319 let db_path = dir.path().join("tx.db");
1320 let key = AuditKey::from_bytes(vec![0x42; 32]);
1321 legacy_v1_database(&db_path, &key);
1322
1323 let store = test_store(&db_path);
1324 assert_eq!(
1325 store.verify_audit_chain(&key).unwrap(),
1326 VerifyOutcome::Intact { rows_checked: 1 }
1327 );
1328 let rows = store.fetch_chain_rows().unwrap();
1329 assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1330 assert_eq!(rows[0].caller_role, None);
1331 }
1332
1333 #[test]
1334 fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1335 let dir = tempdir().unwrap();
1339 let db_path = dir.path().join("tx.db");
1340 let key = AuditKey::from_bytes(vec![0x42; 32]);
1341 legacy_v1_database(&db_path, &key);
1342
1343 let store = test_store(&db_path);
1344 store.record(queued_transaction()).unwrap();
1345
1346 let rows = store.fetch_chain_rows().unwrap();
1347 assert_eq!(rows.len(), 2);
1348 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1349 assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1350 assert_eq!(
1351 store.verify_audit_chain(&key).unwrap(),
1352 VerifyOutcome::Intact { rows_checked: 2 }
1353 );
1354 }
1355
1356 #[test]
1357 fn a_schema_newer_than_this_binary_is_refused() {
1358 let dir = tempdir().unwrap();
1359 let db_path = dir.path().join("tx.db");
1360 let key = AuditKey::from_bytes(vec![0x42; 32]);
1361 legacy_v1_database(&db_path, &key);
1362 let conn = Connection::open(&db_path).unwrap();
1363 conn.execute(
1364 "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1365 [],
1366 )
1367 .unwrap();
1368 drop(conn);
1369
1370 let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1371 assert!(
1372 matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1373 "expected a refusal naming the unsupported version, got {err:?}"
1374 );
1375 }
1376
1377 #[test]
1380 fn the_signed_row_records_which_role_asked() {
1381 let dir = tempdir().unwrap();
1382 let store = test_store(dir.path().join("tx.db"));
1383 let mut tx = queued_transaction();
1384 tx.caller_role = CallerRole::Admin;
1385 store.record(tx).unwrap();
1386
1387 let rows = store.fetch_chain_rows().unwrap();
1388 assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1389 }
1390
1391 #[test]
1392 fn approving_consuming_and_revoking_each_append_a_chained_event() {
1393 let dir = tempdir().unwrap();
1394 let key = AuditKey::from_bytes(vec![0x42; 32]);
1395 let store = test_store(dir.path().join("tx.db"));
1396
1397 let a = store.record(queued_transaction()).unwrap();
1399 let receipt = store
1400 .approve_transaction(&a.transaction_id)
1401 .unwrap()
1402 .expect("queued transaction approves");
1403 let digest = audit_chain::approval_receipt_digest(&receipt);
1404 assert!(store
1405 .claim_approved_for_execution(&a.transaction_id, &digest)
1406 .unwrap());
1407
1408 let b = store.record(queued_transaction()).unwrap();
1410 store.approve_transaction(&b.transaction_id).unwrap();
1411 assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap());
1412
1413 let events = store.fetch_event_rows().unwrap();
1414 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1415 assert_eq!(
1416 kinds,
1417 vec![
1418 "approval_granted",
1419 "approval_consumed",
1420 "approval_granted",
1421 "approval_revoked",
1422 ]
1423 );
1424 assert_eq!(
1425 store.verify_event_chain(&key).unwrap(),
1426 VerifyOutcome::Intact { rows_checked: 4 }
1427 );
1428 }
1429
1430 #[test]
1431 fn a_failed_approval_appends_no_event() {
1432 let dir = tempdir().unwrap();
1436 let store = test_store(dir.path().join("tx.db"));
1437 let tx = store.record(queued_transaction()).unwrap();
1438 store.approve_transaction(&tx.transaction_id).unwrap();
1439 store.approve_transaction(&tx.transaction_id).unwrap();
1440 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1441
1442 assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap());
1444 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1445 }
1446
1447 #[test]
1448 fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
1449 let dir = tempdir().unwrap();
1455 let db_path = dir.path().join("tx.db");
1456 let key = AuditKey::from_bytes(vec![0x42; 32]);
1457 let store = test_store(&db_path);
1458
1459 let a = store.record(queued_transaction()).unwrap();
1460 store.approve_transaction(&a.transaction_id).unwrap();
1461 store.record(queued_transaction()).unwrap();
1464
1465 assert_eq!(
1466 store.verify_event_chain(&key).unwrap(),
1467 VerifyOutcome::Intact { rows_checked: 1 }
1468 );
1469 assert_eq!(
1470 store.verify_event_binding().unwrap(),
1471 audit_chain::BindingOutcome::Consistent {
1472 bindings_checked: 1
1473 }
1474 );
1475
1476 let conn = Connection::open(&db_path).unwrap();
1478 conn.execute("DELETE FROM transaction_approvals", [])
1479 .unwrap();
1480 conn.execute("DELETE FROM audit_events", []).unwrap();
1481 drop(conn);
1482
1483 assert_eq!(
1487 store.verify_event_chain(&key).unwrap(),
1488 VerifyOutcome::Intact { rows_checked: 0 }
1489 );
1490 match store.verify_event_binding().unwrap() {
1491 audit_chain::BindingOutcome::MissingEvent {
1492 transaction_seq, ..
1493 } => assert_eq!(transaction_seq, 2),
1494 other => panic!("expected MissingEvent, got {other:?}"),
1495 }
1496 }
1497
1498 #[test]
1499 fn a_read_only_store_cannot_revoke_or_claim() {
1500 let dir = tempdir().unwrap();
1503 let db_path = dir.path().join("tx.db");
1504 let store = test_store(&db_path);
1505 store.record(queued_transaction()).unwrap();
1506
1507 let read_only = TransactionStore::open_read_only(&db_path).unwrap();
1508 assert!(matches!(
1509 read_only.revoke_unconsumed_approval("tx"),
1510 Err(TransactionStoreError::AuditChainMissing(_))
1511 ));
1512 assert!(matches!(
1513 read_only.claim_approved_for_execution("tx", "digest"),
1514 Err(TransactionStoreError::AuditChainMissing(_))
1515 ));
1516 }
1517
1518 #[test]
1521 fn record_writes_audit_chain_columns() {
1522 let dir = tempdir().unwrap();
1523 let store = test_store(dir.path().join("tx.db"));
1524 let _record = store.record(queued_transaction()).unwrap();
1525
1526 let conn = store.connection().unwrap();
1527 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
1528 .query_row(
1529 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
1530 [],
1531 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1532 )
1533 .unwrap();
1534 assert_eq!(seq, 1, "first row gets seq=1");
1535 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
1536 assert_eq!(prev, "", "first row has empty prev_chain_hash");
1537 assert_eq!(
1538 chain_hash.len(),
1539 audit_chain::HASH_HEX_LEN,
1540 "chain_hash is a hex-encoded Ed25519 signature"
1541 );
1542 }
1543
1544 #[test]
1545 fn sequential_records_produce_chained_hashes() {
1546 let dir = tempdir().unwrap();
1547 let store = test_store(dir.path().join("tx.db"));
1548 store.record(queued_transaction()).unwrap();
1549 store.record(queued_transaction()).unwrap();
1550 store.record(queued_transaction()).unwrap();
1551
1552 let rows = store.fetch_chain_rows().unwrap();
1553 assert_eq!(rows.len(), 3);
1554 assert_eq!(rows[0].seq, 1);
1555 assert_eq!(rows[1].seq, 2);
1556 assert_eq!(rows[2].seq, 3);
1557 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1559 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
1560 }
1561
1562 #[test]
1574 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
1575 const WORKERS: usize = 8;
1576 const RECORDS_PER_WORKER: usize = 10;
1577 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
1578
1579 let dir = tempdir().unwrap();
1580 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1581
1582 let mut handles = Vec::with_capacity(WORKERS);
1583 for w in 0..WORKERS {
1584 let store = std::sync::Arc::clone(&store);
1585 handles.push(std::thread::spawn(move || {
1586 for r in 0..RECORDS_PER_WORKER {
1587 let tx = NewTransaction {
1588 request_id: format!("worker-{w}-record-{r}"),
1589 request_hash: format!("hash-{w}-{r}"),
1590 action_name: "GetSystemState".to_string(),
1591 risk_level: RiskLevel::Low,
1592 summary: format!("worker {w} record {r}"),
1593 warnings: vec![],
1594 caller_role: CallerRole::Dev,
1595 };
1596 store
1597 .record(tx)
1598 .expect("record must succeed under contention");
1599 }
1600 }));
1601 }
1602 for h in handles {
1603 h.join().expect("worker thread did not panic");
1604 }
1605
1606 let key = AuditKey::from_bytes(vec![0x42; 32]);
1608 let outcome = store.verify_audit_chain(&key).unwrap();
1609 match outcome {
1610 VerifyOutcome::Intact { rows_checked } => {
1611 assert_eq!(
1612 rows_checked, TOTAL as u64,
1613 "expected {TOTAL} rows checked, got {rows_checked}"
1614 );
1615 }
1616 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
1617 }
1618
1619 let conn = store.connection().unwrap();
1621 let mut stmt = conn
1622 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
1623 .unwrap();
1624 let seqs: Vec<i64> = stmt
1625 .query_map([], |row| row.get::<_, i64>(0))
1626 .unwrap()
1627 .map(|r| r.unwrap())
1628 .collect();
1629 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
1630 for (i, s) in seqs.iter().enumerate() {
1631 assert_eq!(
1632 *s,
1633 (i as i64) + 1,
1634 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
1635 );
1636 }
1637 }
1638
1639 #[test]
1640 fn verify_audit_chain_intact_after_inserts() {
1641 let dir = tempdir().unwrap();
1642 let store = test_store(dir.path().join("tx.db"));
1643 for _ in 0..3 {
1644 store.record(queued_transaction()).unwrap();
1645 }
1646 let key = AuditKey::from_bytes(vec![0x42; 32]);
1647 let outcome = store.verify_audit_chain(&key).unwrap();
1648 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1649 }
1650
1651 #[test]
1652 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
1653 let dir = tempdir().unwrap();
1654 let store = test_store(dir.path().join("tx.db"));
1655 for _ in 0..3 {
1656 store.record(queued_transaction()).unwrap();
1657 }
1658 let key = AuditKey::from_bytes(vec![0x42; 32]);
1660 let outcome = store
1661 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
1662 .unwrap();
1663 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1664 }
1665
1666 #[test]
1667 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
1668 let dir = tempdir().unwrap();
1669 let store = test_store(dir.path().join("tx.db"));
1670 store.record(queued_transaction()).unwrap();
1671 let other = AuditKey::from_bytes(vec![0x99; 32]);
1673 let outcome = store
1674 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
1675 .unwrap();
1676 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1677 }
1678
1679 #[test]
1680 fn verify_detects_tampered_summary() {
1681 let dir = tempdir().unwrap();
1682 let store = test_store(dir.path().join("tx.db"));
1683 let tx = store.record(queued_transaction()).unwrap();
1684
1685 let conn = store.connection().unwrap();
1688 conn.execute(
1689 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
1690 params!["EVIL CHANGE", tx.transaction_id],
1691 )
1692 .unwrap();
1693
1694 let key = AuditKey::from_bytes(vec![0x42; 32]);
1695 let outcome = store.verify_audit_chain(&key).unwrap();
1696 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1697 }
1698
1699 #[test]
1700 fn status_update_does_not_break_chain() {
1701 let dir = tempdir().unwrap();
1703 let store = test_store(dir.path().join("tx.db"));
1704 let tx = store.record(queued_transaction()).unwrap();
1705 store
1706 .update_status(&tx.transaction_id, JobState::Running)
1707 .unwrap();
1708 store
1709 .update_status(&tx.transaction_id, JobState::Succeeded)
1710 .unwrap();
1711
1712 let key = AuditKey::from_bytes(vec![0x42; 32]);
1713 let outcome = store.verify_audit_chain(&key).unwrap();
1714 assert!(
1715 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
1716 "status mutation must not break the chain (status not in chain content): {outcome:?}"
1717 );
1718 }
1719
1720 #[test]
1721 fn open_read_only_rejects_record() {
1722 let dir = tempdir().unwrap();
1723 let key_path = dir.path().join("audit-key");
1724 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
1725 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1726
1727 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
1728 let result = store.record(queued_transaction());
1729 assert!(matches!(
1730 result,
1731 Err(TransactionStoreError::AuditChainMissing(_))
1732 ));
1733 }
1734
1735 #[test]
1736 fn ensure_private_dir_creates_with_0700_mode() {
1737 let root = tempdir().unwrap();
1738 let target = root.path().join("a/b/c");
1739 ensure_private_dir(&target).unwrap();
1740 assert!(target.is_dir());
1741 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1742 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
1743 }
1744
1745 #[test]
1746 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
1747 let root = tempdir().unwrap();
1748 let target = root.path().join("preexisting");
1749 std::fs::create_dir(&target).unwrap();
1750 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1751 ensure_private_dir(&target).unwrap();
1752 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1754 assert_eq!(mode, 0o755);
1755 }
1756
1757 #[test]
1758 fn open_creates_parent_with_private_mode() {
1759 let root = tempdir().unwrap();
1760 let db_path = root.path().join("nested/dirs/daemon.sqlite");
1761 let _store = test_store(&db_path);
1762 let parent = db_path.parent().unwrap();
1763 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
1764 assert_eq!(mode, 0o700);
1765 }
1766
1767 fn queued_transaction() -> NewTransaction {
1768 NewTransaction {
1769 request_id: "req-1".to_string(),
1770 request_hash: "hash-abc".to_string(),
1771 action_name: "UpdateSystem".to_string(),
1772 risk_level: RiskLevel::High,
1773 summary: "Upgrade the system".to_string(),
1774 warnings: vec![],
1775 caller_role: CallerRole::Dev,
1776 }
1777 }
1778
1779 #[test]
1780 fn update_status_transitions_queued_to_running() {
1781 let dir = tempdir().unwrap();
1782 let store = test_store(dir.path().join("tx.db"));
1783 let tx = store.record(queued_transaction()).unwrap();
1784
1785 store
1786 .update_status(&tx.transaction_id, JobState::Running)
1787 .unwrap();
1788
1789 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1790 assert_eq!(updated.status, JobState::Running);
1791 }
1792
1793 #[test]
1794 fn update_status_transitions_running_to_succeeded() {
1795 let dir = tempdir().unwrap();
1796 let store = test_store(dir.path().join("tx.db"));
1797 let tx = store.record(queued_transaction()).unwrap();
1798
1799 store
1800 .update_status(&tx.transaction_id, JobState::Running)
1801 .unwrap();
1802 store
1803 .update_status(&tx.transaction_id, JobState::Succeeded)
1804 .unwrap();
1805
1806 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1807 assert_eq!(updated.status, JobState::Succeeded);
1808 }
1809
1810 #[test]
1811 fn update_status_for_unknown_id_returns_not_found() {
1812 let dir = tempdir().unwrap();
1813 let store = test_store(dir.path().join("tx.db"));
1814
1815 let result = store.update_status("does-not-exist", JobState::Running);
1816 assert!(
1817 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
1818 "expected NotFound, got: {result:?}"
1819 );
1820 }
1821
1822 #[test]
1823 fn update_status_leaves_other_fields_intact() {
1824 let dir = tempdir().unwrap();
1825 let store = test_store(dir.path().join("tx.db"));
1826 let tx = store.record(queued_transaction()).unwrap();
1827
1828 store
1829 .update_status(&tx.transaction_id, JobState::Running)
1830 .unwrap();
1831 store
1832 .update_status(&tx.transaction_id, JobState::Failed)
1833 .unwrap();
1834
1835 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1836 assert_eq!(updated.action_name, "UpdateSystem");
1837 assert_eq!(updated.risk_level, RiskLevel::High);
1838 assert_eq!(updated.status, JobState::Failed);
1839 }
1840
1841 #[test]
1850 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
1851 let dir = tempdir().unwrap();
1852 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1853 let tx = store.record(queued_transaction()).unwrap();
1854 store
1855 .update_status(&tx.transaction_id, JobState::Running)
1856 .unwrap();
1857
1858 let store_a = std::sync::Arc::clone(&store);
1859 let id_a = tx.transaction_id.clone();
1860 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
1861
1862 let store_b = std::sync::Arc::clone(&store);
1863 let id_b = tx.transaction_id.clone();
1864 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
1865
1866 let succeed_result = succeed.join().unwrap();
1867 let fail_result = fail.join().unwrap();
1868
1869 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
1871 .into_iter()
1872 .filter(|ok| *ok)
1873 .count();
1874 assert_eq!(
1875 oks, 1,
1876 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
1877 fail={fail_result:?}"
1878 );
1879
1880 let loser = if succeed_result.is_ok() {
1896 &fail_result
1897 } else {
1898 &succeed_result
1899 };
1900 match loser {
1901 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
1902 assert_eq!(id, &tx.transaction_id);
1903 }
1904 Err(TransactionStoreError::InvalidTransition { from, to }) => {
1905 let winner = if succeed_result.is_ok() {
1907 JobState::Succeeded
1908 } else {
1909 JobState::Failed
1910 };
1911 assert_eq!(
1912 *from, winner,
1913 "the loser must have observed the winner's committed status"
1914 );
1915 assert_ne!(*to, winner, "the loser must be the other transition");
1916 }
1917 other => panic!(
1918 "the losing transition must be refused with either \
1919 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
1920 ),
1921 }
1922
1923 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
1926 if succeed_result.is_ok() {
1927 assert_eq!(final_status, JobState::Succeeded);
1928 } else {
1929 assert_eq!(final_status, JobState::Failed);
1930 }
1931 }
1932
1933 #[test]
1934 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
1935 let dir = tempdir().unwrap();
1938 let store = test_store(dir.path().join("tx.db"));
1939 let tx = store.record(queued_transaction()).unwrap();
1940 let receipt = store
1941 .approve_transaction(&tx.transaction_id)
1942 .unwrap()
1943 .expect("approved");
1944 let digest = audit_chain::approval_receipt_digest(&receipt);
1945
1946 assert!(
1947 store
1948 .revoke_unconsumed_approval(&tx.transaction_id)
1949 .unwrap(),
1950 "an unconsumed approval must be revocable"
1951 );
1952 assert!(
1953 !store
1954 .claim_approved_for_execution(&tx.transaction_id, &digest)
1955 .unwrap(),
1956 "a revoked receipt must no longer be claimable"
1957 );
1958 }
1959
1960 #[test]
1961 fn revoke_cannot_retract_an_approval_that_was_already_used() {
1962 let dir = tempdir().unwrap();
1966 let store = test_store(dir.path().join("tx.db"));
1967 let tx = store.record(queued_transaction()).unwrap();
1968 let receipt = store
1969 .approve_transaction(&tx.transaction_id)
1970 .unwrap()
1971 .expect("approved");
1972 let digest = audit_chain::approval_receipt_digest(&receipt);
1973 assert!(store
1974 .claim_approved_for_execution(&tx.transaction_id, &digest)
1975 .unwrap());
1976
1977 assert!(
1978 !store
1979 .revoke_unconsumed_approval(&tx.transaction_id)
1980 .unwrap(),
1981 "a consumed approval must not be revocable"
1982 );
1983 assert_eq!(
1984 store.get(&tx.transaction_id).unwrap().unwrap().status,
1985 JobState::Running,
1986 "a failed revocation must not disturb the running transaction"
1987 );
1988 }
1989
1990 #[test]
1991 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
1992 let dir = tempdir().unwrap();
1996 let store = test_store(dir.path().join("tx.db"));
1997 let tx = store.record(queued_transaction()).unwrap();
1998 let receipt = store
1999 .approve_transaction(&tx.transaction_id)
2000 .unwrap()
2001 .expect("approved");
2002 let digest = audit_chain::approval_receipt_digest(&receipt);
2003 assert!(store
2004 .claim_approved_for_execution(&tx.transaction_id, &digest)
2005 .unwrap());
2006
2007 assert!(
2009 store
2010 .approve_transaction(&tx.transaction_id)
2011 .unwrap()
2012 .is_none(),
2013 "a Running transaction must not be approvable"
2014 );
2015
2016 store
2018 .update_status(&tx.transaction_id, JobState::Succeeded)
2019 .unwrap();
2020 assert!(
2021 store
2022 .approve_transaction(&tx.transaction_id)
2023 .unwrap()
2024 .is_none(),
2025 "a completed transaction must not be approvable"
2026 );
2027 }
2028
2029 #[test]
2030 fn only_one_of_two_concurrent_claims_can_execute() {
2031 let dir = tempdir().unwrap();
2036 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2037 let tx = store.record(queued_transaction()).unwrap();
2038 let receipt = store
2039 .approve_transaction(&tx.transaction_id)
2040 .unwrap()
2041 .expect("approved");
2042 let digest = audit_chain::approval_receipt_digest(&receipt);
2043
2044 let mut handles = Vec::new();
2045 for _ in 0..2 {
2046 let store = std::sync::Arc::clone(&store);
2047 let id = tx.transaction_id.clone();
2048 let digest = digest.clone();
2049 handles.push(std::thread::spawn(move || {
2050 store.claim_approved_for_execution(&id, &digest)
2051 }));
2052 }
2053 let claims: Vec<bool> = handles
2054 .into_iter()
2055 .map(|h| h.join().unwrap().unwrap_or(false))
2056 .collect();
2057
2058 assert_eq!(
2059 claims.iter().filter(|c| **c).count(),
2060 1,
2061 "exactly one claim may win: {claims:?}"
2062 );
2063 assert_eq!(
2064 store.get(&tx.transaction_id).unwrap().unwrap().status,
2065 JobState::Running
2066 );
2067 }
2068
2069 #[test]
2070 fn cancel_queued_cancels_a_queued_transaction_once() {
2071 let dir = tempdir().unwrap();
2072 let store = test_store(dir.path().join("tx.db"));
2073 let tx = store.record(queued_transaction()).unwrap();
2074
2075 assert!(
2076 store.cancel_queued(&tx.transaction_id).unwrap(),
2077 "a queued transaction is cancelable"
2078 );
2079 assert_eq!(
2080 store.get(&tx.transaction_id).unwrap().unwrap().status,
2081 JobState::Canceled
2082 );
2083 assert!(
2084 !store.cancel_queued(&tx.transaction_id).unwrap(),
2085 "an already-canceled transaction is not cancelable again"
2086 );
2087 assert!(
2088 !store.cancel_queued("no-such-transaction").unwrap(),
2089 "a missing transaction is not cancelable"
2090 );
2091 }
2092
2093 #[test]
2094 fn cancel_queued_refuses_a_running_transaction() {
2095 let dir = tempdir().unwrap();
2098 let store = test_store(dir.path().join("tx.db"));
2099 let tx = store.record(queued_transaction()).unwrap();
2100 let receipt = store
2101 .approve_transaction(&tx.transaction_id)
2102 .unwrap()
2103 .expect("approved");
2104 let digest = audit_chain::approval_receipt_digest(&receipt);
2105 assert!(store
2106 .claim_approved_for_execution(&tx.transaction_id, &digest)
2107 .unwrap());
2108
2109 assert!(
2110 !store.cancel_queued(&tx.transaction_id).unwrap(),
2111 "a running transaction must not be cancelable"
2112 );
2113 assert_eq!(
2114 store.get(&tx.transaction_id).unwrap().unwrap().status,
2115 JobState::Running,
2116 "cancel must not disturb a running transaction"
2117 );
2118 }
2119
2120 #[test]
2121 fn approved_receipt_is_required_and_consumed_once() {
2122 let dir = tempdir().unwrap();
2123 let store = test_store(dir.path().join("tx.db"));
2124 let tx = store.record(queued_transaction()).unwrap();
2125
2126 assert!(
2127 !store
2128 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
2129 .unwrap(),
2130 "an unapproved preview must not execute"
2131 );
2132 let receipt = store
2133 .approve_transaction(&tx.transaction_id)
2134 .unwrap()
2135 .expect("first approval must succeed");
2136 let digest = audit_chain::approval_receipt_digest(&receipt);
2137 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
2138 assert!(
2139 store
2140 .approve_transaction(&tx.transaction_id)
2141 .unwrap()
2142 .is_none(),
2143 "approval is immutable once issued"
2144 );
2145 assert!(
2146 !store
2147 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
2148 .unwrap(),
2149 "a forged receipt must not execute"
2150 );
2151 assert!(
2152 store
2153 .claim_approved_for_execution(&tx.transaction_id, &digest)
2154 .unwrap(),
2155 "the exact approved receipt must execute"
2156 );
2157 assert!(
2158 !store
2159 .claim_approved_for_execution(&tx.transaction_id, &digest)
2160 .unwrap(),
2161 "the receipt must be one-time"
2162 );
2163 }
2164
2165 #[test]
2166 fn approval_commitment_is_covered_by_the_signed_chain() {
2167 let dir = tempdir().unwrap();
2168 let store = test_store(dir.path().join("tx.db"));
2169 let tx = store.record(queued_transaction()).unwrap();
2170 assert!(tx.approval_id.is_some());
2171
2172 store
2173 .connection()
2174 .unwrap()
2175 .execute(
2176 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2177 params![tx.transaction_id],
2178 )
2179 .unwrap();
2180
2181 let key = AuditKey::from_bytes(vec![0x42; 32]);
2182 assert!(matches!(
2183 store.verify_audit_chain(&key).unwrap(),
2184 VerifyOutcome::Broken { .. }
2185 ));
2186 }
2187
2188 #[test]
2189 fn approve_rejects_a_forged_commitment_at_runtime() {
2190 let dir = tempdir().unwrap();
2194 let store = test_store(dir.path().join("tx.db"));
2195 let tx = store.record(queued_transaction()).unwrap();
2196
2197 store
2198 .connection()
2199 .unwrap()
2200 .execute(
2201 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2202 params![tx.transaction_id],
2203 )
2204 .unwrap();
2205
2206 let err = store
2207 .approve_transaction(&tx.transaction_id)
2208 .expect_err("a forged commitment must be rejected, not approved");
2209 assert!(
2210 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
2211 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
2212 );
2213 }
2214
2215 #[test]
2216 fn stale_iso_timestamp_cannot_be_approved() {
2217 let dir = tempdir().unwrap();
2218 let store = test_store(dir.path().join("tx.db"));
2219 let tx = store.record(queued_transaction()).unwrap();
2220 let conn = store.connection().unwrap();
2221 conn.execute(
2222 "UPDATE transactions \
2223 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2224 WHERE transaction_id = ?1",
2225 params![tx.transaction_id],
2226 )
2227 .unwrap();
2228
2229 assert!(
2230 store
2231 .approve_transaction(&tx.transaction_id)
2232 .unwrap()
2233 .is_none(),
2234 "a production-format timestamp outside the TTL must not be approved"
2235 );
2236 }
2237
2238 #[test]
2239 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
2240 let dir = tempdir().unwrap();
2245 let store = test_store(dir.path().join("tx.db"));
2246 let tx = store.record(queued_transaction()).unwrap();
2247 let receipt = store
2248 .approve_transaction(&tx.transaction_id)
2249 .unwrap()
2250 .expect("a fresh approval succeeds");
2251 let digest = audit_chain::approval_receipt_digest(&receipt);
2252
2253 let conn = store.connection().unwrap();
2255 conn.execute(
2256 "UPDATE transactions \
2257 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2258 WHERE transaction_id = ?1",
2259 params![tx.transaction_id],
2260 )
2261 .unwrap();
2262
2263 assert!(
2264 !store
2265 .claim_approved_for_execution(&tx.transaction_id, &digest)
2266 .unwrap(),
2267 "an approval aged past the TTL must not be claimable at execute time"
2268 );
2269 assert_eq!(
2270 store.get(&tx.transaction_id).unwrap().unwrap().status,
2271 JobState::Queued,
2272 "a TTL-expired claim must leave the transaction Queued, never Running"
2273 );
2274 }
2275
2276 #[test]
2277 fn cleanup_stale_queued_cancels_old_records() {
2278 let dir = tempdir().unwrap();
2279 let store = test_store(dir.path().join("tx.db"));
2280
2281 let fresh = store.record(queued_transaction()).unwrap();
2283 let stale = store.record(queued_transaction()).unwrap();
2284
2285 let conn = store.connection().unwrap();
2287 conn.execute(
2288 "UPDATE transactions \
2289 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2290 WHERE transaction_id = ?1",
2291 params![stale.transaction_id],
2292 )
2293 .unwrap();
2294
2295 let canceled = store.cleanup_stale_queued().unwrap();
2296 assert_eq!(canceled, 1, "only the stale record should be canceled");
2297
2298 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
2300 assert_eq!(stale_record.status, JobState::Canceled);
2301
2302 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
2304 assert_eq!(fresh_record.status, JobState::Queued);
2305 }
2306
2307 #[test]
2310 fn update_status_rejects_queued_to_succeeded() {
2311 let dir = tempdir().unwrap();
2312 let store = test_store(dir.path().join("tx.db"));
2313 let tx = store.record(queued_transaction()).unwrap();
2314
2315 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
2316 assert!(
2317 matches!(
2318 result,
2319 Err(TransactionStoreError::InvalidTransition {
2320 from: JobState::Queued,
2321 to: JobState::Succeeded,
2322 })
2323 ),
2324 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
2325 );
2326 }
2327
2328 #[test]
2329 fn update_status_rejects_succeeded_to_running() {
2330 let dir = tempdir().unwrap();
2331 let store = test_store(dir.path().join("tx.db"));
2332 let tx = store.record(queued_transaction()).unwrap();
2333
2334 store
2335 .update_status(&tx.transaction_id, JobState::Running)
2336 .unwrap();
2337 store
2338 .update_status(&tx.transaction_id, JobState::Succeeded)
2339 .unwrap();
2340
2341 let result = store.update_status(&tx.transaction_id, JobState::Running);
2342 assert!(
2343 matches!(
2344 result,
2345 Err(TransactionStoreError::InvalidTransition {
2346 from: JobState::Succeeded,
2347 to: JobState::Running,
2348 })
2349 ),
2350 "Succeeded -> Running must be rejected (terminal state): {result:?}"
2351 );
2352 }
2353
2354 #[test]
2355 fn update_status_accepts_running_to_failed() {
2356 let dir = tempdir().unwrap();
2357 let store = test_store(dir.path().join("tx.db"));
2358 let tx = store.record(queued_transaction()).unwrap();
2359
2360 store
2361 .update_status(&tx.transaction_id, JobState::Running)
2362 .unwrap();
2363 store
2364 .update_status(&tx.transaction_id, JobState::Failed)
2365 .unwrap();
2366
2367 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2368 assert_eq!(updated.status, JobState::Failed);
2369 }
2370
2371 #[test]
2372 fn update_status_accepts_running_to_rolled_back() {
2373 let dir = tempdir().unwrap();
2374 let store = test_store(dir.path().join("tx.db"));
2375 let tx = store.record(queued_transaction()).unwrap();
2376
2377 store
2378 .update_status(&tx.transaction_id, JobState::Running)
2379 .unwrap();
2380 store
2381 .update_status(&tx.transaction_id, JobState::RolledBack)
2382 .unwrap();
2383
2384 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2385 assert_eq!(updated.status, JobState::RolledBack);
2386 }
2387
2388 #[test]
2391 fn list_transactions_returns_empty_for_fresh_store() {
2392 let dir = tempdir().unwrap();
2393 let store = test_store(dir.path().join("tx.db"));
2394 let results = store.list_transactions(10, None, None, None).unwrap();
2395 assert!(results.is_empty());
2396 }
2397
2398 #[test]
2399 fn list_transactions_returns_all_records_ordered_by_newest_first() {
2400 let dir = tempdir().unwrap();
2401 let store = test_store(dir.path().join("tx.db"));
2402 store.record(queued_transaction()).unwrap();
2403
2404 let mut second = queued_transaction();
2405 second.action_name = "GetDiskUsage".to_string();
2406 second.risk_level = RiskLevel::Low;
2407 store.record(second).unwrap();
2408
2409 let results = store.list_transactions(10, None, None, None).unwrap();
2410 assert_eq!(results.len(), 2);
2411 assert_eq!(results[0].action_name, "GetDiskUsage");
2413 assert_eq!(results[1].action_name, "UpdateSystem");
2414 }
2415
2416 #[test]
2417 fn list_history_populates_created_at_and_risk_level() {
2418 let dir = tempdir().unwrap();
2419 let store = test_store(dir.path().join("tx.db"));
2420 store.record(queued_transaction()).unwrap();
2421
2422 let entries = store.list_history(10, None, None, None).unwrap();
2423 assert_eq!(entries.len(), 1);
2424 let entry = &entries[0];
2425 assert_eq!(entry.action_name, "UpdateSystem");
2426 assert_eq!(entry.risk_level, RiskLevel::High);
2427 assert_eq!(entry.status, JobState::Queued);
2428 assert!(
2429 !entry.created_at.is_empty(),
2430 "created_at must be populated from the stored row, not left blank"
2431 );
2432 }
2433
2434 #[test]
2435 fn list_history_applies_the_same_filters_as_list_transactions() {
2436 let dir = tempdir().unwrap();
2437 let store = test_store(dir.path().join("tx.db"));
2438 store.record(queued_transaction()).unwrap();
2439 let mut low = queued_transaction();
2440 low.action_name = "GetDiskUsage".to_string();
2441 low.risk_level = RiskLevel::Low;
2442 store.record(low).unwrap();
2443
2444 let only = store
2445 .list_history(10, None, Some("GetDiskUsage"), None)
2446 .unwrap();
2447 assert_eq!(only.len(), 1);
2448 assert_eq!(only[0].action_name, "GetDiskUsage");
2449 }
2450
2451 #[test]
2452 fn list_transactions_respects_limit() {
2453 let dir = tempdir().unwrap();
2454 let store = test_store(dir.path().join("tx.db"));
2455 for _ in 0..5 {
2456 store.record(queued_transaction()).unwrap();
2457 }
2458 let results = store.list_transactions(3, None, None, None).unwrap();
2459 assert_eq!(results.len(), 3);
2460 }
2461
2462 #[test]
2463 fn list_transactions_filters_by_status() {
2464 let dir = tempdir().unwrap();
2465 let store = test_store(dir.path().join("tx.db"));
2466 let tx = store.record(queued_transaction()).unwrap();
2467 store
2468 .update_status(&tx.transaction_id, JobState::Running)
2469 .unwrap();
2470 store
2471 .update_status(&tx.transaction_id, JobState::Succeeded)
2472 .unwrap();
2473
2474 store.record(queued_transaction()).unwrap();
2476
2477 let succeeded = store
2478 .list_transactions(10, Some("succeeded"), None, None)
2479 .unwrap();
2480 assert_eq!(succeeded.len(), 1);
2481 assert_eq!(succeeded[0].status, JobState::Succeeded);
2482
2483 let queued = store
2484 .list_transactions(10, Some("queued"), None, None)
2485 .unwrap();
2486 assert_eq!(queued.len(), 1);
2487 assert_eq!(queued[0].status, JobState::Queued);
2488 }
2489
2490 #[test]
2491 fn list_transactions_filters_by_action_name() {
2492 let dir = tempdir().unwrap();
2493 let store = test_store(dir.path().join("tx.db"));
2494 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
2497 disk.action_name = "GetDiskUsage".to_string();
2498 store.record(disk).unwrap();
2499
2500 let results = store
2501 .list_transactions(10, None, Some("GetDiskUsage"), None)
2502 .unwrap();
2503 assert_eq!(results.len(), 1);
2504 assert_eq!(results[0].action_name, "GetDiskUsage");
2505 }
2506
2507 #[test]
2508 fn list_transactions_filters_by_since_hours() {
2509 let dir = tempdir().unwrap();
2510 let store = test_store(dir.path().join("tx.db"));
2511
2512 let old = store.record(queued_transaction()).unwrap();
2514 let conn = store.connection().unwrap();
2515 conn.execute(
2516 "UPDATE transactions \
2517 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
2518 WHERE transaction_id = ?1",
2519 params![old.transaction_id],
2520 )
2521 .unwrap();
2522
2523 store.record(queued_transaction()).unwrap();
2525
2526 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
2528 assert_eq!(results.len(), 1);
2529
2530 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
2532 assert_eq!(results.len(), 2);
2533 }
2534
2535 #[test]
2536 fn list_transactions_rejects_invalid_status_filter() {
2537 let dir = tempdir().unwrap();
2538 let store = test_store(dir.path().join("tx.db"));
2539 store.record(queued_transaction()).unwrap();
2540 let result = store.list_transactions(10, Some("bogus"), None, None);
2541 assert!(result.is_err(), "invalid status filter should return error");
2542 }
2543
2544 #[test]
2552 fn record_emits_one_watermark_per_entry() {
2553 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2554 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2555
2556 let dir = tempdir().unwrap();
2557 let store = test_store(dir.path().join("tx.db"));
2558 store.record(queued_transaction()).unwrap();
2559
2560 let calls = crate::audit_watermark::take_watermarks(&sink);
2561 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
2562 }
2563
2564 #[test]
2566 fn record_previewed_emits_one_watermark() {
2567 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2568 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2569
2570 let dir = tempdir().unwrap();
2571 let store = test_store(dir.path().join("tx.db"));
2572 let preview = PreviewEnvelope {
2573 summary: "Upgrade the system".to_string(),
2574 risk_level: RiskLevel::High,
2575 current_state: serde_json::Value::Null,
2576 proposed_change: serde_json::Value::Null,
2577 expected_side_effects: vec![],
2578 reboot_required: false,
2579 rollback_available: false,
2580 warnings: vec![],
2581 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
2582 };
2583 store
2584 .record_previewed(queued_transaction(), preview)
2585 .unwrap();
2586
2587 let calls = crate::audit_watermark::take_watermarks(&sink);
2588 assert_eq!(
2589 calls.len(),
2590 1,
2591 "expected exactly 1 watermark per record_previewed()"
2592 );
2593 }
2594
2595 #[test]
2597 fn watermark_seq_and_hash_match_chain_row() {
2598 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2599 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2600
2601 let dir = tempdir().unwrap();
2602 let store = test_store(dir.path().join("tx.db"));
2603 store.record(queued_transaction()).unwrap();
2604
2605 let rows = store.fetch_chain_rows().unwrap();
2606 assert_eq!(rows.len(), 1);
2607 let row = &rows[0];
2608
2609 let calls = crate::audit_watermark::take_watermarks(&sink);
2610 assert_eq!(calls.len(), 1);
2611 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
2612 assert_eq!(
2613 calls[0].chain_hash_hex, row.chain_hash,
2614 "watermark chain_hash_hex must match stored chain_hash"
2615 );
2616 }
2617
2618 #[test]
2620 fn multiple_records_produce_one_watermark_each() {
2621 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2622 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2623
2624 let dir = tempdir().unwrap();
2625 let store = test_store(dir.path().join("tx.db"));
2626 for _ in 0..3 {
2627 store.record(queued_transaction()).unwrap();
2628 }
2629
2630 let calls = crate::audit_watermark::take_watermarks(&sink);
2631 assert_eq!(calls.len(), 3, "one watermark per record call");
2632 assert_eq!(calls[0].seq, 1);
2633 assert_eq!(calls[1].seq, 2);
2634 assert_eq!(calls[2].seq, 3);
2635 }
2636
2637 #[test]
2651 fn no_watermark_emitted_before_any_record() {
2652 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2653 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2654
2655 let dir = tempdir().unwrap();
2656 let _store = test_store(dir.path().join("tx.db"));
2657
2658 let calls = crate::audit_watermark::take_watermarks(&sink);
2660 assert!(
2661 calls.is_empty(),
2662 "no watermark must be emitted without a record() call"
2663 );
2664 }
2665}