1use crate::audit_chain::{
2 self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent,
3 EventIdentity, EventRow, VerifyOutcome, CURRENT_KEY_ID,
4};
5use crate::audit_watermark::emit_chain_tip_watermark;
6use crate::auth::CallerPrincipal;
7use rusqlite::{params, Connection, TransactionBehavior};
8use serde::{de::DeserializeOwned, Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use subtle::ConstantTimeEq;
12use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RiskLevel, TransactionRecord};
13use uuid::Uuid;
14
15pub(crate) const APPROVAL_RECEIPT_TTL_MINUTES: i64 = 15;
26
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct JobHistoryEntry {
36 pub transaction_id: String,
37 pub action_name: String,
38 pub risk_level: RiskLevel,
39 pub status: JobState,
40 pub summary: String,
41 pub created_at: String,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum HistorySince {
53 Hours(u32),
55 Cutoff(chrono::DateTime<chrono::Utc>),
57}
58
59impl HistorySince {
60 pub fn cutoff_rfc3339(at: &chrono::DateTime<chrono::Utc>) -> String {
63 at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
64 }
65}
66
67#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct NewTransaction {
74 pub request_id: String,
75 pub request_hash: String,
76 pub action_name: String,
77 pub risk_level: RiskLevel,
78 pub summary: String,
98 pub warnings: Vec<String>,
99 pub caller_role: CallerRole,
108 pub caller_principal: CallerPrincipal,
116}
117
118struct SqliteMigration {
127 version: i64,
128 name: &'static str,
129 sql: &'static str,
130}
131
132const SQLITE_MIGRATIONS: &[SqliteMigration] = &[
150 SqliteMigration {
151 version: 1,
152 name: "initial_audit_schema",
153 sql: r#"
154 CREATE TABLE IF NOT EXISTS transactions (
155 transaction_id TEXT PRIMARY KEY,
156 request_id TEXT NOT NULL,
157 request_hash TEXT NOT NULL,
158 action_name TEXT NOT NULL,
159 risk_level TEXT NOT NULL,
160 status TEXT NOT NULL,
161 approval_id TEXT,
162 summary TEXT NOT NULL,
163 warnings_json TEXT NOT NULL,
164 created_at TEXT NOT NULL DEFAULT (datetime('now')),
165 seq INTEGER NOT NULL UNIQUE,
166 key_id TEXT NOT NULL,
167 chain_hash TEXT NOT NULL,
168 prev_chain_hash TEXT NOT NULL DEFAULT ''
169 );
170
171 CREATE TABLE IF NOT EXISTS transaction_previews (
172 transaction_id TEXT PRIMARY KEY,
173 preview_json TEXT NOT NULL
174 );
175
176 CREATE TABLE IF NOT EXISTS transaction_approvals (
177 transaction_id TEXT PRIMARY KEY,
178 receipt_digest TEXT NOT NULL,
179 approved_at TEXT NOT NULL DEFAULT (datetime('now')),
180 consumed_at TEXT
181 );
182
183 CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
184 "#,
185 },
186 SqliteMigration {
195 version: 2,
196 name: "caller_identity_and_approval_events",
197 sql: r#"
198 ALTER TABLE transactions ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
199 ALTER TABLE transactions ADD COLUMN caller_role TEXT;
200 ALTER TABLE transactions ADD COLUMN event_tip TEXT;
201
202 CREATE TABLE IF NOT EXISTS audit_events (
203 seq INTEGER PRIMARY KEY,
204 key_id TEXT NOT NULL,
205 kind TEXT NOT NULL,
206 transaction_id TEXT NOT NULL,
207 receipt_digest TEXT NOT NULL,
208 created_at TEXT NOT NULL,
209 chain_hash TEXT NOT NULL,
210 prev_chain_hash TEXT NOT NULL DEFAULT ''
211 );
212
213 CREATE INDEX IF NOT EXISTS audit_events_transaction_idx
214 ON audit_events(transaction_id);
215 "#,
216 },
217 SqliteMigration {
222 version: 3,
223 name: "caller_principal",
224 sql: r#"
225 ALTER TABLE transactions ADD COLUMN caller_principal TEXT;
226 "#,
227 },
228 SqliteMigration {
237 version: 4,
238 name: "event_approver_identity",
239 sql: r#"
240 ALTER TABLE audit_events ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
241 ALTER TABLE audit_events ADD COLUMN caller_principal TEXT;
242 "#,
243 },
244];
245
246use crate::store::CHAIN_ROW_COLUMNS;
250
251fn chain_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainRow> {
252 Ok(ChainRow {
253 seq: row.get::<_, i64>(0)? as u64,
254 key_id: row.get(1)?,
255 transaction_id: row.get(2)?,
256 request_id: row.get(3)?,
257 request_hash: row.get(4)?,
258 action_name: row.get(5)?,
259 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
260 rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
261 })?,
262 summary: row.get(7)?,
263 approval_id: row.get(8)?,
264 warnings_json: row.get(9)?,
265 created_at: row.get(10)?,
266 prev_chain_hash: row.get(11)?,
267 chain_hash: row.get(12)?,
268 chain_version: row.get::<_, i64>(13)? as u32,
269 caller_role: row.get(14)?,
270 event_tip: row.get(15)?,
271 caller_principal: row.get(16)?,
272 })
273}
274
275fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<EventRow> {
276 Ok(EventRow {
277 seq: row.get::<_, i64>(0)? as u64,
278 key_id: row.get(1)?,
279 kind: row.get(2)?,
280 transaction_id: row.get(3)?,
281 receipt_digest: row.get(4)?,
282 created_at: row.get(5)?,
283 prev_chain_hash: row.get(6)?,
284 chain_hash: row.get(7)?,
285 chain_version: row.get::<_, i64>(8)? as u32,
286 caller_principal: row.get(9)?,
287 })
288}
289
290fn query_optional<T>(
296 conn: &Connection,
297 sql: &str,
298 params: impl rusqlite::Params,
299 f: impl FnOnce(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
300) -> rusqlite::Result<Option<T>> {
301 match conn.query_row(sql, params, f) {
302 Ok(v) => Ok(Some(v)),
303 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
304 Err(e) => Err(e),
305 }
306}
307
308#[derive(Clone, Debug)]
309pub struct TransactionStore {
310 path: PathBuf,
311 audit_key: Option<Arc<AuditKey>>,
314}
315
316#[derive(Clone, Debug, PartialEq)]
317pub struct RecordedPreviewedTransaction {
318 pub transaction: TransactionRecord,
319 pub preview: PreviewEnvelope,
320}
321
322struct InsertedTransaction {
323 record: TransactionRecord,
324 seq: u64,
325 chain_hash: String,
326}
327
328#[derive(Debug, thiserror::Error)]
329pub enum TransactionStoreError {
330 #[error("io error: {0}")]
331 Io(#[from] std::io::Error),
332
333 #[error("sqlite error: {0}")]
334 Sqlite(#[from] rusqlite::Error),
335
336 #[error("json error: {0}")]
337 Json(#[from] serde_json::Error),
338
339 #[error("database invariant violation: {0}")]
340 DatabaseInvariant(String),
341
342 #[error("transaction not found: {0}")]
343 NotFound(String),
344
345 #[error("invalid transition from {from:?} to {to:?}")]
346 InvalidTransition { from: JobState, to: JobState },
347
348 #[error("audit chain misconfiguration: {0}")]
349 AuditChainMissing(&'static str),
350
351 #[error("transaction {0} status changed concurrently; retry")]
359 ConcurrentStatusChange(String),
360}
361
362impl TransactionStore {
363 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
367 let path = path.as_ref().to_path_buf();
368 if let Some(parent) = path.parent() {
369 ensure_private_dir(parent)?;
370 }
371
372 let store = Self {
373 path,
374 audit_key: None,
375 };
376 store.initialize()?;
377 Ok(store)
378 }
379
380 pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
387 let db_path = path.as_ref();
388 let key_path = crate::audit_chain::resolve_audit_key_path(db_path);
389 let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
390 TransactionStoreError::Io(std::io::Error::new(
391 std::io::ErrorKind::InvalidData,
392 format!("audit key load failed: {e}"),
393 ))
394 })?;
395 Self::open_with_key(path, Arc::new(key))
396 }
397
398 pub fn open_with_key(
401 path: impl AsRef<Path>,
402 audit_key: Arc<AuditKey>,
403 ) -> Result<Self, TransactionStoreError> {
404 let path = path.as_ref().to_path_buf();
405 if let Some(parent) = path.parent() {
406 ensure_private_dir(parent)?;
407 }
408
409 let store = Self {
410 path,
411 audit_key: Some(audit_key),
412 };
413 store.initialize()?;
414 restrict_db_file_mode(&store.path);
415 Ok(store)
416 }
417
418 fn record_with(
427 &self,
428 transaction: NewTransaction,
429 in_tx: impl FnOnce(&Connection, &InsertedTransaction) -> Result<(), TransactionStoreError>,
430 ) -> Result<InsertedTransaction, TransactionStoreError> {
431 let key = self
432 .audit_key
433 .as_ref()
434 .ok_or(TransactionStoreError::AuditChainMissing(
435 "this TransactionStore was opened read-only; cannot record",
436 ))?;
437 let mut conn = self.connection()?;
438 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
443 let transaction_id = Uuid::new_v4().to_string();
444 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
445 in_tx(&tx, &inserted)?;
446 tx.commit()?;
447 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
448 Ok(inserted)
449 }
450
451 pub fn record(
452 &self,
453 transaction: NewTransaction,
454 ) -> Result<TransactionRecord, TransactionStoreError> {
455 let inserted = self.record_with(transaction, |_, _| Ok(()))?;
456 Ok(inserted.record)
457 }
458
459 pub fn record_previewed(
460 &self,
461 transaction: NewTransaction,
462 preview: PreviewEnvelope,
463 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
464 let inserted = self.record_with(transaction, |conn, inserted| {
465 Self::insert_preview(conn, &inserted.record.transaction_id, &preview)
466 })?;
467 Ok(RecordedPreviewedTransaction {
468 transaction: inserted.record,
469 preview,
470 })
471 }
472
473 pub fn get(
474 &self,
475 transaction_id: &str,
476 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
477 let conn = self.connection()?;
478 let mut stmt = conn.prepare(
479 "SELECT
480 transaction_id,
481 request_id,
482 request_hash,
483 action_name,
484 risk_level,
485 status,
486 approval_id,
487 summary,
488 warnings_json
489 FROM transactions
490 WHERE transaction_id = ?1",
491 )?;
492 let mut rows = stmt.query(params![transaction_id])?;
493 if let Some(row) = rows.next()? {
494 Ok(Some(row_to_record(row)?))
495 } else {
496 Ok(None)
497 }
498 }
499
500 pub fn get_preview(
501 &self,
502 transaction_id: &str,
503 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
504 let conn = self.connection()?;
505 let mut stmt = conn.prepare(
506 "SELECT preview_json
507 FROM transaction_previews
508 WHERE transaction_id = ?1",
509 )?;
510 let mut rows = stmt.query(params![transaction_id])?;
511 if let Some(row) = rows.next()? {
512 let preview_json: String = row.get(0)?;
513 Ok(Some(serde_json::from_str(&preview_json)?))
514 } else {
515 Ok(None)
516 }
517 }
518
519 pub fn update_status(
520 &self,
521 transaction_id: &str,
522 new_status: JobState,
523 ) -> Result<(), TransactionStoreError> {
524 let conn = self.connection()?;
525
526 let current_status: String = conn
528 .query_row(
529 "SELECT status FROM transactions WHERE transaction_id = ?1",
530 params![transaction_id],
531 |row| row.get(0),
532 )
533 .map_err(|e| match e {
534 rusqlite::Error::QueryReturnedNoRows => {
535 TransactionStoreError::NotFound(transaction_id.to_string())
536 }
537 other => TransactionStoreError::Sqlite(other),
538 })?;
539
540 let current: JobState = deserialize_field(¤t_status)?;
541 if !crate::jobs::allowed_transition(¤t, &new_status) {
542 return Err(TransactionStoreError::InvalidTransition {
543 from: current,
544 to: new_status,
545 });
546 }
547
548 let mut conn = conn;
565 let tx = conn.transaction()?;
566 let rows_affected = tx.execute(
567 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
568 params![
569 serialize_field(&new_status)?,
570 transaction_id,
571 current_status
572 ],
573 )?;
574 if rows_affected == 0 {
575 return Err(TransactionStoreError::ConcurrentStatusChange(
576 transaction_id.to_string(),
577 ));
578 }
579 let Some(key) = self.audit_key.as_ref() else {
585 return Err(TransactionStoreError::AuditChainMissing(
586 "update_status has no signing key, so the outcome could not be chained",
587 ));
588 };
589 Self::append_event(
590 &tx,
591 key,
592 AuditEventKind::from(new_status),
593 transaction_id,
594 "",
595 EventIdentity::LegacyV1,
599 )?;
600 tx.commit()?;
601 Ok(())
602 }
603
604 pub fn status_matches_chain(
617 &self,
618 ) -> Result<audit_chain::StatusOutcome, TransactionStoreError> {
619 let conn = self.connection()?;
620 let mut stmt = conn.prepare(
621 "SELECT t.transaction_id, t.status, ( \
622 SELECT e.kind FROM audit_events e \
623 WHERE e.transaction_id = t.transaction_id AND e.kind LIKE 'status_%' \
624 ORDER BY e.seq DESC LIMIT 1 \
625 ), EXISTS ( \
626 SELECT 1 FROM audit_events e \
627 WHERE e.transaction_id = t.transaction_id AND e.kind = 'approval_consumed' \
628 ) FROM transactions t",
629 )?;
630 let mut disagreements = Vec::new();
631 let mut unproven = Vec::new();
632 let mut checked: u64 = 0;
633 let rows = stmt.query_map([], |row| {
634 Ok((
635 row.get::<_, String>(0)?,
636 row.get::<_, String>(1)?,
637 row.get::<_, Option<String>>(2)?,
638 row.get::<_, bool>(3)?,
639 ))
640 })?;
641 for row in rows {
642 let (id, status_json, newest, consumed) = row?;
643 checked += 1;
644 let state: JobState = deserialize_field(&status_json)?;
645 let Some(newest) = newest else {
650 if state == JobState::Queued && consumed {
651 disagreements.push(format!(
652 "{id}: status is Queued but a chained approval_consumed event proves it left Queued"
653 ));
654 } else if matches!(state, JobState::Running | JobState::Canceled) {
655 unproven.push(format!(
656 "{id}: status is {state:?} and no status event was ever chained; \
657 historical writes cannot establish the execution outcome"
658 ));
659 } else if state != JobState::Queued {
660 disagreements.push(format!(
661 "{id}: status is {state:?} but no status event was ever chained; \
662 terminal outcomes require a signed status event"
663 ));
664 }
665 continue;
666 };
667 let expected = AuditEventKind::from(state).as_str();
668 if newest != expected {
669 disagreements.push(format!(
670 "{id}: status column says {state:?} ({expected}) and the newest \
671 chained status event is {newest}"
672 ));
673 }
674 }
675 Ok(if !disagreements.is_empty() {
676 audit_chain::StatusOutcome::Disagrees {
677 detail: disagreements.join("; "),
678 }
679 } else if !unproven.is_empty() {
680 audit_chain::StatusOutcome::CannotVerify {
681 detail: unproven.join("; "),
682 }
683 } else {
684 audit_chain::StatusOutcome::Agrees {
685 rows_checked: checked,
686 }
687 })
688 }
689
690 pub fn approve_transaction(
695 &self,
696 transaction_id: &str,
697 approver: CallerPrincipal,
698 ) -> Result<Option<String>, TransactionStoreError> {
699 let key = self
700 .audit_key
701 .as_ref()
702 .ok_or(TransactionStoreError::AuditChainMissing(
703 "this TransactionStore was opened read-only; cannot approve",
704 ))?;
705 let approver_signed = approver.as_signed_str();
708 let Some(record) = self.get(transaction_id)? else {
709 return Ok(None);
710 };
711 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
712 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
713 let Some(committed_digest) = record.approval_id.as_deref() else {
714 return Err(TransactionStoreError::DatabaseInvariant(format!(
715 "transaction {transaction_id} has no signed approval commitment"
716 )));
717 };
718 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
719 return Err(TransactionStoreError::DatabaseInvariant(format!(
720 "transaction {transaction_id} approval commitment does not match its signed preview"
721 )));
722 }
723
724 let mut conn = self.connection()?;
727 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
728 let queued_json = serialize_field(&JobState::Queued)?;
729 let rows_affected = tx.execute(
730 &format!(
731 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
732 SELECT transaction_id, ?1 FROM transactions \
733 WHERE transaction_id = ?2 \
734 AND status = ?3 \
735 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
736 AND NOT EXISTS ( \
737 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
738 )"
739 ),
740 params![receipt_digest, transaction_id, queued_json],
741 )?;
742 if rows_affected > 0 {
743 Self::append_event(
744 &tx,
745 key,
746 AuditEventKind::ApprovalGranted,
747 transaction_id,
748 &receipt_digest,
749 EventIdentity::V2 {
750 caller_principal: &approver_signed,
751 },
752 )?;
753 }
754 tx.commit()?;
755 Ok((rows_affected > 0).then_some(receipt))
756 }
757
758 pub fn revoke_unconsumed_approval(
764 &self,
765 transaction_id: &str,
766 revoker: CallerPrincipal,
767 ) -> Result<bool, TransactionStoreError> {
768 let key = self
769 .audit_key
770 .as_ref()
771 .ok_or(TransactionStoreError::AuditChainMissing(
772 "this TransactionStore was opened read-only; cannot revoke",
773 ))?;
774 let mut conn = self.connection()?;
775 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
776 let revoked = Self::revoke_unconsumed_approval_in_tx(
777 &tx,
778 key,
779 transaction_id,
780 &revoker.as_signed_str(),
781 )?;
782 tx.commit()?;
783 Ok(revoked)
784 }
785
786 fn revoke_unconsumed_approval_in_tx(
787 conn: &Connection,
788 key: &AuditKey,
789 transaction_id: &str,
790 revoker_signed: &str,
791 ) -> Result<bool, TransactionStoreError> {
792 let digest: Option<String> = query_optional(
795 conn,
796 "SELECT receipt_digest FROM transaction_approvals \
797 WHERE transaction_id = ?1 AND consumed_at IS NULL",
798 params![transaction_id],
799 |row| row.get(0),
800 )?;
801 let rows_affected = conn.execute(
802 "DELETE FROM transaction_approvals \
803 WHERE transaction_id = ?1 AND consumed_at IS NULL",
804 params![transaction_id],
805 )?;
806 if rows_affected > 0 {
807 let digest = digest.ok_or_else(|| {
808 TransactionStoreError::DatabaseInvariant(format!(
809 "revoked an approval for {transaction_id} that had no receipt digest"
810 ))
811 })?;
812 Self::append_event(
813 conn,
814 key,
815 AuditEventKind::ApprovalRevoked,
816 transaction_id,
817 &digest,
818 EventIdentity::V2 {
819 caller_principal: revoker_signed,
820 },
821 )?;
822 }
823 Ok(rows_affected > 0)
824 }
825
826 pub fn claim_approved_for_execution(
832 &self,
833 transaction_id: &str,
834 receipt_digest: &str,
835 executor: CallerPrincipal,
836 ) -> Result<bool, TransactionStoreError> {
837 let key = self
838 .audit_key
839 .as_ref()
840 .ok_or(TransactionStoreError::AuditChainMissing(
841 "this TransactionStore was opened read-only; cannot claim",
842 ))?;
843 let mut conn = self.connection()?;
844 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
845 let queued_json = serialize_field(&JobState::Queued)?;
846 let running_json = serialize_field(&JobState::Running)?;
847 let rows_affected = tx.execute(
848 &format!(
849 "UPDATE transactions SET status = ?1 \
850 WHERE transaction_id = ?2 \
851 AND status = ?3 \
852 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
853 AND EXISTS ( \
854 SELECT 1 FROM transaction_approvals \
855 WHERE transaction_id = ?2 \
856 AND receipt_digest = ?4 \
857 AND consumed_at IS NULL \
858 )"
859 ),
860 params![running_json, transaction_id, queued_json, receipt_digest],
861 )?;
862 if rows_affected > 0 {
863 tx.execute(
864 "UPDATE transaction_approvals \
865 SET consumed_at = datetime('now') \
866 WHERE transaction_id = ?1 AND consumed_at IS NULL",
867 params![transaction_id],
868 )?;
869 Self::append_event(
870 &tx,
871 key,
872 AuditEventKind::ApprovalConsumed,
873 transaction_id,
874 receipt_digest,
875 EventIdentity::V2 {
876 caller_principal: &executor.as_signed_str(),
877 },
878 )?;
879 Self::append_event(
880 &tx,
881 key,
882 AuditEventKind::StatusRunning,
883 transaction_id,
884 "",
885 EventIdentity::LegacyV1,
886 )?;
887 }
888 tx.commit()?;
889 Ok(rows_affected > 0)
890 }
891
892 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
907 let key = self
908 .audit_key
909 .as_ref()
910 .ok_or(TransactionStoreError::AuditChainMissing(
911 "this TransactionStore was opened read-only; cannot clean up",
912 ))?;
913 let mut conn = self.connection()?;
914 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
915 let canceled_json = serialize_field(&JobState::Canceled)?;
916 let queued_json = serialize_field(&JobState::Queued)?;
917 let stale_ids: Vec<String> = {
918 let mut statement = tx.prepare(&format!(
919 "SELECT transaction_id FROM transactions \
920 WHERE status = ?1 \
921 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
922 ))?;
923 let stale_ids = statement
924 .query_map(params![queued_json], |row| row.get(0))?
925 .collect::<Result<_, _>>()?;
926 stale_ids
927 };
928 let mut canceled = 0;
929 for transaction_id in stale_ids {
930 let rows_affected = tx.execute(
931 "UPDATE transactions SET status = ?1 \
932 WHERE transaction_id = ?2 AND status = ?3",
933 params![canceled_json, transaction_id, queued_json],
934 )?;
935 if rows_affected > 0 {
936 Self::revoke_unconsumed_approval_in_tx(
940 &tx,
941 key,
942 &transaction_id,
943 &CallerPrincipal::Unattributed.as_signed_str(),
944 )?;
945 Self::append_event(
946 &tx,
947 key,
948 AuditEventKind::StatusCanceled,
949 &transaction_id,
950 "",
951 EventIdentity::LegacyV1,
952 )?;
953 canceled += rows_affected;
954 }
955 }
956 tx.commit()?;
957 Ok(canceled as u64)
958 }
959
960 pub fn cancel_queued(
969 &self,
970 transaction_id: &str,
971 canceller: CallerPrincipal,
972 ) -> Result<bool, TransactionStoreError> {
973 let key = self
974 .audit_key
975 .as_ref()
976 .ok_or(TransactionStoreError::AuditChainMissing(
977 "this TransactionStore was opened read-only; cannot cancel",
978 ))?;
979 let mut conn = self.connection()?;
980 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
981 let canceled_json = serialize_field(&JobState::Canceled)?;
982 let queued_json = serialize_field(&JobState::Queued)?;
983 let rows_affected = tx.execute(
984 "UPDATE transactions SET status = ?1 \
985 WHERE transaction_id = ?2 AND status = ?3",
986 params![canceled_json, transaction_id, queued_json],
987 )?;
988 if rows_affected > 0 {
989 Self::revoke_unconsumed_approval_in_tx(
994 &tx,
995 key,
996 transaction_id,
997 &canceller.as_signed_str(),
998 )?;
999 Self::append_event(
1000 &tx,
1001 key,
1002 AuditEventKind::StatusCanceled,
1003 transaction_id,
1004 "",
1005 EventIdentity::LegacyV1,
1006 )?;
1007 }
1008 tx.commit()?;
1009 Ok(rows_affected > 0)
1010 }
1011
1012 pub fn list_transactions(
1023 &self,
1024 limit: u32,
1025 status_filter: Option<&str>,
1026 action_filter: Option<&str>,
1027 since: Option<HistorySince>,
1028 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
1029 let conn = self.connection()?;
1030 let (filter_sql, param_values) =
1031 Self::build_history_filter(limit, status_filter, action_filter, since)?;
1032 let sql = format!(
1033 "SELECT transaction_id, request_id, request_hash, action_name, \
1034 risk_level, status, approval_id, summary, warnings_json \
1035 FROM transactions WHERE 1=1{filter_sql}"
1036 );
1037 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
1038 param_values.iter().map(|b| b.as_ref()).collect();
1039
1040 let mut stmt = conn.prepare(&sql)?;
1041 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
1042
1043 let mut results = Vec::new();
1044 for row in rows {
1045 results.push(row??);
1046 }
1047 Ok(results)
1048 }
1049
1050 pub fn list_history(
1055 &self,
1056 limit: u32,
1057 status_filter: Option<&str>,
1058 action_filter: Option<&str>,
1059 since: Option<HistorySince>,
1060 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
1061 let conn = self.connection()?;
1062 let (filter_sql, param_values) =
1063 Self::build_history_filter(limit, status_filter, action_filter, since)?;
1064 let sql = format!(
1065 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
1066 FROM transactions WHERE 1=1{filter_sql}"
1067 );
1068 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
1069 param_values.iter().map(|b| b.as_ref()).collect();
1070
1071 let mut stmt = conn.prepare(&sql)?;
1072 let rows = stmt.query_map(params_ref.as_slice(), |row| {
1073 Ok((|| {
1074 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
1075 transaction_id: row.get(0)?,
1076 action_name: row.get(1)?,
1077 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
1078 status: deserialize_field(&row.get::<_, String>(3)?)?,
1079 summary: row.get(4)?,
1080 created_at: row.get(5)?,
1081 })
1082 })())
1083 })?;
1084
1085 let mut results = Vec::new();
1086 for row in rows {
1087 results.push(row??);
1088 }
1089 Ok(results)
1090 }
1091
1092 fn build_history_filter(
1097 limit: u32,
1098 status_filter: Option<&str>,
1099 action_filter: Option<&str>,
1100 since: Option<HistorySince>,
1101 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
1102 let mut sql = String::new();
1103 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1104
1105 if let Some(status) = status_filter {
1106 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
1110 let status_json = serialize_field(&validated)?;
1111 sql.push_str(" AND status = ?");
1112 param_values.push(Box::new(status_json));
1113 }
1114
1115 if let Some(action) = action_filter {
1116 sql.push_str(" AND action_name = ?");
1117 param_values.push(Box::new(action.to_string()));
1118 }
1119
1120 match since {
1123 Some(HistorySince::Hours(hours)) => {
1124 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
1125 param_values.push(Box::new(hours));
1126 }
1127 Some(HistorySince::Cutoff(at)) => {
1128 sql.push_str(" AND julianday(created_at) >= julianday(?)");
1129 param_values.push(Box::new(HistorySince::cutoff_rfc3339(&at)));
1130 }
1131 None => {}
1132 }
1133
1134 sql.push_str(" ORDER BY seq DESC LIMIT ?");
1135 param_values.push(Box::new(limit.min(100)));
1136
1137 Ok((sql, param_values))
1138 }
1139
1140 fn connection(&self) -> Result<Connection, TransactionStoreError> {
1141 let conn = Connection::open(&self.path)?;
1142 conn.pragma_update(None, "journal_mode", "WAL")?;
1153 conn.pragma_update(None, "synchronous", "NORMAL")?;
1154 conn.pragma_update(None, "foreign_keys", "ON")?;
1155 conn.busy_timeout(std::time::Duration::from_secs(5))?;
1156 Ok(conn)
1157 }
1158
1159 fn initialize(&self) -> Result<(), TransactionStoreError> {
1160 let mut conn = self.connection()?;
1161 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
1162 tx.execute_batch(
1163 "CREATE TABLE IF NOT EXISTS schema_migrations (\
1164 version INTEGER PRIMARY KEY,\
1165 name TEXT NOT NULL,\
1166 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
1167 );",
1168 )?;
1169 let current: i64 = tx.query_row(
1170 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
1171 [],
1172 |row| row.get(0),
1173 )?;
1174 let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
1175 if current > latest {
1176 return Err(TransactionStoreError::DatabaseInvariant(format!(
1177 "sqlite schema version {current} is newer than this binary supports ({latest})"
1178 )));
1179 }
1180 for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
1181 tx.execute_batch(migration.sql)?;
1182 tx.execute(
1183 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1184 params![migration.version, migration.name],
1185 )?;
1186 }
1187 tx.commit()?;
1188 Ok(())
1189 }
1190
1191 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
1193 let conn = self.connection()?;
1194 let mut stmt = conn.prepare(&format!(
1195 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
1196 ))?;
1197 let rows = stmt.query_map([], chain_row_from_sqlite)?;
1198 let mut out = Vec::new();
1199 for row in rows {
1200 out.push(row?);
1201 }
1202 Ok(out)
1203 }
1204
1205 pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
1207 let conn = self.connection()?;
1208 let mut stmt = conn.prepare(
1209 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
1210 created_at, prev_chain_hash, chain_hash, chain_version, caller_principal \
1211 FROM audit_events ORDER BY seq ASC",
1212 )?;
1213 let rows = stmt.query_map([], event_row_from_sqlite)?;
1214 let mut out = Vec::new();
1215 for row in rows {
1216 out.push(row?);
1217 }
1218 Ok(out)
1219 }
1220
1221 pub fn verify_audit_chain(
1223 &self,
1224 key: &AuditKey,
1225 ) -> Result<VerifyOutcome, TransactionStoreError> {
1226 let rows = self.fetch_chain_rows()?;
1227 Ok(audit_chain::verify_chain(key, &rows))
1228 }
1229
1230 pub fn verify_event_chain(
1232 &self,
1233 key: &AuditKey,
1234 ) -> Result<VerifyOutcome, TransactionStoreError> {
1235 let rows = self.fetch_event_rows()?;
1236 Ok(audit_chain::verify_event_chain(key, &rows))
1237 }
1238
1239 pub fn verify_event_binding(
1242 &self,
1243 ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
1244 let tx_rows = self.fetch_chain_rows()?;
1245 let event_rows = self.fetch_event_rows()?;
1246 Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
1247 }
1248
1249 pub fn verify_audit_chain_with_pubkey(
1252 &self,
1253 verifying_key_hex: &str,
1254 ) -> Result<VerifyOutcome, TransactionStoreError> {
1255 let rows = self.fetch_chain_rows()?;
1256 Ok(audit_chain::verify_chain_with_pubkey(
1257 verifying_key_hex,
1258 &rows,
1259 ))
1260 }
1261
1262 pub fn fetch_chain_row(
1265 &self,
1266 transaction_id: &str,
1267 ) -> Result<Option<ChainRow>, TransactionStoreError> {
1268 let conn = self.connection()?;
1269 let mut stmt = conn.prepare(&format!(
1270 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
1271 ))?;
1272 let mut rows = stmt.query(params![transaction_id])?;
1273 if let Some(row) = rows.next()? {
1274 Ok(Some(chain_row_from_sqlite(row)?))
1275 } else {
1276 Ok(None)
1277 }
1278 }
1279
1280 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
1284 let prev: Option<(i64, String)> = query_optional(
1285 conn,
1286 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
1287 [],
1288 |row| Ok((row.get(0)?, row.get(1)?)),
1289 )?;
1290 Ok(match prev {
1291 Some((seq, hash)) => ((seq as u64) + 1, hash),
1292 None => (1, String::new()),
1293 })
1294 }
1295
1296 fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
1299 Ok(query_optional(
1300 conn,
1301 "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
1302 [],
1303 |row| row.get(0),
1304 )?)
1305 }
1306
1307 fn append_event(
1312 conn: &Connection,
1313 key: &AuditKey,
1314 kind: AuditEventKind,
1315 transaction_id: &str,
1316 receipt_digest: &str,
1317 identity: EventIdentity<'_>,
1318 ) -> Result<(), TransactionStoreError> {
1319 let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1320 let seq: i64 = conn.query_row(
1321 "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1322 [],
1323 |row| row.get(0),
1324 )?;
1325 let created_at: String =
1326 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1327 row.get(0)
1328 })?;
1329 let key_id = CURRENT_KEY_ID.to_string();
1330 let content = EventContent {
1335 seq: seq as u64,
1336 key_id: &key_id,
1337 kind,
1338 transaction_id,
1339 receipt_digest,
1340 created_at: &created_at,
1341 identity,
1342 };
1343 let chain_hash = key.event_hash(&content, &prev_chain_hash);
1344 let stored_principal = match identity {
1345 EventIdentity::V2 { caller_principal } => Some(caller_principal),
1346 EventIdentity::LegacyV1 => None,
1347 };
1348 conn.execute(
1349 "INSERT INTO audit_events (\
1350 seq, key_id, kind, transaction_id, receipt_digest, \
1351 created_at, chain_hash, prev_chain_hash, chain_version, caller_principal \
1352 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1353 params![
1354 seq,
1355 key_id,
1356 kind.as_str(),
1357 transaction_id,
1358 receipt_digest,
1359 created_at,
1360 chain_hash,
1361 prev_chain_hash,
1362 identity.version() as i64,
1363 stored_principal,
1364 ],
1365 )?;
1366 Ok(())
1367 }
1368
1369 fn insert_transaction(
1370 conn: &Connection,
1371 key: &AuditKey,
1372 transaction_id: &str,
1373 transaction: NewTransaction,
1374 ) -> Result<InsertedTransaction, TransactionStoreError> {
1375 let request_id = transaction.request_id;
1376 let request_hash = transaction.request_hash;
1377 let action_name = transaction.action_name;
1378 let risk_level = transaction.risk_level;
1379 let status = JobState::Queued;
1381 let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1388 let summary = transaction.summary;
1389 let warnings = transaction.warnings;
1390 let warnings_json = serde_json::to_string(&warnings)?;
1391
1392 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1395
1396 let created_at: String =
1400 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1401 row.get(0)
1402 })?;
1403
1404 let key_id = CURRENT_KEY_ID.to_string();
1405 let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1409 let caller_role = transaction.caller_role.as_str();
1410 let caller_principal = transaction.caller_principal.as_signed_str();
1411 let identity = ChainIdentity::V4 {
1415 caller_role,
1416 event_tip: &event_tip,
1417 caller_principal: &caller_principal,
1418 };
1419 let chain_hash = key.chain_hash(
1420 &ChainContent {
1421 seq,
1422 key_id: &key_id,
1423 transaction_id,
1424 request_id: &request_id,
1425 request_hash: &request_hash,
1426 action_name: &action_name,
1427 risk_level,
1428 summary: &summary,
1429 approval_id: approval_id.as_deref(),
1430 warnings_json: &warnings_json,
1431 created_at: &created_at,
1432 identity,
1433 },
1434 &prev_chain_hash,
1435 );
1436
1437 let record = TransactionRecord {
1438 transaction_id: transaction_id.to_string(),
1439 request_id: request_id.clone(),
1440 request_hash: request_hash.clone(),
1441 action_name: action_name.clone(),
1442 risk_level,
1443 status,
1444 approval_id: approval_id.clone(),
1445 summary: summary.clone(),
1446 warnings: warnings.clone(),
1447 };
1448
1449 conn.execute(
1450 "INSERT INTO transactions (
1451 transaction_id,
1452 request_id,
1453 request_hash,
1454 action_name,
1455 risk_level,
1456 status,
1457 approval_id,
1458 summary,
1459 warnings_json,
1460 created_at,
1461 seq,
1462 key_id,
1463 chain_hash,
1464 prev_chain_hash,
1465 chain_version,
1466 caller_role,
1467 event_tip,
1468 caller_principal
1469 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1470 params![
1471 transaction_id,
1472 request_id,
1473 request_hash,
1474 action_name,
1475 serialize_field(&risk_level)?,
1476 serialize_field(&status)?,
1477 approval_id,
1478 summary,
1479 warnings_json,
1480 created_at,
1481 seq as i64,
1482 key_id,
1483 chain_hash,
1484 prev_chain_hash,
1485 identity.version() as i64,
1486 caller_role,
1487 event_tip,
1488 caller_principal,
1489 ],
1490 )?;
1491
1492 Ok(InsertedTransaction {
1493 record,
1494 seq,
1495 chain_hash,
1496 })
1497 }
1498
1499 fn insert_preview(
1500 conn: &Connection,
1501 transaction_id: &str,
1502 preview: &PreviewEnvelope,
1503 ) -> Result<(), TransactionStoreError> {
1504 conn.execute(
1505 "INSERT INTO transaction_previews (transaction_id, preview_json)
1506 VALUES (?1, ?2)",
1507 params![transaction_id, serde_json::to_string(preview)?],
1508 )?;
1509 Ok(())
1510 }
1511}
1512
1513fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1520 use std::os::unix::fs::DirBuilderExt;
1521 if dir.exists() {
1522 return Ok(());
1523 }
1524 std::fs::DirBuilder::new()
1525 .recursive(true)
1526 .mode(0o700)
1527 .create(dir)
1528}
1529
1530fn restrict_db_file_mode(db_path: &Path) {
1544 use std::os::unix::fs::PermissionsExt;
1545
1546 let sidecars = [
1550 db_path.to_path_buf(),
1551 with_suffix(db_path, "-wal"),
1552 with_suffix(db_path, "-shm"),
1553 ];
1554 for path in sidecars {
1555 let Ok(metadata) = std::fs::metadata(&path) else {
1556 continue; };
1558 let mode = metadata.permissions().mode() & 0o777;
1559 if mode & 0o077 == 0 {
1560 continue; }
1562 if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
1563 eprintln!(
1564 "[sysknife-daemon] WARNING: {} is mode {mode:o} and could not be \
1565 narrowed to 0600 ({err}); it is readable by anyone who can reach \
1566 it. The parent directory should be 0700 — check it.",
1567 path.display()
1568 );
1569 }
1570 }
1571}
1572
1573fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
1576 let mut name = path.as_os_str().to_os_string();
1577 name.push(suffix);
1578 PathBuf::from(name)
1579}
1580
1581fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1582 Ok(TransactionRecord {
1583 transaction_id: row.get(0)?,
1584 request_id: row.get(1)?,
1585 request_hash: row.get(2)?,
1586 action_name: row.get(3)?,
1587 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1588 status: deserialize_field(&row.get::<_, String>(5)?)?,
1589 approval_id: row.get(6)?,
1590 summary: row.get(7)?,
1591 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1592 })
1593}
1594
1595fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1596 serde_json::to_string(value)
1597}
1598
1599fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1600 serde_json::from_str(value)
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605 use super::*;
1606 use crate::audit_chain::CHAIN_VERSION_CURRENT;
1607 use crate::audit_chain::CHAIN_VERSION_LEGACY;
1608 use crate::audit_chain::EVENT_VERSION_CURRENT;
1609 use crate::audit_chain::EVENT_VERSION_LEGACY;
1610 use std::os::unix::fs::PermissionsExt;
1611 use tempfile::tempdir;
1612
1613 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1617 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1618 TransactionStore::open_with_key(path, key).unwrap()
1619 }
1620
1621 fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1628 let conn = Connection::open(path).unwrap();
1629 conn.execute_batch(
1630 "CREATE TABLE schema_migrations (
1631 version INTEGER PRIMARY KEY,
1632 name TEXT NOT NULL,
1633 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1634 );",
1635 )
1636 .unwrap();
1637 conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1638 conn.execute(
1639 "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1640 params![SQLITE_MIGRATIONS[0].name],
1641 )
1642 .unwrap();
1643
1644 let transaction_id = "tx-legacy";
1645 let created_at = "2026-04-24T12:00:00.000Z";
1646 let chain_hash = key.chain_hash(
1647 &ChainContent {
1648 seq: 1,
1649 key_id: CURRENT_KEY_ID,
1650 transaction_id,
1651 request_id: "req-legacy",
1652 request_hash: "hash-legacy",
1653 action_name: "UpdateSystem",
1654 risk_level: RiskLevel::High,
1655 summary: "Upgrade the system",
1656 approval_id: None,
1657 warnings_json: "[]",
1658 created_at,
1659 identity: ChainIdentity::LegacyV1,
1660 },
1661 "",
1662 );
1663 conn.execute(
1664 "INSERT INTO transactions (
1665 transaction_id, request_id, request_hash, action_name, risk_level,
1666 status, approval_id, summary, warnings_json, created_at,
1667 seq, key_id, chain_hash, prev_chain_hash
1668 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1669 params![
1670 transaction_id,
1671 "req-legacy",
1672 "hash-legacy",
1673 "UpdateSystem",
1674 serialize_field(&RiskLevel::High).unwrap(),
1675 serialize_field(&JobState::Queued).unwrap(),
1676 "Upgrade the system",
1677 "[]",
1678 created_at,
1679 CURRENT_KEY_ID,
1680 chain_hash,
1681 ],
1682 )
1683 .unwrap();
1684 transaction_id.to_string()
1685 }
1686
1687 fn v2_database(path: &Path, key: &AuditKey) -> String {
1694 let conn = Connection::open(path).unwrap();
1695 conn.execute_batch(
1696 "CREATE TABLE schema_migrations (
1697 version INTEGER PRIMARY KEY,
1698 name TEXT NOT NULL,
1699 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1700 );",
1701 )
1702 .unwrap();
1703 for migration in &SQLITE_MIGRATIONS[0..=1] {
1704 conn.execute_batch(migration.sql).unwrap();
1705 conn.execute(
1706 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1707 params![migration.version, migration.name],
1708 )
1709 .unwrap();
1710 }
1711
1712 let transaction_id = "tx-v2";
1713 let created_at = "2026-07-01T12:00:00.000Z";
1714 let chain_hash = key.chain_hash(
1715 &ChainContent {
1716 seq: 1,
1717 key_id: CURRENT_KEY_ID,
1718 transaction_id,
1719 request_id: "req-v2",
1720 request_hash: "hash-v2",
1721 action_name: "UpdateSystem",
1722 risk_level: RiskLevel::High,
1723 summary: "Upgrade the system",
1724 approval_id: None,
1725 warnings_json: "[]",
1726 created_at,
1727 identity: ChainIdentity::V2 {
1728 caller_role: "admin",
1729 event_tip: "",
1730 },
1731 },
1732 "",
1733 );
1734 conn.execute(
1735 "INSERT INTO transactions (
1736 transaction_id, request_id, request_hash, action_name, risk_level,
1737 status, approval_id, summary, warnings_json, created_at,
1738 seq, key_id, chain_hash, prev_chain_hash,
1739 chain_version, caller_role, event_tip
1740 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
1741 params![
1742 transaction_id,
1743 "req-v2",
1744 "hash-v2",
1745 "UpdateSystem",
1746 serialize_field(&RiskLevel::High).unwrap(),
1747 serialize_field(&JobState::Queued).unwrap(),
1748 "Upgrade the system",
1749 "[]",
1750 created_at,
1751 CURRENT_KEY_ID,
1752 chain_hash,
1753 ],
1754 )
1755 .unwrap();
1756 transaction_id.to_string()
1757 }
1758
1759 #[test]
1767 fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
1768 let dir = tempdir().unwrap();
1769 let db_path = dir.path().join("tx.db");
1770 let key = AuditKey::from_bytes(vec![0x42; 32]);
1771 let old_transaction = v2_database(&db_path, &key);
1772
1773 let store = test_store(&db_path);
1774
1775 let version: i64 = store
1776 .connection()
1777 .unwrap()
1778 .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
1779 row.get(0)
1780 })
1781 .unwrap();
1782 assert_eq!(
1783 version, 4,
1784 "opening a v2 database must apply every migration up to 4"
1785 );
1786
1787 let rows = store.fetch_chain_rows().unwrap();
1788 assert_eq!(rows.len(), 1);
1789 assert_eq!(rows[0].transaction_id, old_transaction);
1790 assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
1791 assert_eq!(
1792 rows[0].caller_principal, None,
1793 "migration 3 must not invent a principal for a row signed without one"
1794 );
1795 assert!(matches!(
1796 store.verify_audit_chain(&key).unwrap(),
1797 audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
1798 ));
1799
1800 let mut fresh = queued_transaction();
1802 fresh.caller_principal = CallerPrincipal::Uid(4242);
1803 store.record(fresh).unwrap();
1804
1805 let rows = store.fetch_chain_rows().unwrap();
1806 assert_eq!(rows.len(), 2);
1807 assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
1808 assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
1809 assert_eq!(
1810 rows[1].prev_chain_hash, rows[0].chain_hash,
1811 "the v3 row must chain onto the v2 row, not start a new chain"
1812 );
1813 assert!(matches!(
1814 store.verify_audit_chain(&key).unwrap(),
1815 audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
1816 ));
1817
1818 let census = audit_chain::AttributionCensus::of(&rows);
1826 assert_eq!(census.rows(), 2);
1827 assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
1828 assert_eq!(
1829 census.not_recorded(),
1830 1,
1831 "the v2 row's encoding never signed a principal"
1832 );
1833 assert_eq!(census.attribution_failed(), 0);
1834 assert_eq!(census.unattested(), 0);
1835 assert_eq!(census.unnamed(), 1);
1836 }
1837
1838 #[test]
1839 fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1840 let dir = tempdir().unwrap();
1845 let db_path = dir.path().join("tx.db");
1846 let key = AuditKey::from_bytes(vec![0x42; 32]);
1847 legacy_v1_database(&db_path, &key);
1848
1849 let store = test_store(&db_path);
1850 assert_eq!(
1851 store.verify_audit_chain(&key).unwrap(),
1852 VerifyOutcome::Intact { rows_checked: 1 }
1853 );
1854 let rows = store.fetch_chain_rows().unwrap();
1855 assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1856 assert_eq!(rows[0].caller_role, None);
1857 }
1858
1859 #[test]
1860 fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1861 let dir = tempdir().unwrap();
1865 let db_path = dir.path().join("tx.db");
1866 let key = AuditKey::from_bytes(vec![0x42; 32]);
1867 legacy_v1_database(&db_path, &key);
1868
1869 let store = test_store(&db_path);
1870 store.record(queued_transaction()).unwrap();
1871
1872 let rows = store.fetch_chain_rows().unwrap();
1873 assert_eq!(rows.len(), 2);
1874 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1875 assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1876 assert_eq!(
1877 store.verify_audit_chain(&key).unwrap(),
1878 VerifyOutcome::Intact { rows_checked: 2 }
1879 );
1880 }
1881
1882 #[test]
1883 fn a_schema_newer_than_this_binary_is_refused() {
1884 let dir = tempdir().unwrap();
1885 let db_path = dir.path().join("tx.db");
1886 let key = AuditKey::from_bytes(vec![0x42; 32]);
1887 legacy_v1_database(&db_path, &key);
1888 let conn = Connection::open(&db_path).unwrap();
1889 conn.execute(
1890 "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1891 [],
1892 )
1893 .unwrap();
1894 drop(conn);
1895
1896 let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1897 assert!(
1898 matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1899 "expected a refusal naming the unsupported version, got {err:?}"
1900 );
1901 }
1902
1903 #[test]
1906 fn the_signed_row_records_which_role_asked() {
1907 let dir = tempdir().unwrap();
1908 let store = test_store(dir.path().join("tx.db"));
1909 let mut tx = queued_transaction();
1910 tx.caller_role = CallerRole::Admin;
1911 store.record(tx).unwrap();
1912
1913 let rows = store.fetch_chain_rows().unwrap();
1914 assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1915 }
1916
1917 #[test]
1918 fn approving_consuming_and_revoking_each_append_a_chained_event() {
1919 let dir = tempdir().unwrap();
1920 let key = AuditKey::from_bytes(vec![0x42; 32]);
1921 let store = test_store(dir.path().join("tx.db"));
1922
1923 let a = store.record(queued_transaction()).unwrap();
1925 let receipt = store
1926 .approve_transaction(&a.transaction_id, CallerPrincipal::Uid(1000))
1927 .unwrap()
1928 .expect("queued transaction approves");
1929 let digest = audit_chain::approval_receipt_digest(&receipt);
1930 assert!(store
1931 .claim_approved_for_execution(&a.transaction_id, &digest, CallerPrincipal::Uid(1000))
1932 .unwrap());
1933
1934 let b = store.record(queued_transaction()).unwrap();
1936 store
1937 .approve_transaction(&b.transaction_id, CallerPrincipal::Uid(1000))
1938 .unwrap();
1939 assert!(store
1940 .revoke_unconsumed_approval(&b.transaction_id, CallerPrincipal::Uid(1000))
1941 .unwrap());
1942
1943 let events = store.fetch_event_rows().unwrap();
1944 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1945 assert_eq!(
1946 kinds,
1947 vec![
1948 "approval_granted",
1949 "approval_consumed",
1950 "status_running",
1951 "approval_granted",
1952 "approval_revoked",
1953 ]
1954 );
1955 assert_eq!(
1956 store.verify_event_chain(&key).unwrap(),
1957 VerifyOutcome::Intact { rows_checked: 5 }
1958 );
1959 }
1960
1961 #[test]
1962 fn a_stored_approval_event_names_the_account_that_acted() {
1963 let dir = tempdir().unwrap();
1970 let key = AuditKey::from_bytes(vec![0x42; 32]);
1971 let store = test_store(dir.path().join("tx.db"));
1972
1973 let tx = store.record(queued_transaction()).unwrap();
1974 let receipt = store
1976 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
1977 .unwrap()
1978 .expect("queued transaction approves");
1979 let digest = audit_chain::approval_receipt_digest(&receipt);
1980 assert!(store
1982 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1001))
1983 .unwrap());
1984
1985 let events = store.fetch_event_rows().unwrap();
1986 assert_eq!(events.len(), 3);
1987 let granted = events
1988 .iter()
1989 .find(|e| e.kind == "approval_granted")
1990 .expect("a grant event");
1991 let consumed = events
1992 .iter()
1993 .find(|e| e.kind == "approval_consumed")
1994 .expect("a consume event");
1995
1996 assert_eq!(granted.chain_version, EVENT_VERSION_CURRENT);
1998 assert_eq!(consumed.chain_version, EVENT_VERSION_CURRENT);
1999 assert_eq!(
2000 granted.caller_principal.as_deref(),
2001 Some("uid:1000"),
2002 "the grant must name the approver"
2003 );
2004 assert_eq!(
2005 consumed.caller_principal.as_deref(),
2006 Some("uid:1001"),
2007 "the consume must name the executor"
2008 );
2009 assert_ne!(
2010 granted.caller_principal, consumed.caller_principal,
2011 "a grant and a consume by different accounts must be distinguishable"
2012 );
2013
2014 assert_eq!(
2016 store.verify_event_chain(&key).unwrap(),
2017 VerifyOutcome::Intact { rows_checked: 3 }
2018 );
2019 }
2020
2021 #[test]
2022 fn a_stored_status_event_carries_no_principal_and_stays_legacy() {
2023 let dir = tempdir().unwrap();
2028 let key = AuditKey::from_bytes(vec![0x42; 32]);
2029 let store = test_store(dir.path().join("tx.db"));
2030
2031 let tx = store.record(queued_transaction()).unwrap();
2032 let receipt = store
2033 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2034 .unwrap()
2035 .expect("queued transaction approves");
2036 let digest = audit_chain::approval_receipt_digest(&receipt);
2037 assert!(store
2038 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2039 .unwrap());
2040 store
2041 .update_status(&tx.transaction_id, JobState::Succeeded)
2042 .unwrap();
2043
2044 let events = store.fetch_event_rows().unwrap();
2045 let status = events
2046 .iter()
2047 .find(|e| e.kind == "status_succeeded")
2048 .expect("a status event");
2049 assert_eq!(status.chain_version, EVENT_VERSION_LEGACY);
2050 assert_eq!(
2051 status.caller_principal, None,
2052 "a status event names no account"
2053 );
2054 assert_eq!(
2056 store.verify_event_chain(&key).unwrap(),
2057 VerifyOutcome::Intact { rows_checked: 4 }
2058 );
2059 }
2060
2061 #[test]
2062 fn audit_status_event_failure_rolls_back_the_entire_transition() {
2063 for operation in ["claim", "cancel", "sweep"] {
2064 let dir = tempdir().unwrap();
2065 let store = test_store(dir.path().join("tx.db"));
2066 let tx = store.record(queued_transaction()).unwrap();
2067 let receipt = store
2068 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2069 .unwrap()
2070 .unwrap();
2071 let digest = audit_chain::approval_receipt_digest(&receipt);
2072 let conn = store.connection().unwrap();
2073 if operation == "sweep" {
2074 conn.execute(
2075 "UPDATE transactions SET created_at = datetime('now', '-20 minutes')",
2076 [],
2077 )
2078 .unwrap();
2079 }
2080 conn.execute_batch("CREATE TRIGGER reject_status_event BEFORE INSERT ON audit_events WHEN NEW.kind LIKE 'status_%' BEGIN SELECT RAISE(ABORT, 'injected event failure'); END;").unwrap();
2081 let result = match operation {
2082 "claim" => store.claim_approved_for_execution(
2083 &tx.transaction_id,
2084 &digest,
2085 CallerPrincipal::Uid(1000),
2086 ),
2087 "cancel" => store.cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000)),
2088 _ => store.cleanup_stale_queued().map(|count| count > 0),
2089 };
2090 assert!(
2091 result.is_err(),
2092 "{operation} must fail when its event cannot be written"
2093 );
2094 assert_eq!(
2095 store.get(&tx.transaction_id).unwrap().unwrap().status,
2096 JobState::Queued
2097 );
2098 let unconsumed: i64 = conn.query_row("SELECT COUNT(*) FROM transaction_approvals WHERE transaction_id = ?1 AND consumed_at IS NULL", params![tx.transaction_id], |row| row.get(0)).unwrap();
2099 assert_eq!(
2100 unconsumed, 1,
2101 "{operation} must roll back approval changes too"
2102 );
2103 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
2104 }
2105 }
2106
2107 #[test]
2108 fn audit_status_legacy_consumption_cannot_be_rewritten_as_queued() {
2109 let dir = tempdir().unwrap();
2110 let store = test_store(dir.path().join("tx.db"));
2111 let tx = store.record(queued_transaction()).unwrap();
2112 let receipt = store
2113 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2114 .unwrap()
2115 .unwrap();
2116 let digest = audit_chain::approval_receipt_digest(&receipt);
2117 store
2118 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2119 .unwrap();
2120 let conn = store.connection().unwrap();
2121 conn.execute("DELETE FROM audit_events WHERE kind LIKE 'status_%'", [])
2123 .unwrap();
2124 let outcome = store.status_matches_chain().unwrap();
2125 assert_eq!(
2126 serde_json::to_value(&outcome).unwrap()["state"],
2127 "cannot_verify"
2128 );
2129 conn.execute(
2130 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2131 params![
2132 serialize_field(&JobState::Queued).unwrap(),
2133 tx.transaction_id
2134 ],
2135 )
2136 .unwrap();
2137 assert!(matches!(
2138 store.status_matches_chain().unwrap(),
2139 audit_chain::StatusOutcome::Disagrees { .. }
2140 ));
2141 }
2142
2143 #[test]
2144 fn audit_status_tracks_an_in_flight_claim() {
2145 let dir = tempdir().unwrap();
2146 let store = test_store(dir.path().join("tx.db"));
2147 let tx = store.record(queued_transaction()).unwrap();
2148 let receipt = store
2149 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2150 .unwrap()
2151 .unwrap();
2152 let digest = audit_chain::approval_receipt_digest(&receipt);
2153 assert!(store
2154 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2155 .unwrap());
2156 assert_eq!(
2157 store.status_matches_chain().unwrap(),
2158 audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2159 );
2160 assert_eq!(
2161 store.fetch_event_rows().unwrap().last().unwrap().kind,
2162 "status_running"
2163 );
2164 assert_eq!(
2165 store
2166 .verify_event_chain(&AuditKey::from_bytes(vec![0x42; 32]))
2167 .unwrap(),
2168 VerifyOutcome::Intact { rows_checked: 3 }
2169 );
2170 }
2171
2172 #[test]
2173 fn audit_status_tracks_cancellation_with_and_without_approval() {
2174 for approved in [false, true] {
2175 let dir = tempdir().unwrap();
2176 let store = test_store(dir.path().join("tx.db"));
2177 let tx = store.record(queued_transaction()).unwrap();
2178 if approved {
2179 store
2180 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2181 .unwrap();
2182 }
2183 assert!(store
2184 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
2185 .unwrap());
2186 assert_eq!(
2187 store.status_matches_chain().unwrap(),
2188 audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2189 );
2190 assert_eq!(
2191 store.fetch_event_rows().unwrap().last().unwrap().kind,
2192 "status_canceled"
2193 );
2194 assert!(matches!(
2195 store
2196 .verify_event_chain(&AuditKey::from_bytes(vec![0x42; 32]))
2197 .unwrap(),
2198 VerifyOutcome::Intact { .. }
2199 ));
2200 let count = store.fetch_event_rows().unwrap().len();
2201 assert!(!store
2202 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
2203 .unwrap());
2204 assert_eq!(store.fetch_event_rows().unwrap().len(), count);
2205 }
2206 }
2207
2208 #[test]
2209 fn audit_status_tracks_stale_sweep_with_and_without_approval() {
2210 for approved in [false, true] {
2211 let dir = tempdir().unwrap();
2212 let store = test_store(dir.path().join("tx.db"));
2213 let tx = store.record(queued_transaction()).unwrap();
2214 if approved {
2215 store
2216 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2217 .unwrap();
2218 }
2219 store.connection().unwrap().execute(
2220 "UPDATE transactions SET created_at = datetime('now', '-20 minutes') WHERE transaction_id = ?1",
2221 params![tx.transaction_id],
2222 ).unwrap();
2223 assert_eq!(store.cleanup_stale_queued().unwrap(), 1);
2224 assert_eq!(
2225 store.status_matches_chain().unwrap(),
2226 audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2227 );
2228 assert_eq!(
2229 store.fetch_event_rows().unwrap().last().unwrap().kind,
2230 "status_canceled"
2231 );
2232 let count = store.fetch_event_rows().unwrap().len();
2233 assert_eq!(store.cleanup_stale_queued().unwrap(), 0);
2234 assert_eq!(store.fetch_event_rows().unwrap().len(), count);
2235 }
2236 }
2237
2238 #[test]
2239 fn audit_status_only_historical_claims_and_cancellations_are_inconclusive() {
2240 let dir = tempdir().unwrap();
2241 let store = test_store(dir.path().join("tx.db"));
2242 let tx = store.record(queued_transaction()).unwrap();
2243 store
2246 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2247 .unwrap();
2248 store
2249 .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000))
2250 .unwrap();
2251 assert_eq!(
2252 store.status_matches_chain().unwrap(),
2253 audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2254 );
2255 for state in [
2256 JobState::Canceled,
2257 JobState::Running,
2258 JobState::Succeeded,
2259 JobState::Failed,
2260 JobState::RolledBack,
2261 JobState::NeedsReboot,
2262 ] {
2263 store
2264 .connection()
2265 .unwrap()
2266 .execute(
2267 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2268 params![serialize_field(&state).unwrap(), tx.transaction_id],
2269 )
2270 .unwrap();
2271 let outcome = store.status_matches_chain().unwrap();
2272 let historical = matches!(state, JobState::Running | JobState::Canceled);
2273 assert_eq!(
2274 serde_json::to_value(&outcome).unwrap()["state"],
2275 if historical {
2276 "cannot_verify"
2277 } else {
2278 "disagrees"
2279 },
2280 "unsigned state {state:?}"
2281 );
2282 assert_eq!(
2283 audit_chain::status_outcome_to_exit_code(&outcome),
2284 if historical { 2 } else { 1 },
2285 "unsigned state {state:?}"
2286 );
2287 }
2288 }
2289
2290 #[test]
2291 fn audit_status_new_event_contradictions_remain_broken() {
2292 let dir = tempdir().unwrap();
2293 let store = test_store(dir.path().join("tx.db"));
2294 let tx = store.record(queued_transaction()).unwrap();
2295 store
2296 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
2297 .unwrap();
2298 store
2299 .connection()
2300 .unwrap()
2301 .execute(
2302 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2303 params![
2304 serialize_field(&JobState::Queued).unwrap(),
2305 tx.transaction_id
2306 ],
2307 )
2308 .unwrap();
2309 let legacy = store.record(queued_transaction()).unwrap();
2312 store
2313 .connection()
2314 .unwrap()
2315 .execute(
2316 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2317 params![
2318 serialize_field(&JobState::Canceled).unwrap(),
2319 legacy.transaction_id
2320 ],
2321 )
2322 .unwrap();
2323 assert!(matches!(
2324 store.status_matches_chain().unwrap(),
2325 audit_chain::StatusOutcome::Disagrees { .. }
2326 ));
2327 }
2328
2329 #[test]
2330 fn a_terminal_outcome_is_chained_so_rewriting_status_is_detectable() {
2331 let dir = tempdir().unwrap();
2339 let key = AuditKey::from_bytes(vec![0x42; 32]);
2340 let store = test_store(dir.path().join("tx.db"));
2341
2342 let tx = store.record(queued_transaction()).unwrap();
2343 let receipt = store
2344 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2345 .unwrap()
2346 .expect("queued transaction approves");
2347 let digest = audit_chain::approval_receipt_digest(&receipt);
2348 assert!(store
2349 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2350 .unwrap());
2351 store
2352 .update_status(&tx.transaction_id, JobState::Succeeded)
2353 .unwrap();
2354
2355 let kinds: Vec<String> = store
2357 .fetch_event_rows()
2358 .unwrap()
2359 .iter()
2360 .map(|e| e.kind.clone())
2361 .collect();
2362 assert!(
2363 kinds.iter().any(|k| k.contains("succeeded")),
2364 "no event records the terminal outcome; the chain covers only {kinds:?}"
2365 );
2366 assert!(
2367 matches!(
2368 store.status_matches_chain().unwrap(),
2369 audit_chain::StatusOutcome::Agrees { .. }
2370 ),
2371 "an untampered store must report agreement, got {:?}",
2372 store.status_matches_chain().unwrap()
2373 );
2374
2375 {
2378 let conn = rusqlite::Connection::open(dir.path().join("tx.db")).unwrap();
2379 let n = conn
2380 .execute(
2381 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2382 rusqlite::params!["\"canceled\"", tx.transaction_id],
2383 )
2384 .unwrap();
2385 assert_eq!(n, 1, "the fixture did not rewrite the status column");
2386 }
2387 let audit_chain::StatusOutcome::Disagrees { detail } =
2388 store.status_matches_chain().unwrap()
2389 else {
2390 panic!("a rewritten status must be reported, not passed over");
2391 };
2392 assert!(
2393 detail.contains(&tx.transaction_id),
2394 "the report must name the transaction: {detail}"
2395 );
2396 assert!(
2399 matches!(
2400 store.verify_event_chain(&key).unwrap(),
2401 VerifyOutcome::Intact { .. }
2402 ),
2403 "event chain: {:?}, rows: {:?}",
2404 store.verify_event_chain(&key).unwrap(),
2405 store
2406 .fetch_event_rows()
2407 .unwrap()
2408 .iter()
2409 .map(|e| (e.seq, e.kind.clone()))
2410 .collect::<Vec<_>>()
2411 );
2412 }
2413
2414 #[test]
2415 fn a_failed_approval_appends_no_event() {
2416 let dir = tempdir().unwrap();
2420 let store = test_store(dir.path().join("tx.db"));
2421 let tx = store.record(queued_transaction()).unwrap();
2422 store
2423 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2424 .unwrap();
2425 store
2426 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2427 .unwrap();
2428 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
2429
2430 assert!(!store
2432 .revoke_unconsumed_approval("no-such-tx", CallerPrincipal::Uid(1000))
2433 .unwrap());
2434 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
2435 }
2436
2437 #[test]
2438 fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
2439 let dir = tempdir().unwrap();
2445 let db_path = dir.path().join("tx.db");
2446 let key = AuditKey::from_bytes(vec![0x42; 32]);
2447 let store = test_store(&db_path);
2448
2449 let a = store.record(queued_transaction()).unwrap();
2450 store
2451 .approve_transaction(&a.transaction_id, CallerPrincipal::Uid(1000))
2452 .unwrap();
2453 store.record(queued_transaction()).unwrap();
2456
2457 assert_eq!(
2458 store.verify_event_chain(&key).unwrap(),
2459 VerifyOutcome::Intact { rows_checked: 1 }
2460 );
2461 assert_eq!(
2462 store.verify_event_binding().unwrap(),
2463 audit_chain::BindingOutcome::Consistent {
2464 bindings_checked: 1
2465 }
2466 );
2467
2468 let conn = Connection::open(&db_path).unwrap();
2470 conn.execute("DELETE FROM transaction_approvals", [])
2471 .unwrap();
2472 conn.execute("DELETE FROM audit_events", []).unwrap();
2473 drop(conn);
2474
2475 assert_eq!(
2479 store.verify_event_chain(&key).unwrap(),
2480 VerifyOutcome::Intact { rows_checked: 0 }
2481 );
2482 match store.verify_event_binding().unwrap() {
2483 audit_chain::BindingOutcome::MissingEvent {
2484 transaction_seq, ..
2485 } => assert_eq!(transaction_seq, 2),
2486 other => panic!("expected MissingEvent, got {other:?}"),
2487 }
2488 }
2489
2490 #[test]
2491 fn a_read_only_store_cannot_revoke_or_claim() {
2492 let dir = tempdir().unwrap();
2495 let db_path = dir.path().join("tx.db");
2496 let store = test_store(&db_path);
2497 store.record(queued_transaction()).unwrap();
2498
2499 let read_only = TransactionStore::open_read_only(&db_path).unwrap();
2500 assert!(matches!(
2501 read_only.revoke_unconsumed_approval("tx", CallerPrincipal::Uid(1000)),
2502 Err(TransactionStoreError::AuditChainMissing(_))
2503 ));
2504 assert!(matches!(
2505 read_only.claim_approved_for_execution("tx", "digest", CallerPrincipal::Uid(1000)),
2506 Err(TransactionStoreError::AuditChainMissing(_))
2507 ));
2508 }
2509
2510 #[test]
2517 fn record_persists_the_caller_principal_under_the_current_encoding() {
2518 let dir = tempdir().unwrap();
2519 let store = test_store(dir.path().join("tx.db"));
2520 let mut tx = queued_transaction();
2521 tx.caller_principal = CallerPrincipal::Uid(4242);
2522 store.record(tx).unwrap();
2523
2524 let conn = store.connection().unwrap();
2525 let (version, role, principal): (i64, Option<String>, Option<String>) = conn
2526 .query_row(
2527 "SELECT chain_version, caller_role, caller_principal FROM transactions",
2528 [],
2529 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2530 )
2531 .unwrap();
2532
2533 assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
2534 assert_eq!(role.as_deref(), Some("dev"));
2535 assert_eq!(
2536 principal.as_deref(),
2537 Some("uid:4242"),
2538 "the row must name the account the dispatcher resolved"
2539 );
2540
2541 let key = AuditKey::from_bytes(vec![0x42; 32]);
2543 assert!(matches!(
2544 store.verify_audit_chain(&key).unwrap(),
2545 audit_chain::VerifyOutcome::Intact { .. }
2546 ));
2547 }
2548
2549 #[test]
2550 fn record_writes_audit_chain_columns() {
2551 let dir = tempdir().unwrap();
2552 let store = test_store(dir.path().join("tx.db"));
2553 let _record = store.record(queued_transaction()).unwrap();
2554
2555 let conn = store.connection().unwrap();
2556 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
2557 .query_row(
2558 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
2559 [],
2560 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
2561 )
2562 .unwrap();
2563 assert_eq!(seq, 1, "first row gets seq=1");
2564 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
2565 assert_eq!(prev, "", "first row has empty prev_chain_hash");
2566 assert_eq!(
2567 chain_hash.len(),
2568 audit_chain::HASH_HEX_LEN,
2569 "chain_hash is a hex-encoded Ed25519 signature"
2570 );
2571 }
2572
2573 #[test]
2574 fn sequential_records_produce_chained_hashes() {
2575 let dir = tempdir().unwrap();
2576 let store = test_store(dir.path().join("tx.db"));
2577 store.record(queued_transaction()).unwrap();
2578 store.record(queued_transaction()).unwrap();
2579 store.record(queued_transaction()).unwrap();
2580
2581 let rows = store.fetch_chain_rows().unwrap();
2582 assert_eq!(rows.len(), 3);
2583 assert_eq!(rows[0].seq, 1);
2584 assert_eq!(rows[1].seq, 2);
2585 assert_eq!(rows[2].seq, 3);
2586 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
2588 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
2589 }
2590
2591 #[test]
2603 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
2604 const WORKERS: usize = 8;
2605 const RECORDS_PER_WORKER: usize = 10;
2606 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
2607
2608 let dir = tempdir().unwrap();
2609 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2610
2611 let mut handles = Vec::with_capacity(WORKERS);
2612 for w in 0..WORKERS {
2613 let store = std::sync::Arc::clone(&store);
2614 handles.push(std::thread::spawn(move || {
2615 for r in 0..RECORDS_PER_WORKER {
2616 let tx = NewTransaction {
2617 request_id: format!("worker-{w}-record-{r}"),
2618 request_hash: format!("hash-{w}-{r}"),
2619 action_name: "GetSystemState".to_string(),
2620 risk_level: RiskLevel::Low,
2621 summary: format!("worker {w} record {r}"),
2622 warnings: vec![],
2623 caller_role: CallerRole::Dev,
2624 caller_principal: CallerPrincipal::Uid(3000 + w as u32),
2628 };
2629 store
2630 .record(tx)
2631 .expect("record must succeed under contention");
2632 }
2633 }));
2634 }
2635 for h in handles {
2636 h.join().expect("worker thread did not panic");
2637 }
2638
2639 let key = AuditKey::from_bytes(vec![0x42; 32]);
2641 let outcome = store.verify_audit_chain(&key).unwrap();
2642 match outcome {
2643 VerifyOutcome::Intact { rows_checked } => {
2644 assert_eq!(
2645 rows_checked, TOTAL as u64,
2646 "expected {TOTAL} rows checked, got {rows_checked}"
2647 );
2648 }
2649 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
2650 }
2651
2652 {
2656 let conn = store.connection().unwrap();
2657 let mut stmt = conn
2658 .prepare("SELECT summary, caller_principal FROM transactions")
2659 .unwrap();
2660 let pairs: Vec<(String, Option<String>)> = stmt
2661 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
2662 .unwrap()
2663 .map(|r| r.unwrap())
2664 .collect();
2665 assert_eq!(pairs.len(), TOTAL);
2666 for (summary, principal) in pairs {
2667 let worker: u32 = summary
2668 .split_whitespace()
2669 .nth(1)
2670 .and_then(|w| w.parse().ok())
2671 .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
2672 assert_eq!(
2673 principal.as_deref(),
2674 Some(format!("uid:{}", 3000 + worker).as_str()),
2675 "row {summary:?} was attributed to the wrong account"
2676 );
2677 }
2678 }
2679
2680 let conn = store.connection().unwrap();
2682 let mut stmt = conn
2683 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
2684 .unwrap();
2685 let seqs: Vec<i64> = stmt
2686 .query_map([], |row| row.get::<_, i64>(0))
2687 .unwrap()
2688 .map(|r| r.unwrap())
2689 .collect();
2690 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
2691 for (i, s) in seqs.iter().enumerate() {
2692 assert_eq!(
2693 *s,
2694 (i as i64) + 1,
2695 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
2696 );
2697 }
2698 }
2699
2700 #[test]
2701 fn verify_audit_chain_intact_after_inserts() {
2702 let dir = tempdir().unwrap();
2703 let store = test_store(dir.path().join("tx.db"));
2704 for _ in 0..3 {
2705 store.record(queued_transaction()).unwrap();
2706 }
2707 let key = AuditKey::from_bytes(vec![0x42; 32]);
2708 let outcome = store.verify_audit_chain(&key).unwrap();
2709 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2710 }
2711
2712 #[test]
2713 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
2714 let dir = tempdir().unwrap();
2715 let store = test_store(dir.path().join("tx.db"));
2716 for _ in 0..3 {
2717 store.record(queued_transaction()).unwrap();
2718 }
2719 let key = AuditKey::from_bytes(vec![0x42; 32]);
2721 let outcome = store
2722 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
2723 .unwrap();
2724 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2725 }
2726
2727 #[test]
2728 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
2729 let dir = tempdir().unwrap();
2730 let store = test_store(dir.path().join("tx.db"));
2731 store.record(queued_transaction()).unwrap();
2732 let other = AuditKey::from_bytes(vec![0x99; 32]);
2734 let outcome = store
2735 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
2736 .unwrap();
2737 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2738 }
2739
2740 #[test]
2741 fn verify_detects_tampered_summary() {
2742 let dir = tempdir().unwrap();
2743 let store = test_store(dir.path().join("tx.db"));
2744 let tx = store.record(queued_transaction()).unwrap();
2745
2746 let conn = store.connection().unwrap();
2749 conn.execute(
2750 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
2751 params!["EVIL CHANGE", tx.transaction_id],
2752 )
2753 .unwrap();
2754
2755 let key = AuditKey::from_bytes(vec![0x42; 32]);
2756 let outcome = store.verify_audit_chain(&key).unwrap();
2757 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2758 }
2759
2760 #[test]
2761 fn status_update_does_not_break_chain() {
2762 let dir = tempdir().unwrap();
2764 let store = test_store(dir.path().join("tx.db"));
2765 let tx = store.record(queued_transaction()).unwrap();
2766 store
2767 .update_status(&tx.transaction_id, JobState::Running)
2768 .unwrap();
2769 store
2770 .update_status(&tx.transaction_id, JobState::Succeeded)
2771 .unwrap();
2772
2773 let key = AuditKey::from_bytes(vec![0x42; 32]);
2774 let outcome = store.verify_audit_chain(&key).unwrap();
2775 assert!(
2776 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
2777 "status mutation must not break the chain (status not in chain content): {outcome:?}"
2778 );
2779 }
2780
2781 #[test]
2782 fn open_read_only_rejects_record() {
2783 let dir = tempdir().unwrap();
2784 let key_path = dir.path().join("audit-key");
2785 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
2786 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2787
2788 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
2789 let result = store.record(queued_transaction());
2790 assert!(matches!(
2791 result,
2792 Err(TransactionStoreError::AuditChainMissing(_))
2793 ));
2794 }
2795
2796 #[test]
2797 fn ensure_private_dir_creates_with_0700_mode() {
2798 let root = tempdir().unwrap();
2799 let target = root.path().join("a/b/c");
2800 ensure_private_dir(&target).unwrap();
2801 assert!(target.is_dir());
2802 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2803 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
2804 }
2805
2806 #[test]
2814 fn the_audit_database_is_created_owner_only() {
2815 let dir = tempdir().unwrap();
2816 let db = dir.path().join("daemon.sqlite");
2817 let store = TransactionStore::open(&db).unwrap();
2818 store
2820 .record(NewTransaction {
2821 request_id: "mode-probe".to_string(),
2822 request_hash: "hash-mode-probe".to_string(),
2823 action_name: "GetSystemState".to_string(),
2824 risk_level: RiskLevel::Low,
2825 summary: "probe".to_string(),
2826 warnings: vec![],
2827 caller_role: CallerRole::Dev,
2828 caller_principal: CallerPrincipal::Uid(3000),
2829 })
2830 .unwrap();
2831
2832 for path in [db.clone(), db.with_extension("sqlite-wal")] {
2833 if !path.exists() {
2834 continue;
2835 }
2836 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2837 assert_eq!(
2838 mode,
2839 0o600,
2840 "{} must be owner-only, got {mode:o}",
2841 path.display()
2842 );
2843 }
2844 }
2845
2846 #[test]
2847 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
2848 let root = tempdir().unwrap();
2849 let target = root.path().join("preexisting");
2850 std::fs::create_dir(&target).unwrap();
2851 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
2852 ensure_private_dir(&target).unwrap();
2853 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2855 assert_eq!(mode, 0o755);
2856 }
2857
2858 #[test]
2859 fn open_creates_parent_with_private_mode() {
2860 let root = tempdir().unwrap();
2861 let db_path = root.path().join("nested/dirs/daemon.sqlite");
2862 let _store = test_store(&db_path);
2863 let parent = db_path.parent().unwrap();
2864 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
2865 assert_eq!(mode, 0o700);
2866 }
2867
2868 fn queued_transaction() -> NewTransaction {
2869 NewTransaction {
2870 request_id: "req-1".to_string(),
2871 request_hash: "hash-abc".to_string(),
2872 action_name: "UpdateSystem".to_string(),
2873 risk_level: RiskLevel::High,
2874 summary: "Upgrade the system".to_string(),
2875 warnings: vec![],
2876 caller_role: CallerRole::Dev,
2877 caller_principal: CallerPrincipal::Uid(1000),
2878 }
2879 }
2880
2881 #[test]
2882 fn update_status_transitions_queued_to_running() {
2883 let dir = tempdir().unwrap();
2884 let store = test_store(dir.path().join("tx.db"));
2885 let tx = store.record(queued_transaction()).unwrap();
2886
2887 store
2888 .update_status(&tx.transaction_id, JobState::Running)
2889 .unwrap();
2890
2891 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2892 assert_eq!(updated.status, JobState::Running);
2893 }
2894
2895 #[test]
2896 fn update_status_transitions_running_to_succeeded() {
2897 let dir = tempdir().unwrap();
2898 let store = test_store(dir.path().join("tx.db"));
2899 let tx = store.record(queued_transaction()).unwrap();
2900
2901 store
2902 .update_status(&tx.transaction_id, JobState::Running)
2903 .unwrap();
2904 store
2905 .update_status(&tx.transaction_id, JobState::Succeeded)
2906 .unwrap();
2907
2908 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2909 assert_eq!(updated.status, JobState::Succeeded);
2910 }
2911
2912 #[test]
2913 fn update_status_for_unknown_id_returns_not_found() {
2914 let dir = tempdir().unwrap();
2915 let store = test_store(dir.path().join("tx.db"));
2916
2917 let result = store.update_status("does-not-exist", JobState::Running);
2918 assert!(
2919 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2920 "expected NotFound, got: {result:?}"
2921 );
2922 }
2923
2924 #[test]
2925 fn update_status_leaves_other_fields_intact() {
2926 let dir = tempdir().unwrap();
2927 let store = test_store(dir.path().join("tx.db"));
2928 let tx = store.record(queued_transaction()).unwrap();
2929
2930 store
2931 .update_status(&tx.transaction_id, JobState::Running)
2932 .unwrap();
2933 store
2934 .update_status(&tx.transaction_id, JobState::Failed)
2935 .unwrap();
2936
2937 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2938 assert_eq!(updated.action_name, "UpdateSystem");
2939 assert_eq!(updated.risk_level, RiskLevel::High);
2940 assert_eq!(updated.status, JobState::Failed);
2941 }
2942
2943 #[test]
2952 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2953 let dir = tempdir().unwrap();
2954 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2955 let tx = store.record(queued_transaction()).unwrap();
2956 store
2957 .update_status(&tx.transaction_id, JobState::Running)
2958 .unwrap();
2959
2960 let store_a = std::sync::Arc::clone(&store);
2961 let id_a = tx.transaction_id.clone();
2962 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2963
2964 let store_b = std::sync::Arc::clone(&store);
2965 let id_b = tx.transaction_id.clone();
2966 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2967
2968 let succeed_result = succeed.join().unwrap();
2969 let fail_result = fail.join().unwrap();
2970
2971 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2973 .into_iter()
2974 .filter(|ok| *ok)
2975 .count();
2976 assert_eq!(
2977 oks, 1,
2978 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2979 fail={fail_result:?}"
2980 );
2981
2982 let loser = if succeed_result.is_ok() {
2998 &fail_result
2999 } else {
3000 &succeed_result
3001 };
3002 match loser {
3003 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
3004 assert_eq!(id, &tx.transaction_id);
3005 }
3006 Err(TransactionStoreError::InvalidTransition { from, to }) => {
3007 let winner = if succeed_result.is_ok() {
3009 JobState::Succeeded
3010 } else {
3011 JobState::Failed
3012 };
3013 assert_eq!(
3014 *from, winner,
3015 "the loser must have observed the winner's committed status"
3016 );
3017 assert_ne!(*to, winner, "the loser must be the other transition");
3018 }
3019 other => panic!(
3020 "the losing transition must be refused with either \
3021 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
3022 ),
3023 }
3024
3025 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
3028 if succeed_result.is_ok() {
3029 assert_eq!(final_status, JobState::Succeeded);
3030 } else {
3031 assert_eq!(final_status, JobState::Failed);
3032 }
3033 }
3034
3035 #[test]
3036 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
3037 let dir = tempdir().unwrap();
3040 let store = test_store(dir.path().join("tx.db"));
3041 let tx = store.record(queued_transaction()).unwrap();
3042 let receipt = store
3043 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3044 .unwrap()
3045 .expect("approved");
3046 let digest = audit_chain::approval_receipt_digest(&receipt);
3047
3048 assert!(
3049 store
3050 .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000))
3051 .unwrap(),
3052 "an unconsumed approval must be revocable"
3053 );
3054 assert!(
3055 !store
3056 .claim_approved_for_execution(
3057 &tx.transaction_id,
3058 &digest,
3059 CallerPrincipal::Uid(1000)
3060 )
3061 .unwrap(),
3062 "a revoked receipt must no longer be claimable"
3063 );
3064 }
3065
3066 #[test]
3067 fn revoke_cannot_retract_an_approval_that_was_already_used() {
3068 let dir = tempdir().unwrap();
3072 let store = test_store(dir.path().join("tx.db"));
3073 let tx = store.record(queued_transaction()).unwrap();
3074 let receipt = store
3075 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3076 .unwrap()
3077 .expect("approved");
3078 let digest = audit_chain::approval_receipt_digest(&receipt);
3079 assert!(store
3080 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
3081 .unwrap());
3082
3083 assert!(
3084 !store
3085 .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000))
3086 .unwrap(),
3087 "a consumed approval must not be revocable"
3088 );
3089 assert_eq!(
3090 store.get(&tx.transaction_id).unwrap().unwrap().status,
3091 JobState::Running,
3092 "a failed revocation must not disturb the running transaction"
3093 );
3094 }
3095
3096 #[test]
3097 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
3098 let dir = tempdir().unwrap();
3102 let store = test_store(dir.path().join("tx.db"));
3103 let tx = store.record(queued_transaction()).unwrap();
3104 let receipt = store
3105 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3106 .unwrap()
3107 .expect("approved");
3108 let digest = audit_chain::approval_receipt_digest(&receipt);
3109 assert!(store
3110 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
3111 .unwrap());
3112
3113 assert!(
3115 store
3116 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3117 .unwrap()
3118 .is_none(),
3119 "a Running transaction must not be approvable"
3120 );
3121
3122 store
3124 .update_status(&tx.transaction_id, JobState::Succeeded)
3125 .unwrap();
3126 assert!(
3127 store
3128 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3129 .unwrap()
3130 .is_none(),
3131 "a completed transaction must not be approvable"
3132 );
3133 }
3134
3135 #[test]
3136 fn only_one_of_two_concurrent_claims_can_execute() {
3137 let dir = tempdir().unwrap();
3142 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
3143 let tx = store.record(queued_transaction()).unwrap();
3144 let receipt = store
3145 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3146 .unwrap()
3147 .expect("approved");
3148 let digest = audit_chain::approval_receipt_digest(&receipt);
3149
3150 let mut handles = Vec::new();
3151 for _ in 0..2 {
3152 let store = std::sync::Arc::clone(&store);
3153 let id = tx.transaction_id.clone();
3154 let digest = digest.clone();
3155 handles.push(std::thread::spawn(move || {
3156 store.claim_approved_for_execution(&id, &digest, CallerPrincipal::Uid(1000))
3157 }));
3158 }
3159 let claims: Vec<bool> = handles
3160 .into_iter()
3161 .map(|h| h.join().unwrap().unwrap_or(false))
3162 .collect();
3163
3164 assert_eq!(
3165 claims.iter().filter(|c| **c).count(),
3166 1,
3167 "exactly one claim may win: {claims:?}"
3168 );
3169 assert_eq!(
3170 store.get(&tx.transaction_id).unwrap().unwrap().status,
3171 JobState::Running
3172 );
3173 }
3174
3175 #[test]
3176 fn cancel_queued_cancels_a_queued_transaction_once() {
3177 let dir = tempdir().unwrap();
3178 let store = test_store(dir.path().join("tx.db"));
3179 let tx = store.record(queued_transaction()).unwrap();
3180
3181 assert!(
3182 store
3183 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3184 .unwrap(),
3185 "a queued transaction is cancelable"
3186 );
3187 assert_eq!(
3188 store.get(&tx.transaction_id).unwrap().unwrap().status,
3189 JobState::Canceled
3190 );
3191 assert!(
3192 !store
3193 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3194 .unwrap(),
3195 "an already-canceled transaction is not cancelable again"
3196 );
3197 assert!(
3198 !store
3199 .cancel_queued("no-such-transaction", CallerPrincipal::Uid(1000))
3200 .unwrap(),
3201 "a missing transaction is not cancelable"
3202 );
3203 assert!(
3204 store
3205 .fetch_event_rows()
3206 .unwrap()
3207 .iter()
3208 .all(|event| event.kind == "status_canceled"),
3209 "canceling an unapproved transaction must not append an approval event"
3210 );
3211 }
3212
3213 #[test]
3214 fn cancel_queued_revokes_an_unconsumed_approval_and_appends_event() {
3215 let dir = tempdir().unwrap();
3216 let store = test_store(dir.path().join("tx.db"));
3217 let tx = store.record(queued_transaction()).unwrap();
3218 let receipt = store
3219 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3220 .unwrap()
3221 .expect("queued transaction is approvable");
3222 let digest = audit_chain::approval_receipt_digest(&receipt);
3223
3224 assert!(store
3225 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3226 .unwrap());
3227 assert_eq!(
3228 store.get(&tx.transaction_id).unwrap().unwrap().status,
3229 JobState::Canceled
3230 );
3231 assert!(
3232 !store
3233 .claim_approved_for_execution(
3234 &tx.transaction_id,
3235 &digest,
3236 CallerPrincipal::Uid(1000)
3237 )
3238 .unwrap(),
3239 "canceling must revoke the unconsumed receipt"
3240 );
3241 assert_eq!(
3242 store
3243 .fetch_event_rows()
3244 .unwrap()
3245 .iter()
3246 .map(|event| event.kind.as_str())
3247 .collect::<Vec<_>>(),
3248 vec!["approval_granted", "approval_revoked", "status_canceled"]
3249 );
3250 }
3251
3252 #[test]
3253 fn cancel_queued_refuses_a_running_transaction() {
3254 let dir = tempdir().unwrap();
3257 let store = test_store(dir.path().join("tx.db"));
3258 let tx = store.record(queued_transaction()).unwrap();
3259 let receipt = store
3260 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3261 .unwrap()
3262 .expect("approved");
3263 let digest = audit_chain::approval_receipt_digest(&receipt);
3264 assert!(store
3265 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
3266 .unwrap());
3267
3268 assert!(
3269 !store
3270 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3271 .unwrap(),
3272 "a running transaction must not be cancelable"
3273 );
3274 assert_eq!(
3275 store.get(&tx.transaction_id).unwrap().unwrap().status,
3276 JobState::Running,
3277 "cancel must not disturb a running transaction"
3278 );
3279 }
3280
3281 #[test]
3282 fn approved_receipt_is_required_and_consumed_once() {
3283 let dir = tempdir().unwrap();
3284 let store = test_store(dir.path().join("tx.db"));
3285 let tx = store.record(queued_transaction()).unwrap();
3286
3287 assert!(
3288 !store
3289 .claim_approved_for_execution(
3290 &tx.transaction_id,
3291 "digest-a",
3292 CallerPrincipal::Uid(1000)
3293 )
3294 .unwrap(),
3295 "an unapproved preview must not execute"
3296 );
3297 let receipt = store
3298 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3299 .unwrap()
3300 .expect("first approval must succeed");
3301 let digest = audit_chain::approval_receipt_digest(&receipt);
3302 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
3303 assert!(
3304 store
3305 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3306 .unwrap()
3307 .is_none(),
3308 "approval is immutable once issued"
3309 );
3310 assert!(
3311 !store
3312 .claim_approved_for_execution(
3313 &tx.transaction_id,
3314 "wrong-digest",
3315 CallerPrincipal::Uid(1000)
3316 )
3317 .unwrap(),
3318 "a forged receipt must not execute"
3319 );
3320 assert!(
3321 store
3322 .claim_approved_for_execution(
3323 &tx.transaction_id,
3324 &digest,
3325 CallerPrincipal::Uid(1000)
3326 )
3327 .unwrap(),
3328 "the exact approved receipt must execute"
3329 );
3330 assert!(
3331 !store
3332 .claim_approved_for_execution(
3333 &tx.transaction_id,
3334 &digest,
3335 CallerPrincipal::Uid(1000)
3336 )
3337 .unwrap(),
3338 "the receipt must be one-time"
3339 );
3340 }
3341
3342 #[test]
3343 fn approval_commitment_is_covered_by_the_signed_chain() {
3344 let dir = tempdir().unwrap();
3345 let store = test_store(dir.path().join("tx.db"));
3346 let tx = store.record(queued_transaction()).unwrap();
3347 assert!(tx.approval_id.is_some());
3348
3349 store
3350 .connection()
3351 .unwrap()
3352 .execute(
3353 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
3354 params![tx.transaction_id],
3355 )
3356 .unwrap();
3357
3358 let key = AuditKey::from_bytes(vec![0x42; 32]);
3359 assert!(matches!(
3360 store.verify_audit_chain(&key).unwrap(),
3361 VerifyOutcome::Broken { .. }
3362 ));
3363 }
3364
3365 #[test]
3366 fn approve_rejects_a_forged_commitment_at_runtime() {
3367 let dir = tempdir().unwrap();
3371 let store = test_store(dir.path().join("tx.db"));
3372 let tx = store.record(queued_transaction()).unwrap();
3373
3374 store
3375 .connection()
3376 .unwrap()
3377 .execute(
3378 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
3379 params![tx.transaction_id],
3380 )
3381 .unwrap();
3382
3383 let err = store
3384 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3385 .expect_err("a forged commitment must be rejected, not approved");
3386 assert!(
3387 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
3388 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
3389 );
3390 }
3391
3392 #[test]
3393 fn stale_iso_timestamp_cannot_be_approved() {
3394 let dir = tempdir().unwrap();
3395 let store = test_store(dir.path().join("tx.db"));
3396 let tx = store.record(queued_transaction()).unwrap();
3397 let conn = store.connection().unwrap();
3398 conn.execute(
3399 "UPDATE transactions \
3400 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3401 WHERE transaction_id = ?1",
3402 params![tx.transaction_id],
3403 )
3404 .unwrap();
3405
3406 assert!(
3407 store
3408 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3409 .unwrap()
3410 .is_none(),
3411 "a production-format timestamp outside the TTL must not be approved"
3412 );
3413 }
3414
3415 #[test]
3416 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
3417 let dir = tempdir().unwrap();
3422 let store = test_store(dir.path().join("tx.db"));
3423 let tx = store.record(queued_transaction()).unwrap();
3424 let receipt = store
3425 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3426 .unwrap()
3427 .expect("a fresh approval succeeds");
3428 let digest = audit_chain::approval_receipt_digest(&receipt);
3429
3430 let conn = store.connection().unwrap();
3432 conn.execute(
3433 "UPDATE transactions \
3434 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3435 WHERE transaction_id = ?1",
3436 params![tx.transaction_id],
3437 )
3438 .unwrap();
3439
3440 assert!(
3441 !store
3442 .claim_approved_for_execution(
3443 &tx.transaction_id,
3444 &digest,
3445 CallerPrincipal::Uid(1000)
3446 )
3447 .unwrap(),
3448 "an approval aged past the TTL must not be claimable at execute time"
3449 );
3450 assert_eq!(
3451 store.get(&tx.transaction_id).unwrap().unwrap().status,
3452 JobState::Queued,
3453 "a TTL-expired claim must leave the transaction Queued, never Running"
3454 );
3455 }
3456
3457 #[test]
3458 fn cleanup_stale_queued_cancels_old_records() {
3459 let dir = tempdir().unwrap();
3460 let store = test_store(dir.path().join("tx.db"));
3461
3462 let fresh = store.record(queued_transaction()).unwrap();
3464 let stale = store.record(queued_transaction()).unwrap();
3465
3466 let conn = store.connection().unwrap();
3468 conn.execute(
3469 "UPDATE transactions \
3470 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3471 WHERE transaction_id = ?1",
3472 params![stale.transaction_id],
3473 )
3474 .unwrap();
3475
3476 let canceled = store.cleanup_stale_queued().unwrap();
3477 assert_eq!(canceled, 1, "only the stale record should be canceled");
3478
3479 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
3481 assert_eq!(stale_record.status, JobState::Canceled);
3482
3483 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
3485 assert_eq!(fresh_record.status, JobState::Queued);
3486 }
3487
3488 #[test]
3489 fn cleanup_stale_queued_revokes_every_unconsumed_approval() {
3490 let dir = tempdir().unwrap();
3491 let store = test_store(dir.path().join("tx.db"));
3492 let transactions: Vec<_> = (0..3)
3493 .map(|_| {
3494 let transaction = store.record(queued_transaction()).unwrap();
3495 store
3496 .approve_transaction(&transaction.transaction_id, CallerPrincipal::Uid(1000))
3497 .unwrap()
3498 .expect("stale transaction is approvable");
3499 transaction
3500 })
3501 .collect();
3502
3503 let conn = store.connection().unwrap();
3504 for transaction in &transactions {
3505 conn.execute(
3506 "UPDATE transactions \
3507 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3508 WHERE transaction_id = ?1",
3509 params![transaction.transaction_id],
3510 )
3511 .unwrap();
3512 }
3513
3514 assert_eq!(store.cleanup_stale_queued().unwrap(), 3);
3515 for transaction in &transactions {
3516 assert_eq!(
3517 store
3518 .get(&transaction.transaction_id)
3519 .unwrap()
3520 .unwrap()
3521 .status,
3522 JobState::Canceled
3523 );
3524 assert!(!store
3525 .revoke_unconsumed_approval(&transaction.transaction_id, CallerPrincipal::Uid(1000))
3526 .unwrap());
3527 }
3528 assert_eq!(
3529 store
3530 .fetch_event_rows()
3531 .unwrap()
3532 .iter()
3533 .map(|event| event.kind.as_str())
3534 .collect::<Vec<_>>(),
3535 vec![
3536 "approval_granted",
3537 "approval_granted",
3538 "approval_granted",
3539 "approval_revoked",
3540 "status_canceled",
3541 "approval_revoked",
3542 "status_canceled",
3543 "approval_revoked",
3544 "status_canceled"
3545 ]
3546 );
3547 }
3548
3549 #[test]
3552 fn update_status_rejects_queued_to_succeeded() {
3553 let dir = tempdir().unwrap();
3554 let store = test_store(dir.path().join("tx.db"));
3555 let tx = store.record(queued_transaction()).unwrap();
3556
3557 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
3558 assert!(
3559 matches!(
3560 result,
3561 Err(TransactionStoreError::InvalidTransition {
3562 from: JobState::Queued,
3563 to: JobState::Succeeded,
3564 })
3565 ),
3566 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
3567 );
3568 }
3569
3570 #[test]
3571 fn update_status_rejects_succeeded_to_running() {
3572 let dir = tempdir().unwrap();
3573 let store = test_store(dir.path().join("tx.db"));
3574 let tx = store.record(queued_transaction()).unwrap();
3575
3576 store
3577 .update_status(&tx.transaction_id, JobState::Running)
3578 .unwrap();
3579 store
3580 .update_status(&tx.transaction_id, JobState::Succeeded)
3581 .unwrap();
3582
3583 let result = store.update_status(&tx.transaction_id, JobState::Running);
3584 assert!(
3585 matches!(
3586 result,
3587 Err(TransactionStoreError::InvalidTransition {
3588 from: JobState::Succeeded,
3589 to: JobState::Running,
3590 })
3591 ),
3592 "Succeeded -> Running must be rejected (terminal state): {result:?}"
3593 );
3594 }
3595
3596 #[test]
3597 fn update_status_accepts_running_to_failed() {
3598 let dir = tempdir().unwrap();
3599 let store = test_store(dir.path().join("tx.db"));
3600 let tx = store.record(queued_transaction()).unwrap();
3601
3602 store
3603 .update_status(&tx.transaction_id, JobState::Running)
3604 .unwrap();
3605 store
3606 .update_status(&tx.transaction_id, JobState::Failed)
3607 .unwrap();
3608
3609 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3610 assert_eq!(updated.status, JobState::Failed);
3611 }
3612
3613 #[test]
3614 fn update_status_accepts_running_to_rolled_back() {
3615 let dir = tempdir().unwrap();
3616 let store = test_store(dir.path().join("tx.db"));
3617 let tx = store.record(queued_transaction()).unwrap();
3618
3619 store
3620 .update_status(&tx.transaction_id, JobState::Running)
3621 .unwrap();
3622 store
3623 .update_status(&tx.transaction_id, JobState::RolledBack)
3624 .unwrap();
3625
3626 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3627 assert_eq!(updated.status, JobState::RolledBack);
3628 }
3629
3630 #[test]
3633 fn list_transactions_returns_empty_for_fresh_store() {
3634 let dir = tempdir().unwrap();
3635 let store = test_store(dir.path().join("tx.db"));
3636 let results = store.list_transactions(10, None, None, None).unwrap();
3637 assert!(results.is_empty());
3638 }
3639
3640 #[test]
3641 fn list_transactions_returns_all_records_ordered_by_newest_first() {
3642 let dir = tempdir().unwrap();
3643 let store = test_store(dir.path().join("tx.db"));
3644 store.record(queued_transaction()).unwrap();
3645
3646 let mut second = queued_transaction();
3647 second.action_name = "GetDiskUsage".to_string();
3648 second.risk_level = RiskLevel::Low;
3649 store.record(second).unwrap();
3650
3651 let results = store.list_transactions(10, None, None, None).unwrap();
3652 assert_eq!(results.len(), 2);
3653 assert_eq!(results[0].action_name, "GetDiskUsage");
3655 assert_eq!(results[1].action_name, "UpdateSystem");
3656 }
3657
3658 #[test]
3659 fn list_history_populates_created_at_and_risk_level() {
3660 let dir = tempdir().unwrap();
3661 let store = test_store(dir.path().join("tx.db"));
3662 store.record(queued_transaction()).unwrap();
3663
3664 let entries = store.list_history(10, None, None, None).unwrap();
3665 assert_eq!(entries.len(), 1);
3666 let entry = &entries[0];
3667 assert_eq!(entry.action_name, "UpdateSystem");
3668 assert_eq!(entry.risk_level, RiskLevel::High);
3669 assert_eq!(entry.status, JobState::Queued);
3670 assert!(
3671 !entry.created_at.is_empty(),
3672 "created_at must be populated from the stored row, not left blank"
3673 );
3674 }
3675
3676 #[test]
3677 fn list_history_applies_the_same_filters_as_list_transactions() {
3678 let dir = tempdir().unwrap();
3679 let store = test_store(dir.path().join("tx.db"));
3680 store.record(queued_transaction()).unwrap();
3681 let mut low = queued_transaction();
3682 low.action_name = "GetDiskUsage".to_string();
3683 low.risk_level = RiskLevel::Low;
3684 store.record(low).unwrap();
3685
3686 let only = store
3687 .list_history(10, None, Some("GetDiskUsage"), None)
3688 .unwrap();
3689 assert_eq!(only.len(), 1);
3690 assert_eq!(only[0].action_name, "GetDiskUsage");
3691 }
3692
3693 #[test]
3694 fn list_transactions_respects_limit() {
3695 let dir = tempdir().unwrap();
3696 let store = test_store(dir.path().join("tx.db"));
3697 for _ in 0..5 {
3698 store.record(queued_transaction()).unwrap();
3699 }
3700 let results = store.list_transactions(3, None, None, None).unwrap();
3701 assert_eq!(results.len(), 3);
3702 }
3703
3704 #[test]
3705 fn list_transactions_filters_by_status() {
3706 let dir = tempdir().unwrap();
3707 let store = test_store(dir.path().join("tx.db"));
3708 let tx = store.record(queued_transaction()).unwrap();
3709 store
3710 .update_status(&tx.transaction_id, JobState::Running)
3711 .unwrap();
3712 store
3713 .update_status(&tx.transaction_id, JobState::Succeeded)
3714 .unwrap();
3715
3716 store.record(queued_transaction()).unwrap();
3718
3719 let succeeded = store
3720 .list_transactions(10, Some("succeeded"), None, None)
3721 .unwrap();
3722 assert_eq!(succeeded.len(), 1);
3723 assert_eq!(succeeded[0].status, JobState::Succeeded);
3724
3725 let queued = store
3726 .list_transactions(10, Some("queued"), None, None)
3727 .unwrap();
3728 assert_eq!(queued.len(), 1);
3729 assert_eq!(queued[0].status, JobState::Queued);
3730 }
3731
3732 #[test]
3733 fn list_transactions_filters_by_action_name() {
3734 let dir = tempdir().unwrap();
3735 let store = test_store(dir.path().join("tx.db"));
3736 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
3739 disk.action_name = "GetDiskUsage".to_string();
3740 store.record(disk).unwrap();
3741
3742 let results = store
3743 .list_transactions(10, None, Some("GetDiskUsage"), None)
3744 .unwrap();
3745 assert_eq!(results.len(), 1);
3746 assert_eq!(results[0].action_name, "GetDiskUsage");
3747 }
3748
3749 #[test]
3750 fn list_transactions_filters_by_since_hours() {
3751 let dir = tempdir().unwrap();
3752 let store = test_store(dir.path().join("tx.db"));
3753
3754 let old = store.record(queued_transaction()).unwrap();
3756 let conn = store.connection().unwrap();
3757 conn.execute(
3758 "UPDATE transactions \
3759 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
3760 WHERE transaction_id = ?1",
3761 params![old.transaction_id],
3762 )
3763 .unwrap();
3764
3765 store.record(queued_transaction()).unwrap();
3767
3768 let results = store
3770 .list_transactions(10, None, None, Some(HistorySince::Hours(24)))
3771 .unwrap();
3772 assert_eq!(results.len(), 1);
3773
3774 let results = store
3776 .list_transactions(10, None, None, Some(HistorySince::Hours(72)))
3777 .unwrap();
3778 assert_eq!(results.len(), 2);
3779 }
3780
3781 #[test]
3782 fn list_history_filters_by_exact_cutoff_below_one_hour() {
3783 let dir = tempdir().unwrap();
3787 let store = test_store(dir.path().join("tx.db"));
3788 let conn = store.connection().unwrap();
3789 for minutes_ago in [75, 20] {
3790 let tx = store.record(queued_transaction()).unwrap();
3791 conn.execute(
3792 "UPDATE transactions \
3793 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?1) \
3794 WHERE transaction_id = ?2",
3795 params![format!("-{minutes_ago} minutes"), tx.transaction_id],
3796 )
3797 .unwrap();
3798 }
3799
3800 let cutoff = |minutes: i64| {
3801 Some(HistorySince::Cutoff(
3802 chrono::Utc::now() - chrono::Duration::minutes(minutes),
3803 ))
3804 };
3805 assert_eq!(
3806 store
3807 .list_history(10, None, None, cutoff(30))
3808 .unwrap()
3809 .len(),
3810 1
3811 );
3812 assert_eq!(
3813 store
3814 .list_history(10, None, None, cutoff(90))
3815 .unwrap()
3816 .len(),
3817 2
3818 );
3819 assert_eq!(
3820 store
3821 .list_history(10, None, None, cutoff(10))
3822 .unwrap()
3823 .len(),
3824 0
3825 );
3826 assert_eq!(
3827 store
3828 .list_transactions(10, None, None, cutoff(30))
3829 .unwrap()
3830 .len(),
3831 1
3832 );
3833 }
3834
3835 #[test]
3836 fn list_history_cutoff_normalises_offsets_and_legacy_rows() {
3837 let dir = tempdir().unwrap();
3840 let store = test_store(dir.path().join("tx.db"));
3841 let conn = store.connection().unwrap();
3842 let ten_min_ago = chrono::Utc::now() - chrono::Duration::minutes(10);
3843 let shapes = [
3844 ten_min_ago
3845 .with_timezone(&chrono::FixedOffset::east_opt(2 * 3600).unwrap())
3846 .to_rfc3339(),
3847 ten_min_ago.format("%Y-%m-%d %H:%M:%S").to_string(),
3848 ];
3849 for created_at in &shapes {
3850 let tx = store.record(queued_transaction()).unwrap();
3851 conn.execute(
3852 "UPDATE transactions SET created_at = ?1 WHERE transaction_id = ?2",
3853 params![created_at, tx.transaction_id],
3854 )
3855 .unwrap();
3856 }
3857
3858 let at = |minutes: i64| {
3859 Some(HistorySince::Cutoff(
3860 chrono::Utc::now() - chrono::Duration::minutes(minutes),
3861 ))
3862 };
3863 assert_eq!(store.list_history(10, None, None, at(15)).unwrap().len(), 2);
3864 assert_eq!(store.list_history(10, None, None, at(5)).unwrap().len(), 0);
3865 }
3866
3867 #[test]
3868 fn list_transactions_rejects_invalid_status_filter() {
3869 let dir = tempdir().unwrap();
3870 let store = test_store(dir.path().join("tx.db"));
3871 store.record(queued_transaction()).unwrap();
3872 let result = store.list_transactions(10, Some("bogus"), None, None);
3873 assert!(result.is_err(), "invalid status filter should return error");
3874 }
3875
3876 #[test]
3884 fn record_emits_one_watermark_per_entry() {
3885 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3886 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3887
3888 let dir = tempdir().unwrap();
3889 let store = test_store(dir.path().join("tx.db"));
3890 store.record(queued_transaction()).unwrap();
3891
3892 let calls = crate::audit_watermark::take_watermarks(&sink);
3893 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
3894 }
3895
3896 #[test]
3898 fn record_previewed_emits_one_watermark() {
3899 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3900 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3901
3902 let dir = tempdir().unwrap();
3903 let store = test_store(dir.path().join("tx.db"));
3904 let preview = PreviewEnvelope {
3905 summary: "Upgrade the system".to_string(),
3906 risk_level: RiskLevel::High,
3907 current_state: serde_json::Value::Null,
3908 proposed_change: serde_json::Value::Null,
3909 expected_side_effects: vec![],
3910 reboot_required: false,
3911 rollback_available: false,
3912 warnings: vec![],
3913 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
3914 };
3915 store
3916 .record_previewed(queued_transaction(), preview)
3917 .unwrap();
3918
3919 let calls = crate::audit_watermark::take_watermarks(&sink);
3920 assert_eq!(
3921 calls.len(),
3922 1,
3923 "expected exactly 1 watermark per record_previewed()"
3924 );
3925 }
3926
3927 #[test]
3929 fn watermark_seq_and_hash_match_chain_row() {
3930 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3931 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3932
3933 let dir = tempdir().unwrap();
3934 let store = test_store(dir.path().join("tx.db"));
3935 store.record(queued_transaction()).unwrap();
3936
3937 let rows = store.fetch_chain_rows().unwrap();
3938 assert_eq!(rows.len(), 1);
3939 let row = &rows[0];
3940
3941 let calls = crate::audit_watermark::take_watermarks(&sink);
3942 assert_eq!(calls.len(), 1);
3943 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
3944 assert_eq!(
3945 calls[0].chain_hash_hex, row.chain_hash,
3946 "watermark chain_hash_hex must match stored chain_hash"
3947 );
3948 }
3949
3950 #[test]
3952 fn multiple_records_produce_one_watermark_each() {
3953 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3954 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3955
3956 let dir = tempdir().unwrap();
3957 let store = test_store(dir.path().join("tx.db"));
3958 for _ in 0..3 {
3959 store.record(queued_transaction()).unwrap();
3960 }
3961
3962 let calls = crate::audit_watermark::take_watermarks(&sink);
3963 assert_eq!(calls.len(), 3, "one watermark per record call");
3964 assert_eq!(calls[0].seq, 1);
3965 assert_eq!(calls[1].seq, 2);
3966 assert_eq!(calls[2].seq, 3);
3967 }
3968
3969 #[test]
3983 fn no_watermark_emitted_before_any_record() {
3984 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3985 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3986
3987 let dir = tempdir().unwrap();
3988 let _store = test_store(dir.path().join("tx.db"));
3989
3990 let calls = crate::audit_watermark::take_watermarks(&sink);
3992 assert!(
3993 calls.is_empty(),
3994 "no watermark must be emitted without a record() call"
3995 );
3996 }
3997}