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, Debug, PartialEq, Eq)]
50pub struct NewTransaction {
51 pub request_id: String,
52 pub request_hash: String,
53 pub action_name: String,
54 pub risk_level: RiskLevel,
55 pub summary: String,
75 pub warnings: Vec<String>,
76 pub caller_role: CallerRole,
85 pub caller_principal: CallerPrincipal,
93}
94
95struct SqliteMigration {
104 version: i64,
105 name: &'static str,
106 sql: &'static str,
107}
108
109const SQLITE_MIGRATIONS: &[SqliteMigration] = &[
127 SqliteMigration {
128 version: 1,
129 name: "initial_audit_schema",
130 sql: r#"
131 CREATE TABLE IF NOT EXISTS transactions (
132 transaction_id TEXT PRIMARY KEY,
133 request_id TEXT NOT NULL,
134 request_hash TEXT NOT NULL,
135 action_name TEXT NOT NULL,
136 risk_level TEXT NOT NULL,
137 status TEXT NOT NULL,
138 approval_id TEXT,
139 summary TEXT NOT NULL,
140 warnings_json TEXT NOT NULL,
141 created_at TEXT NOT NULL DEFAULT (datetime('now')),
142 seq INTEGER NOT NULL UNIQUE,
143 key_id TEXT NOT NULL,
144 chain_hash TEXT NOT NULL,
145 prev_chain_hash TEXT NOT NULL DEFAULT ''
146 );
147
148 CREATE TABLE IF NOT EXISTS transaction_previews (
149 transaction_id TEXT PRIMARY KEY,
150 preview_json TEXT NOT NULL
151 );
152
153 CREATE TABLE IF NOT EXISTS transaction_approvals (
154 transaction_id TEXT PRIMARY KEY,
155 receipt_digest TEXT NOT NULL,
156 approved_at TEXT NOT NULL DEFAULT (datetime('now')),
157 consumed_at TEXT
158 );
159
160 CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
161 "#,
162 },
163 SqliteMigration {
172 version: 2,
173 name: "caller_identity_and_approval_events",
174 sql: r#"
175 ALTER TABLE transactions ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
176 ALTER TABLE transactions ADD COLUMN caller_role TEXT;
177 ALTER TABLE transactions ADD COLUMN event_tip TEXT;
178
179 CREATE TABLE IF NOT EXISTS audit_events (
180 seq INTEGER PRIMARY KEY,
181 key_id TEXT NOT NULL,
182 kind TEXT NOT NULL,
183 transaction_id TEXT NOT NULL,
184 receipt_digest TEXT NOT NULL,
185 created_at TEXT NOT NULL,
186 chain_hash TEXT NOT NULL,
187 prev_chain_hash TEXT NOT NULL DEFAULT ''
188 );
189
190 CREATE INDEX IF NOT EXISTS audit_events_transaction_idx
191 ON audit_events(transaction_id);
192 "#,
193 },
194 SqliteMigration {
199 version: 3,
200 name: "caller_principal",
201 sql: r#"
202 ALTER TABLE transactions ADD COLUMN caller_principal TEXT;
203 "#,
204 },
205 SqliteMigration {
214 version: 4,
215 name: "event_approver_identity",
216 sql: r#"
217 ALTER TABLE audit_events ADD COLUMN chain_version INTEGER NOT NULL DEFAULT 1;
218 ALTER TABLE audit_events ADD COLUMN caller_principal TEXT;
219 "#,
220 },
221];
222
223use crate::store::CHAIN_ROW_COLUMNS;
227
228fn chain_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainRow> {
229 Ok(ChainRow {
230 seq: row.get::<_, i64>(0)? as u64,
231 key_id: row.get(1)?,
232 transaction_id: row.get(2)?,
233 request_id: row.get(3)?,
234 request_hash: row.get(4)?,
235 action_name: row.get(5)?,
236 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
237 rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
238 })?,
239 summary: row.get(7)?,
240 approval_id: row.get(8)?,
241 warnings_json: row.get(9)?,
242 created_at: row.get(10)?,
243 prev_chain_hash: row.get(11)?,
244 chain_hash: row.get(12)?,
245 chain_version: row.get::<_, i64>(13)? as u32,
246 caller_role: row.get(14)?,
247 event_tip: row.get(15)?,
248 caller_principal: row.get(16)?,
249 })
250}
251
252fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<EventRow> {
253 Ok(EventRow {
254 seq: row.get::<_, i64>(0)? as u64,
255 key_id: row.get(1)?,
256 kind: row.get(2)?,
257 transaction_id: row.get(3)?,
258 receipt_digest: row.get(4)?,
259 created_at: row.get(5)?,
260 prev_chain_hash: row.get(6)?,
261 chain_hash: row.get(7)?,
262 chain_version: row.get::<_, i64>(8)? as u32,
263 caller_principal: row.get(9)?,
264 })
265}
266
267fn query_optional<T>(
273 conn: &Connection,
274 sql: &str,
275 params: impl rusqlite::Params,
276 f: impl FnOnce(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
277) -> rusqlite::Result<Option<T>> {
278 match conn.query_row(sql, params, f) {
279 Ok(v) => Ok(Some(v)),
280 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
281 Err(e) => Err(e),
282 }
283}
284
285#[derive(Clone, Debug)]
286pub struct TransactionStore {
287 path: PathBuf,
288 audit_key: Option<Arc<AuditKey>>,
291}
292
293#[derive(Clone, Debug, PartialEq)]
294pub struct RecordedPreviewedTransaction {
295 pub transaction: TransactionRecord,
296 pub preview: PreviewEnvelope,
297}
298
299struct InsertedTransaction {
300 record: TransactionRecord,
301 seq: u64,
302 chain_hash: String,
303}
304
305#[derive(Debug, thiserror::Error)]
306pub enum TransactionStoreError {
307 #[error("io error: {0}")]
308 Io(#[from] std::io::Error),
309
310 #[error("sqlite error: {0}")]
311 Sqlite(#[from] rusqlite::Error),
312
313 #[error("json error: {0}")]
314 Json(#[from] serde_json::Error),
315
316 #[error("database invariant violation: {0}")]
317 DatabaseInvariant(String),
318
319 #[error("transaction not found: {0}")]
320 NotFound(String),
321
322 #[error("invalid transition from {from:?} to {to:?}")]
323 InvalidTransition { from: JobState, to: JobState },
324
325 #[error("audit chain misconfiguration: {0}")]
326 AuditChainMissing(&'static str),
327
328 #[error("transaction {0} status changed concurrently; retry")]
336 ConcurrentStatusChange(String),
337}
338
339impl TransactionStore {
340 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
344 let path = path.as_ref().to_path_buf();
345 if let Some(parent) = path.parent() {
346 ensure_private_dir(parent)?;
347 }
348
349 let store = Self {
350 path,
351 audit_key: None,
352 };
353 store.initialize()?;
354 Ok(store)
355 }
356
357 pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
364 let db_path = path.as_ref();
365 let key_path = crate::audit_chain::resolve_audit_key_path(db_path);
366 let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
367 TransactionStoreError::Io(std::io::Error::new(
368 std::io::ErrorKind::InvalidData,
369 format!("audit key load failed: {e}"),
370 ))
371 })?;
372 Self::open_with_key(path, Arc::new(key))
373 }
374
375 pub fn open_with_key(
378 path: impl AsRef<Path>,
379 audit_key: Arc<AuditKey>,
380 ) -> Result<Self, TransactionStoreError> {
381 let path = path.as_ref().to_path_buf();
382 if let Some(parent) = path.parent() {
383 ensure_private_dir(parent)?;
384 }
385
386 let store = Self {
387 path,
388 audit_key: Some(audit_key),
389 };
390 store.initialize()?;
391 restrict_db_file_mode(&store.path);
392 Ok(store)
393 }
394
395 fn record_with(
404 &self,
405 transaction: NewTransaction,
406 in_tx: impl FnOnce(&Connection, &InsertedTransaction) -> Result<(), TransactionStoreError>,
407 ) -> Result<InsertedTransaction, TransactionStoreError> {
408 let key = self
409 .audit_key
410 .as_ref()
411 .ok_or(TransactionStoreError::AuditChainMissing(
412 "this TransactionStore was opened read-only; cannot record",
413 ))?;
414 let mut conn = self.connection()?;
415 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
420 let transaction_id = Uuid::new_v4().to_string();
421 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
422 in_tx(&tx, &inserted)?;
423 tx.commit()?;
424 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
425 Ok(inserted)
426 }
427
428 pub fn record(
429 &self,
430 transaction: NewTransaction,
431 ) -> Result<TransactionRecord, TransactionStoreError> {
432 let inserted = self.record_with(transaction, |_, _| Ok(()))?;
433 Ok(inserted.record)
434 }
435
436 pub fn record_previewed(
437 &self,
438 transaction: NewTransaction,
439 preview: PreviewEnvelope,
440 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
441 let inserted = self.record_with(transaction, |conn, inserted| {
442 Self::insert_preview(conn, &inserted.record.transaction_id, &preview)
443 })?;
444 Ok(RecordedPreviewedTransaction {
445 transaction: inserted.record,
446 preview,
447 })
448 }
449
450 pub fn get(
451 &self,
452 transaction_id: &str,
453 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
454 let conn = self.connection()?;
455 let mut stmt = conn.prepare(
456 "SELECT
457 transaction_id,
458 request_id,
459 request_hash,
460 action_name,
461 risk_level,
462 status,
463 approval_id,
464 summary,
465 warnings_json
466 FROM transactions
467 WHERE transaction_id = ?1",
468 )?;
469 let mut rows = stmt.query(params![transaction_id])?;
470 if let Some(row) = rows.next()? {
471 Ok(Some(row_to_record(row)?))
472 } else {
473 Ok(None)
474 }
475 }
476
477 pub fn get_preview(
478 &self,
479 transaction_id: &str,
480 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
481 let conn = self.connection()?;
482 let mut stmt = conn.prepare(
483 "SELECT preview_json
484 FROM transaction_previews
485 WHERE transaction_id = ?1",
486 )?;
487 let mut rows = stmt.query(params![transaction_id])?;
488 if let Some(row) = rows.next()? {
489 let preview_json: String = row.get(0)?;
490 Ok(Some(serde_json::from_str(&preview_json)?))
491 } else {
492 Ok(None)
493 }
494 }
495
496 pub fn update_status(
497 &self,
498 transaction_id: &str,
499 new_status: JobState,
500 ) -> Result<(), TransactionStoreError> {
501 let conn = self.connection()?;
502
503 let current_status: String = conn
505 .query_row(
506 "SELECT status FROM transactions WHERE transaction_id = ?1",
507 params![transaction_id],
508 |row| row.get(0),
509 )
510 .map_err(|e| match e {
511 rusqlite::Error::QueryReturnedNoRows => {
512 TransactionStoreError::NotFound(transaction_id.to_string())
513 }
514 other => TransactionStoreError::Sqlite(other),
515 })?;
516
517 let current: JobState = deserialize_field(¤t_status)?;
518 if !crate::jobs::allowed_transition(¤t, &new_status) {
519 return Err(TransactionStoreError::InvalidTransition {
520 from: current,
521 to: new_status,
522 });
523 }
524
525 let mut conn = conn;
542 let tx = conn.transaction()?;
543 let rows_affected = tx.execute(
544 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
545 params![
546 serialize_field(&new_status)?,
547 transaction_id,
548 current_status
549 ],
550 )?;
551 if rows_affected == 0 {
552 return Err(TransactionStoreError::ConcurrentStatusChange(
553 transaction_id.to_string(),
554 ));
555 }
556 let Some(key) = self.audit_key.as_ref() else {
562 return Err(TransactionStoreError::AuditChainMissing(
563 "update_status has no signing key, so the outcome could not be chained",
564 ));
565 };
566 Self::append_event(
567 &tx,
568 key,
569 AuditEventKind::from(new_status),
570 transaction_id,
571 "",
572 EventIdentity::LegacyV1,
576 )?;
577 tx.commit()?;
578 Ok(())
579 }
580
581 pub fn status_matches_chain(
594 &self,
595 ) -> Result<audit_chain::StatusOutcome, TransactionStoreError> {
596 let conn = self.connection()?;
597 let mut stmt = conn.prepare(
598 "SELECT t.transaction_id, t.status, ( \
599 SELECT e.kind FROM audit_events e \
600 WHERE e.transaction_id = t.transaction_id AND e.kind LIKE 'status_%' \
601 ORDER BY e.seq DESC LIMIT 1 \
602 ), EXISTS ( \
603 SELECT 1 FROM audit_events e \
604 WHERE e.transaction_id = t.transaction_id AND e.kind = 'approval_consumed' \
605 ) FROM transactions t",
606 )?;
607 let mut disagreements = Vec::new();
608 let mut unproven = Vec::new();
609 let mut checked: u64 = 0;
610 let rows = stmt.query_map([], |row| {
611 Ok((
612 row.get::<_, String>(0)?,
613 row.get::<_, String>(1)?,
614 row.get::<_, Option<String>>(2)?,
615 row.get::<_, bool>(3)?,
616 ))
617 })?;
618 for row in rows {
619 let (id, status_json, newest, consumed) = row?;
620 checked += 1;
621 let state: JobState = deserialize_field(&status_json)?;
622 let Some(newest) = newest else {
627 if state == JobState::Queued && consumed {
628 disagreements.push(format!(
629 "{id}: status is Queued but a chained approval_consumed event proves it left Queued"
630 ));
631 } else if matches!(state, JobState::Running | JobState::Canceled) {
632 unproven.push(format!(
633 "{id}: status is {state:?} and no status event was ever chained; \
634 historical writes cannot establish the execution outcome"
635 ));
636 } else if state != JobState::Queued {
637 disagreements.push(format!(
638 "{id}: status is {state:?} but no status event was ever chained; \
639 terminal outcomes require a signed status event"
640 ));
641 }
642 continue;
643 };
644 let expected = AuditEventKind::from(state).as_str();
645 if newest != expected {
646 disagreements.push(format!(
647 "{id}: status column says {state:?} ({expected}) and the newest \
648 chained status event is {newest}"
649 ));
650 }
651 }
652 Ok(if !disagreements.is_empty() {
653 audit_chain::StatusOutcome::Disagrees {
654 detail: disagreements.join("; "),
655 }
656 } else if !unproven.is_empty() {
657 audit_chain::StatusOutcome::CannotVerify {
658 detail: unproven.join("; "),
659 }
660 } else {
661 audit_chain::StatusOutcome::Agrees {
662 rows_checked: checked,
663 }
664 })
665 }
666
667 pub fn approve_transaction(
672 &self,
673 transaction_id: &str,
674 approver: CallerPrincipal,
675 ) -> Result<Option<String>, TransactionStoreError> {
676 let key = self
677 .audit_key
678 .as_ref()
679 .ok_or(TransactionStoreError::AuditChainMissing(
680 "this TransactionStore was opened read-only; cannot approve",
681 ))?;
682 let approver_signed = approver.as_signed_str();
685 let Some(record) = self.get(transaction_id)? else {
686 return Ok(None);
687 };
688 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
689 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
690 let Some(committed_digest) = record.approval_id.as_deref() else {
691 return Err(TransactionStoreError::DatabaseInvariant(format!(
692 "transaction {transaction_id} has no signed approval commitment"
693 )));
694 };
695 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
696 return Err(TransactionStoreError::DatabaseInvariant(format!(
697 "transaction {transaction_id} approval commitment does not match its signed preview"
698 )));
699 }
700
701 let mut conn = self.connection()?;
704 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
705 let queued_json = serialize_field(&JobState::Queued)?;
706 let rows_affected = tx.execute(
707 &format!(
708 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
709 SELECT transaction_id, ?1 FROM transactions \
710 WHERE transaction_id = ?2 \
711 AND status = ?3 \
712 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
713 AND NOT EXISTS ( \
714 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
715 )"
716 ),
717 params![receipt_digest, transaction_id, queued_json],
718 )?;
719 if rows_affected > 0 {
720 Self::append_event(
721 &tx,
722 key,
723 AuditEventKind::ApprovalGranted,
724 transaction_id,
725 &receipt_digest,
726 EventIdentity::V2 {
727 caller_principal: &approver_signed,
728 },
729 )?;
730 }
731 tx.commit()?;
732 Ok((rows_affected > 0).then_some(receipt))
733 }
734
735 pub fn revoke_unconsumed_approval(
741 &self,
742 transaction_id: &str,
743 revoker: CallerPrincipal,
744 ) -> Result<bool, TransactionStoreError> {
745 let key = self
746 .audit_key
747 .as_ref()
748 .ok_or(TransactionStoreError::AuditChainMissing(
749 "this TransactionStore was opened read-only; cannot revoke",
750 ))?;
751 let mut conn = self.connection()?;
752 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
753 let revoked = Self::revoke_unconsumed_approval_in_tx(
754 &tx,
755 key,
756 transaction_id,
757 &revoker.as_signed_str(),
758 )?;
759 tx.commit()?;
760 Ok(revoked)
761 }
762
763 fn revoke_unconsumed_approval_in_tx(
764 conn: &Connection,
765 key: &AuditKey,
766 transaction_id: &str,
767 revoker_signed: &str,
768 ) -> Result<bool, TransactionStoreError> {
769 let digest: Option<String> = query_optional(
772 conn,
773 "SELECT receipt_digest FROM transaction_approvals \
774 WHERE transaction_id = ?1 AND consumed_at IS NULL",
775 params![transaction_id],
776 |row| row.get(0),
777 )?;
778 let rows_affected = conn.execute(
779 "DELETE FROM transaction_approvals \
780 WHERE transaction_id = ?1 AND consumed_at IS NULL",
781 params![transaction_id],
782 )?;
783 if rows_affected > 0 {
784 let digest = digest.ok_or_else(|| {
785 TransactionStoreError::DatabaseInvariant(format!(
786 "revoked an approval for {transaction_id} that had no receipt digest"
787 ))
788 })?;
789 Self::append_event(
790 conn,
791 key,
792 AuditEventKind::ApprovalRevoked,
793 transaction_id,
794 &digest,
795 EventIdentity::V2 {
796 caller_principal: revoker_signed,
797 },
798 )?;
799 }
800 Ok(rows_affected > 0)
801 }
802
803 pub fn claim_approved_for_execution(
809 &self,
810 transaction_id: &str,
811 receipt_digest: &str,
812 executor: CallerPrincipal,
813 ) -> Result<bool, TransactionStoreError> {
814 let key = self
815 .audit_key
816 .as_ref()
817 .ok_or(TransactionStoreError::AuditChainMissing(
818 "this TransactionStore was opened read-only; cannot claim",
819 ))?;
820 let mut conn = self.connection()?;
821 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
822 let queued_json = serialize_field(&JobState::Queued)?;
823 let running_json = serialize_field(&JobState::Running)?;
824 let rows_affected = tx.execute(
825 &format!(
826 "UPDATE transactions SET status = ?1 \
827 WHERE transaction_id = ?2 \
828 AND status = ?3 \
829 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
830 AND EXISTS ( \
831 SELECT 1 FROM transaction_approvals \
832 WHERE transaction_id = ?2 \
833 AND receipt_digest = ?4 \
834 AND consumed_at IS NULL \
835 )"
836 ),
837 params![running_json, transaction_id, queued_json, receipt_digest],
838 )?;
839 if rows_affected > 0 {
840 tx.execute(
841 "UPDATE transaction_approvals \
842 SET consumed_at = datetime('now') \
843 WHERE transaction_id = ?1 AND consumed_at IS NULL",
844 params![transaction_id],
845 )?;
846 Self::append_event(
847 &tx,
848 key,
849 AuditEventKind::ApprovalConsumed,
850 transaction_id,
851 receipt_digest,
852 EventIdentity::V2 {
853 caller_principal: &executor.as_signed_str(),
854 },
855 )?;
856 Self::append_event(
857 &tx,
858 key,
859 AuditEventKind::StatusRunning,
860 transaction_id,
861 "",
862 EventIdentity::LegacyV1,
863 )?;
864 }
865 tx.commit()?;
866 Ok(rows_affected > 0)
867 }
868
869 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
884 let key = self
885 .audit_key
886 .as_ref()
887 .ok_or(TransactionStoreError::AuditChainMissing(
888 "this TransactionStore was opened read-only; cannot clean up",
889 ))?;
890 let mut conn = self.connection()?;
891 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
892 let canceled_json = serialize_field(&JobState::Canceled)?;
893 let queued_json = serialize_field(&JobState::Queued)?;
894 let stale_ids: Vec<String> = {
895 let mut statement = tx.prepare(&format!(
896 "SELECT transaction_id FROM transactions \
897 WHERE status = ?1 \
898 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
899 ))?;
900 let stale_ids = statement
901 .query_map(params![queued_json], |row| row.get(0))?
902 .collect::<Result<_, _>>()?;
903 stale_ids
904 };
905 let mut canceled = 0;
906 for transaction_id in stale_ids {
907 let rows_affected = tx.execute(
908 "UPDATE transactions SET status = ?1 \
909 WHERE transaction_id = ?2 AND status = ?3",
910 params![canceled_json, transaction_id, queued_json],
911 )?;
912 if rows_affected > 0 {
913 Self::revoke_unconsumed_approval_in_tx(
917 &tx,
918 key,
919 &transaction_id,
920 &CallerPrincipal::Unattributed.as_signed_str(),
921 )?;
922 Self::append_event(
923 &tx,
924 key,
925 AuditEventKind::StatusCanceled,
926 &transaction_id,
927 "",
928 EventIdentity::LegacyV1,
929 )?;
930 canceled += rows_affected;
931 }
932 }
933 tx.commit()?;
934 Ok(canceled as u64)
935 }
936
937 pub fn cancel_queued(
946 &self,
947 transaction_id: &str,
948 canceller: CallerPrincipal,
949 ) -> Result<bool, TransactionStoreError> {
950 let key = self
951 .audit_key
952 .as_ref()
953 .ok_or(TransactionStoreError::AuditChainMissing(
954 "this TransactionStore was opened read-only; cannot cancel",
955 ))?;
956 let mut conn = self.connection()?;
957 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
958 let canceled_json = serialize_field(&JobState::Canceled)?;
959 let queued_json = serialize_field(&JobState::Queued)?;
960 let rows_affected = tx.execute(
961 "UPDATE transactions SET status = ?1 \
962 WHERE transaction_id = ?2 AND status = ?3",
963 params![canceled_json, transaction_id, queued_json],
964 )?;
965 if rows_affected > 0 {
966 Self::revoke_unconsumed_approval_in_tx(
971 &tx,
972 key,
973 transaction_id,
974 &canceller.as_signed_str(),
975 )?;
976 Self::append_event(
977 &tx,
978 key,
979 AuditEventKind::StatusCanceled,
980 transaction_id,
981 "",
982 EventIdentity::LegacyV1,
983 )?;
984 }
985 tx.commit()?;
986 Ok(rows_affected > 0)
987 }
988
989 pub fn list_transactions(
998 &self,
999 limit: u32,
1000 status_filter: Option<&str>,
1001 action_filter: Option<&str>,
1002 since_hours: Option<u32>,
1003 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
1004 let conn = self.connection()?;
1005 let (filter_sql, param_values) =
1006 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
1007 let sql = format!(
1008 "SELECT transaction_id, request_id, request_hash, action_name, \
1009 risk_level, status, approval_id, summary, warnings_json \
1010 FROM transactions WHERE 1=1{filter_sql}"
1011 );
1012 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
1013 param_values.iter().map(|b| b.as_ref()).collect();
1014
1015 let mut stmt = conn.prepare(&sql)?;
1016 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
1017
1018 let mut results = Vec::new();
1019 for row in rows {
1020 results.push(row??);
1021 }
1022 Ok(results)
1023 }
1024
1025 pub fn list_history(
1030 &self,
1031 limit: u32,
1032 status_filter: Option<&str>,
1033 action_filter: Option<&str>,
1034 since_hours: Option<u32>,
1035 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
1036 let conn = self.connection()?;
1037 let (filter_sql, param_values) =
1038 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
1039 let sql = format!(
1040 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
1041 FROM transactions WHERE 1=1{filter_sql}"
1042 );
1043 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
1044 param_values.iter().map(|b| b.as_ref()).collect();
1045
1046 let mut stmt = conn.prepare(&sql)?;
1047 let rows = stmt.query_map(params_ref.as_slice(), |row| {
1048 Ok((|| {
1049 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
1050 transaction_id: row.get(0)?,
1051 action_name: row.get(1)?,
1052 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
1053 status: deserialize_field(&row.get::<_, String>(3)?)?,
1054 summary: row.get(4)?,
1055 created_at: row.get(5)?,
1056 })
1057 })())
1058 })?;
1059
1060 let mut results = Vec::new();
1061 for row in rows {
1062 results.push(row??);
1063 }
1064 Ok(results)
1065 }
1066
1067 fn build_history_filter(
1072 limit: u32,
1073 status_filter: Option<&str>,
1074 action_filter: Option<&str>,
1075 since_hours: Option<u32>,
1076 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
1077 let mut sql = String::new();
1078 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1079
1080 if let Some(status) = status_filter {
1081 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
1085 let status_json = serialize_field(&validated)?;
1086 sql.push_str(" AND status = ?");
1087 param_values.push(Box::new(status_json));
1088 }
1089
1090 if let Some(action) = action_filter {
1091 sql.push_str(" AND action_name = ?");
1092 param_values.push(Box::new(action.to_string()));
1093 }
1094
1095 if let Some(hours) = since_hours {
1096 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
1097 param_values.push(Box::new(hours));
1098 }
1099
1100 sql.push_str(" ORDER BY seq DESC LIMIT ?");
1101 param_values.push(Box::new(limit.min(100)));
1102
1103 Ok((sql, param_values))
1104 }
1105
1106 fn connection(&self) -> Result<Connection, TransactionStoreError> {
1107 let conn = Connection::open(&self.path)?;
1108 conn.pragma_update(None, "journal_mode", "WAL")?;
1119 conn.pragma_update(None, "synchronous", "NORMAL")?;
1120 conn.pragma_update(None, "foreign_keys", "ON")?;
1121 conn.busy_timeout(std::time::Duration::from_secs(5))?;
1122 Ok(conn)
1123 }
1124
1125 fn initialize(&self) -> Result<(), TransactionStoreError> {
1126 let mut conn = self.connection()?;
1127 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
1128 tx.execute_batch(
1129 "CREATE TABLE IF NOT EXISTS schema_migrations (\
1130 version INTEGER PRIMARY KEY,\
1131 name TEXT NOT NULL,\
1132 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
1133 );",
1134 )?;
1135 let current: i64 = tx.query_row(
1136 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
1137 [],
1138 |row| row.get(0),
1139 )?;
1140 let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
1141 if current > latest {
1142 return Err(TransactionStoreError::DatabaseInvariant(format!(
1143 "sqlite schema version {current} is newer than this binary supports ({latest})"
1144 )));
1145 }
1146 for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
1147 tx.execute_batch(migration.sql)?;
1148 tx.execute(
1149 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1150 params![migration.version, migration.name],
1151 )?;
1152 }
1153 tx.commit()?;
1154 Ok(())
1155 }
1156
1157 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
1159 let conn = self.connection()?;
1160 let mut stmt = conn.prepare(&format!(
1161 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
1162 ))?;
1163 let rows = stmt.query_map([], chain_row_from_sqlite)?;
1164 let mut out = Vec::new();
1165 for row in rows {
1166 out.push(row?);
1167 }
1168 Ok(out)
1169 }
1170
1171 pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
1173 let conn = self.connection()?;
1174 let mut stmt = conn.prepare(
1175 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
1176 created_at, prev_chain_hash, chain_hash, chain_version, caller_principal \
1177 FROM audit_events ORDER BY seq ASC",
1178 )?;
1179 let rows = stmt.query_map([], event_row_from_sqlite)?;
1180 let mut out = Vec::new();
1181 for row in rows {
1182 out.push(row?);
1183 }
1184 Ok(out)
1185 }
1186
1187 pub fn verify_audit_chain(
1189 &self,
1190 key: &AuditKey,
1191 ) -> Result<VerifyOutcome, TransactionStoreError> {
1192 let rows = self.fetch_chain_rows()?;
1193 Ok(audit_chain::verify_chain(key, &rows))
1194 }
1195
1196 pub fn verify_event_chain(
1198 &self,
1199 key: &AuditKey,
1200 ) -> Result<VerifyOutcome, TransactionStoreError> {
1201 let rows = self.fetch_event_rows()?;
1202 Ok(audit_chain::verify_event_chain(key, &rows))
1203 }
1204
1205 pub fn verify_event_binding(
1208 &self,
1209 ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
1210 let tx_rows = self.fetch_chain_rows()?;
1211 let event_rows = self.fetch_event_rows()?;
1212 Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
1213 }
1214
1215 pub fn verify_audit_chain_with_pubkey(
1218 &self,
1219 verifying_key_hex: &str,
1220 ) -> Result<VerifyOutcome, TransactionStoreError> {
1221 let rows = self.fetch_chain_rows()?;
1222 Ok(audit_chain::verify_chain_with_pubkey(
1223 verifying_key_hex,
1224 &rows,
1225 ))
1226 }
1227
1228 pub fn fetch_chain_row(
1231 &self,
1232 transaction_id: &str,
1233 ) -> Result<Option<ChainRow>, TransactionStoreError> {
1234 let conn = self.connection()?;
1235 let mut stmt = conn.prepare(&format!(
1236 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
1237 ))?;
1238 let mut rows = stmt.query(params![transaction_id])?;
1239 if let Some(row) = rows.next()? {
1240 Ok(Some(chain_row_from_sqlite(row)?))
1241 } else {
1242 Ok(None)
1243 }
1244 }
1245
1246 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
1250 let prev: Option<(i64, String)> = query_optional(
1251 conn,
1252 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
1253 [],
1254 |row| Ok((row.get(0)?, row.get(1)?)),
1255 )?;
1256 Ok(match prev {
1257 Some((seq, hash)) => ((seq as u64) + 1, hash),
1258 None => (1, String::new()),
1259 })
1260 }
1261
1262 fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
1265 Ok(query_optional(
1266 conn,
1267 "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
1268 [],
1269 |row| row.get(0),
1270 )?)
1271 }
1272
1273 fn append_event(
1278 conn: &Connection,
1279 key: &AuditKey,
1280 kind: AuditEventKind,
1281 transaction_id: &str,
1282 receipt_digest: &str,
1283 identity: EventIdentity<'_>,
1284 ) -> Result<(), TransactionStoreError> {
1285 let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1286 let seq: i64 = conn.query_row(
1287 "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1288 [],
1289 |row| row.get(0),
1290 )?;
1291 let created_at: String =
1292 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1293 row.get(0)
1294 })?;
1295 let key_id = CURRENT_KEY_ID.to_string();
1296 let content = EventContent {
1301 seq: seq as u64,
1302 key_id: &key_id,
1303 kind,
1304 transaction_id,
1305 receipt_digest,
1306 created_at: &created_at,
1307 identity,
1308 };
1309 let chain_hash = key.event_hash(&content, &prev_chain_hash);
1310 let stored_principal = match identity {
1311 EventIdentity::V2 { caller_principal } => Some(caller_principal),
1312 EventIdentity::LegacyV1 => None,
1313 };
1314 conn.execute(
1315 "INSERT INTO audit_events (\
1316 seq, key_id, kind, transaction_id, receipt_digest, \
1317 created_at, chain_hash, prev_chain_hash, chain_version, caller_principal \
1318 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1319 params![
1320 seq,
1321 key_id,
1322 kind.as_str(),
1323 transaction_id,
1324 receipt_digest,
1325 created_at,
1326 chain_hash,
1327 prev_chain_hash,
1328 identity.version() as i64,
1329 stored_principal,
1330 ],
1331 )?;
1332 Ok(())
1333 }
1334
1335 fn insert_transaction(
1336 conn: &Connection,
1337 key: &AuditKey,
1338 transaction_id: &str,
1339 transaction: NewTransaction,
1340 ) -> Result<InsertedTransaction, TransactionStoreError> {
1341 let request_id = transaction.request_id;
1342 let request_hash = transaction.request_hash;
1343 let action_name = transaction.action_name;
1344 let risk_level = transaction.risk_level;
1345 let status = JobState::Queued;
1347 let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1354 let summary = transaction.summary;
1355 let warnings = transaction.warnings;
1356 let warnings_json = serde_json::to_string(&warnings)?;
1357
1358 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1361
1362 let created_at: String =
1366 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1367 row.get(0)
1368 })?;
1369
1370 let key_id = CURRENT_KEY_ID.to_string();
1371 let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1375 let caller_role = transaction.caller_role.as_str();
1376 let caller_principal = transaction.caller_principal.as_signed_str();
1377 let identity = ChainIdentity::V3 {
1381 caller_role,
1382 event_tip: &event_tip,
1383 caller_principal: &caller_principal,
1384 };
1385 let chain_hash = key.chain_hash(
1386 &ChainContent {
1387 seq,
1388 key_id: &key_id,
1389 transaction_id,
1390 request_id: &request_id,
1391 request_hash: &request_hash,
1392 action_name: &action_name,
1393 risk_level,
1394 summary: &summary,
1395 approval_id: approval_id.as_deref(),
1396 warnings_json: &warnings_json,
1397 created_at: &created_at,
1398 identity,
1399 },
1400 &prev_chain_hash,
1401 );
1402
1403 let record = TransactionRecord {
1404 transaction_id: transaction_id.to_string(),
1405 request_id: request_id.clone(),
1406 request_hash: request_hash.clone(),
1407 action_name: action_name.clone(),
1408 risk_level,
1409 status,
1410 approval_id: approval_id.clone(),
1411 summary: summary.clone(),
1412 warnings: warnings.clone(),
1413 };
1414
1415 conn.execute(
1416 "INSERT INTO transactions (
1417 transaction_id,
1418 request_id,
1419 request_hash,
1420 action_name,
1421 risk_level,
1422 status,
1423 approval_id,
1424 summary,
1425 warnings_json,
1426 created_at,
1427 seq,
1428 key_id,
1429 chain_hash,
1430 prev_chain_hash,
1431 chain_version,
1432 caller_role,
1433 event_tip,
1434 caller_principal
1435 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1436 params![
1437 transaction_id,
1438 request_id,
1439 request_hash,
1440 action_name,
1441 serialize_field(&risk_level)?,
1442 serialize_field(&status)?,
1443 approval_id,
1444 summary,
1445 warnings_json,
1446 created_at,
1447 seq as i64,
1448 key_id,
1449 chain_hash,
1450 prev_chain_hash,
1451 identity.version() as i64,
1452 caller_role,
1453 event_tip,
1454 caller_principal,
1455 ],
1456 )?;
1457
1458 Ok(InsertedTransaction {
1459 record,
1460 seq,
1461 chain_hash,
1462 })
1463 }
1464
1465 fn insert_preview(
1466 conn: &Connection,
1467 transaction_id: &str,
1468 preview: &PreviewEnvelope,
1469 ) -> Result<(), TransactionStoreError> {
1470 conn.execute(
1471 "INSERT INTO transaction_previews (transaction_id, preview_json)
1472 VALUES (?1, ?2)",
1473 params![transaction_id, serde_json::to_string(preview)?],
1474 )?;
1475 Ok(())
1476 }
1477}
1478
1479fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1486 use std::os::unix::fs::DirBuilderExt;
1487 if dir.exists() {
1488 return Ok(());
1489 }
1490 std::fs::DirBuilder::new()
1491 .recursive(true)
1492 .mode(0o700)
1493 .create(dir)
1494}
1495
1496fn restrict_db_file_mode(db_path: &Path) {
1510 use std::os::unix::fs::PermissionsExt;
1511
1512 let sidecars = [
1516 db_path.to_path_buf(),
1517 with_suffix(db_path, "-wal"),
1518 with_suffix(db_path, "-shm"),
1519 ];
1520 for path in sidecars {
1521 let Ok(metadata) = std::fs::metadata(&path) else {
1522 continue; };
1524 let mode = metadata.permissions().mode() & 0o777;
1525 if mode & 0o077 == 0 {
1526 continue; }
1528 if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
1529 eprintln!(
1530 "[sysknife-daemon] WARNING: {} is mode {mode:o} and could not be \
1531 narrowed to 0600 ({err}); it is readable by anyone who can reach \
1532 it. The parent directory should be 0700 — check it.",
1533 path.display()
1534 );
1535 }
1536 }
1537}
1538
1539fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
1542 let mut name = path.as_os_str().to_os_string();
1543 name.push(suffix);
1544 PathBuf::from(name)
1545}
1546
1547fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1548 Ok(TransactionRecord {
1549 transaction_id: row.get(0)?,
1550 request_id: row.get(1)?,
1551 request_hash: row.get(2)?,
1552 action_name: row.get(3)?,
1553 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1554 status: deserialize_field(&row.get::<_, String>(5)?)?,
1555 approval_id: row.get(6)?,
1556 summary: row.get(7)?,
1557 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1558 })
1559}
1560
1561fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1562 serde_json::to_string(value)
1563}
1564
1565fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1566 serde_json::from_str(value)
1567}
1568
1569#[cfg(test)]
1570mod tests {
1571 use super::*;
1572 use crate::audit_chain::CHAIN_VERSION_CURRENT;
1573 use crate::audit_chain::CHAIN_VERSION_LEGACY;
1574 use crate::audit_chain::EVENT_VERSION_CURRENT;
1575 use crate::audit_chain::EVENT_VERSION_LEGACY;
1576 use std::os::unix::fs::PermissionsExt;
1577 use tempfile::tempdir;
1578
1579 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1583 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1584 TransactionStore::open_with_key(path, key).unwrap()
1585 }
1586
1587 fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1594 let conn = Connection::open(path).unwrap();
1595 conn.execute_batch(
1596 "CREATE TABLE schema_migrations (
1597 version INTEGER PRIMARY KEY,
1598 name TEXT NOT NULL,
1599 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1600 );",
1601 )
1602 .unwrap();
1603 conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1604 conn.execute(
1605 "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1606 params![SQLITE_MIGRATIONS[0].name],
1607 )
1608 .unwrap();
1609
1610 let transaction_id = "tx-legacy";
1611 let created_at = "2026-04-24T12:00:00.000Z";
1612 let chain_hash = key.chain_hash(
1613 &ChainContent {
1614 seq: 1,
1615 key_id: CURRENT_KEY_ID,
1616 transaction_id,
1617 request_id: "req-legacy",
1618 request_hash: "hash-legacy",
1619 action_name: "UpdateSystem",
1620 risk_level: RiskLevel::High,
1621 summary: "Upgrade the system",
1622 approval_id: None,
1623 warnings_json: "[]",
1624 created_at,
1625 identity: ChainIdentity::LegacyV1,
1626 },
1627 "",
1628 );
1629 conn.execute(
1630 "INSERT INTO transactions (
1631 transaction_id, request_id, request_hash, action_name, risk_level,
1632 status, approval_id, summary, warnings_json, created_at,
1633 seq, key_id, chain_hash, prev_chain_hash
1634 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1635 params![
1636 transaction_id,
1637 "req-legacy",
1638 "hash-legacy",
1639 "UpdateSystem",
1640 serialize_field(&RiskLevel::High).unwrap(),
1641 serialize_field(&JobState::Queued).unwrap(),
1642 "Upgrade the system",
1643 "[]",
1644 created_at,
1645 CURRENT_KEY_ID,
1646 chain_hash,
1647 ],
1648 )
1649 .unwrap();
1650 transaction_id.to_string()
1651 }
1652
1653 fn v2_database(path: &Path, key: &AuditKey) -> String {
1660 let conn = Connection::open(path).unwrap();
1661 conn.execute_batch(
1662 "CREATE TABLE schema_migrations (
1663 version INTEGER PRIMARY KEY,
1664 name TEXT NOT NULL,
1665 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1666 );",
1667 )
1668 .unwrap();
1669 for migration in &SQLITE_MIGRATIONS[0..=1] {
1670 conn.execute_batch(migration.sql).unwrap();
1671 conn.execute(
1672 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1673 params![migration.version, migration.name],
1674 )
1675 .unwrap();
1676 }
1677
1678 let transaction_id = "tx-v2";
1679 let created_at = "2026-07-01T12:00:00.000Z";
1680 let chain_hash = key.chain_hash(
1681 &ChainContent {
1682 seq: 1,
1683 key_id: CURRENT_KEY_ID,
1684 transaction_id,
1685 request_id: "req-v2",
1686 request_hash: "hash-v2",
1687 action_name: "UpdateSystem",
1688 risk_level: RiskLevel::High,
1689 summary: "Upgrade the system",
1690 approval_id: None,
1691 warnings_json: "[]",
1692 created_at,
1693 identity: ChainIdentity::V2 {
1694 caller_role: "admin",
1695 event_tip: "",
1696 },
1697 },
1698 "",
1699 );
1700 conn.execute(
1701 "INSERT INTO transactions (
1702 transaction_id, request_id, request_hash, action_name, risk_level,
1703 status, approval_id, summary, warnings_json, created_at,
1704 seq, key_id, chain_hash, prev_chain_hash,
1705 chain_version, caller_role, event_tip
1706 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
1707 params![
1708 transaction_id,
1709 "req-v2",
1710 "hash-v2",
1711 "UpdateSystem",
1712 serialize_field(&RiskLevel::High).unwrap(),
1713 serialize_field(&JobState::Queued).unwrap(),
1714 "Upgrade the system",
1715 "[]",
1716 created_at,
1717 CURRENT_KEY_ID,
1718 chain_hash,
1719 ],
1720 )
1721 .unwrap();
1722 transaction_id.to_string()
1723 }
1724
1725 #[test]
1733 fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
1734 let dir = tempdir().unwrap();
1735 let db_path = dir.path().join("tx.db");
1736 let key = AuditKey::from_bytes(vec![0x42; 32]);
1737 let old_transaction = v2_database(&db_path, &key);
1738
1739 let store = test_store(&db_path);
1740
1741 let version: i64 = store
1742 .connection()
1743 .unwrap()
1744 .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
1745 row.get(0)
1746 })
1747 .unwrap();
1748 assert_eq!(
1749 version, 4,
1750 "opening a v2 database must apply every migration up to 4"
1751 );
1752
1753 let rows = store.fetch_chain_rows().unwrap();
1754 assert_eq!(rows.len(), 1);
1755 assert_eq!(rows[0].transaction_id, old_transaction);
1756 assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
1757 assert_eq!(
1758 rows[0].caller_principal, None,
1759 "migration 3 must not invent a principal for a row signed without one"
1760 );
1761 assert!(matches!(
1762 store.verify_audit_chain(&key).unwrap(),
1763 audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
1764 ));
1765
1766 let mut fresh = queued_transaction();
1768 fresh.caller_principal = CallerPrincipal::Uid(4242);
1769 store.record(fresh).unwrap();
1770
1771 let rows = store.fetch_chain_rows().unwrap();
1772 assert_eq!(rows.len(), 2);
1773 assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
1774 assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
1775 assert_eq!(
1776 rows[1].prev_chain_hash, rows[0].chain_hash,
1777 "the v3 row must chain onto the v2 row, not start a new chain"
1778 );
1779 assert!(matches!(
1780 store.verify_audit_chain(&key).unwrap(),
1781 audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
1782 ));
1783
1784 let census = audit_chain::AttributionCensus::of(&rows);
1792 assert_eq!(census.rows(), 2);
1793 assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
1794 assert_eq!(
1795 census.not_recorded(),
1796 1,
1797 "the v2 row's encoding never signed a principal"
1798 );
1799 assert_eq!(census.attribution_failed(), 0);
1800 assert_eq!(census.unattested(), 0);
1801 assert_eq!(census.unnamed(), 1);
1802 }
1803
1804 #[test]
1805 fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1806 let dir = tempdir().unwrap();
1811 let db_path = dir.path().join("tx.db");
1812 let key = AuditKey::from_bytes(vec![0x42; 32]);
1813 legacy_v1_database(&db_path, &key);
1814
1815 let store = test_store(&db_path);
1816 assert_eq!(
1817 store.verify_audit_chain(&key).unwrap(),
1818 VerifyOutcome::Intact { rows_checked: 1 }
1819 );
1820 let rows = store.fetch_chain_rows().unwrap();
1821 assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1822 assert_eq!(rows[0].caller_role, None);
1823 }
1824
1825 #[test]
1826 fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1827 let dir = tempdir().unwrap();
1831 let db_path = dir.path().join("tx.db");
1832 let key = AuditKey::from_bytes(vec![0x42; 32]);
1833 legacy_v1_database(&db_path, &key);
1834
1835 let store = test_store(&db_path);
1836 store.record(queued_transaction()).unwrap();
1837
1838 let rows = store.fetch_chain_rows().unwrap();
1839 assert_eq!(rows.len(), 2);
1840 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1841 assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1842 assert_eq!(
1843 store.verify_audit_chain(&key).unwrap(),
1844 VerifyOutcome::Intact { rows_checked: 2 }
1845 );
1846 }
1847
1848 #[test]
1849 fn a_schema_newer_than_this_binary_is_refused() {
1850 let dir = tempdir().unwrap();
1851 let db_path = dir.path().join("tx.db");
1852 let key = AuditKey::from_bytes(vec![0x42; 32]);
1853 legacy_v1_database(&db_path, &key);
1854 let conn = Connection::open(&db_path).unwrap();
1855 conn.execute(
1856 "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1857 [],
1858 )
1859 .unwrap();
1860 drop(conn);
1861
1862 let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1863 assert!(
1864 matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1865 "expected a refusal naming the unsupported version, got {err:?}"
1866 );
1867 }
1868
1869 #[test]
1872 fn the_signed_row_records_which_role_asked() {
1873 let dir = tempdir().unwrap();
1874 let store = test_store(dir.path().join("tx.db"));
1875 let mut tx = queued_transaction();
1876 tx.caller_role = CallerRole::Admin;
1877 store.record(tx).unwrap();
1878
1879 let rows = store.fetch_chain_rows().unwrap();
1880 assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1881 }
1882
1883 #[test]
1884 fn approving_consuming_and_revoking_each_append_a_chained_event() {
1885 let dir = tempdir().unwrap();
1886 let key = AuditKey::from_bytes(vec![0x42; 32]);
1887 let store = test_store(dir.path().join("tx.db"));
1888
1889 let a = store.record(queued_transaction()).unwrap();
1891 let receipt = store
1892 .approve_transaction(&a.transaction_id, CallerPrincipal::Uid(1000))
1893 .unwrap()
1894 .expect("queued transaction approves");
1895 let digest = audit_chain::approval_receipt_digest(&receipt);
1896 assert!(store
1897 .claim_approved_for_execution(&a.transaction_id, &digest, CallerPrincipal::Uid(1000))
1898 .unwrap());
1899
1900 let b = store.record(queued_transaction()).unwrap();
1902 store
1903 .approve_transaction(&b.transaction_id, CallerPrincipal::Uid(1000))
1904 .unwrap();
1905 assert!(store
1906 .revoke_unconsumed_approval(&b.transaction_id, CallerPrincipal::Uid(1000))
1907 .unwrap());
1908
1909 let events = store.fetch_event_rows().unwrap();
1910 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1911 assert_eq!(
1912 kinds,
1913 vec![
1914 "approval_granted",
1915 "approval_consumed",
1916 "status_running",
1917 "approval_granted",
1918 "approval_revoked",
1919 ]
1920 );
1921 assert_eq!(
1922 store.verify_event_chain(&key).unwrap(),
1923 VerifyOutcome::Intact { rows_checked: 5 }
1924 );
1925 }
1926
1927 #[test]
1928 fn a_stored_approval_event_names_the_account_that_acted() {
1929 let dir = tempdir().unwrap();
1936 let key = AuditKey::from_bytes(vec![0x42; 32]);
1937 let store = test_store(dir.path().join("tx.db"));
1938
1939 let tx = store.record(queued_transaction()).unwrap();
1940 let receipt = store
1942 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
1943 .unwrap()
1944 .expect("queued transaction approves");
1945 let digest = audit_chain::approval_receipt_digest(&receipt);
1946 assert!(store
1948 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1001))
1949 .unwrap());
1950
1951 let events = store.fetch_event_rows().unwrap();
1952 assert_eq!(events.len(), 3);
1953 let granted = events
1954 .iter()
1955 .find(|e| e.kind == "approval_granted")
1956 .expect("a grant event");
1957 let consumed = events
1958 .iter()
1959 .find(|e| e.kind == "approval_consumed")
1960 .expect("a consume event");
1961
1962 assert_eq!(granted.chain_version, EVENT_VERSION_CURRENT);
1964 assert_eq!(consumed.chain_version, EVENT_VERSION_CURRENT);
1965 assert_eq!(
1966 granted.caller_principal.as_deref(),
1967 Some("uid:1000"),
1968 "the grant must name the approver"
1969 );
1970 assert_eq!(
1971 consumed.caller_principal.as_deref(),
1972 Some("uid:1001"),
1973 "the consume must name the executor"
1974 );
1975 assert_ne!(
1976 granted.caller_principal, consumed.caller_principal,
1977 "a grant and a consume by different accounts must be distinguishable"
1978 );
1979
1980 assert_eq!(
1982 store.verify_event_chain(&key).unwrap(),
1983 VerifyOutcome::Intact { rows_checked: 3 }
1984 );
1985 }
1986
1987 #[test]
1988 fn a_stored_status_event_carries_no_principal_and_stays_legacy() {
1989 let dir = tempdir().unwrap();
1994 let key = AuditKey::from_bytes(vec![0x42; 32]);
1995 let store = test_store(dir.path().join("tx.db"));
1996
1997 let tx = store.record(queued_transaction()).unwrap();
1998 let receipt = store
1999 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2000 .unwrap()
2001 .expect("queued transaction approves");
2002 let digest = audit_chain::approval_receipt_digest(&receipt);
2003 assert!(store
2004 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2005 .unwrap());
2006 store
2007 .update_status(&tx.transaction_id, JobState::Succeeded)
2008 .unwrap();
2009
2010 let events = store.fetch_event_rows().unwrap();
2011 let status = events
2012 .iter()
2013 .find(|e| e.kind == "status_succeeded")
2014 .expect("a status event");
2015 assert_eq!(status.chain_version, EVENT_VERSION_LEGACY);
2016 assert_eq!(
2017 status.caller_principal, None,
2018 "a status event names no account"
2019 );
2020 assert_eq!(
2022 store.verify_event_chain(&key).unwrap(),
2023 VerifyOutcome::Intact { rows_checked: 4 }
2024 );
2025 }
2026
2027 #[test]
2028 fn audit_status_event_failure_rolls_back_the_entire_transition() {
2029 for operation in ["claim", "cancel", "sweep"] {
2030 let dir = tempdir().unwrap();
2031 let store = test_store(dir.path().join("tx.db"));
2032 let tx = store.record(queued_transaction()).unwrap();
2033 let receipt = store
2034 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2035 .unwrap()
2036 .unwrap();
2037 let digest = audit_chain::approval_receipt_digest(&receipt);
2038 let conn = store.connection().unwrap();
2039 if operation == "sweep" {
2040 conn.execute(
2041 "UPDATE transactions SET created_at = datetime('now', '-20 minutes')",
2042 [],
2043 )
2044 .unwrap();
2045 }
2046 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();
2047 let result = match operation {
2048 "claim" => store.claim_approved_for_execution(
2049 &tx.transaction_id,
2050 &digest,
2051 CallerPrincipal::Uid(1000),
2052 ),
2053 "cancel" => store.cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000)),
2054 _ => store.cleanup_stale_queued().map(|count| count > 0),
2055 };
2056 assert!(
2057 result.is_err(),
2058 "{operation} must fail when its event cannot be written"
2059 );
2060 assert_eq!(
2061 store.get(&tx.transaction_id).unwrap().unwrap().status,
2062 JobState::Queued
2063 );
2064 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();
2065 assert_eq!(
2066 unconsumed, 1,
2067 "{operation} must roll back approval changes too"
2068 );
2069 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
2070 }
2071 }
2072
2073 #[test]
2074 fn audit_status_legacy_consumption_cannot_be_rewritten_as_queued() {
2075 let dir = tempdir().unwrap();
2076 let store = test_store(dir.path().join("tx.db"));
2077 let tx = store.record(queued_transaction()).unwrap();
2078 let receipt = store
2079 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2080 .unwrap()
2081 .unwrap();
2082 let digest = audit_chain::approval_receipt_digest(&receipt);
2083 store
2084 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2085 .unwrap();
2086 let conn = store.connection().unwrap();
2087 conn.execute("DELETE FROM audit_events WHERE kind LIKE 'status_%'", [])
2089 .unwrap();
2090 let outcome = store.status_matches_chain().unwrap();
2091 assert_eq!(
2092 serde_json::to_value(&outcome).unwrap()["state"],
2093 "cannot_verify"
2094 );
2095 conn.execute(
2096 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2097 params![
2098 serialize_field(&JobState::Queued).unwrap(),
2099 tx.transaction_id
2100 ],
2101 )
2102 .unwrap();
2103 assert!(matches!(
2104 store.status_matches_chain().unwrap(),
2105 audit_chain::StatusOutcome::Disagrees { .. }
2106 ));
2107 }
2108
2109 #[test]
2110 fn audit_status_tracks_an_in_flight_claim() {
2111 let dir = tempdir().unwrap();
2112 let store = test_store(dir.path().join("tx.db"));
2113 let tx = store.record(queued_transaction()).unwrap();
2114 let receipt = store
2115 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2116 .unwrap()
2117 .unwrap();
2118 let digest = audit_chain::approval_receipt_digest(&receipt);
2119 assert!(store
2120 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2121 .unwrap());
2122 assert_eq!(
2123 store.status_matches_chain().unwrap(),
2124 audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2125 );
2126 assert_eq!(
2127 store.fetch_event_rows().unwrap().last().unwrap().kind,
2128 "status_running"
2129 );
2130 assert_eq!(
2131 store
2132 .verify_event_chain(&AuditKey::from_bytes(vec![0x42; 32]))
2133 .unwrap(),
2134 VerifyOutcome::Intact { rows_checked: 3 }
2135 );
2136 }
2137
2138 #[test]
2139 fn audit_status_tracks_cancellation_with_and_without_approval() {
2140 for approved in [false, true] {
2141 let dir = tempdir().unwrap();
2142 let store = test_store(dir.path().join("tx.db"));
2143 let tx = store.record(queued_transaction()).unwrap();
2144 if approved {
2145 store
2146 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2147 .unwrap();
2148 }
2149 assert!(store
2150 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
2151 .unwrap());
2152 assert_eq!(
2153 store.status_matches_chain().unwrap(),
2154 audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2155 );
2156 assert_eq!(
2157 store.fetch_event_rows().unwrap().last().unwrap().kind,
2158 "status_canceled"
2159 );
2160 assert!(matches!(
2161 store
2162 .verify_event_chain(&AuditKey::from_bytes(vec![0x42; 32]))
2163 .unwrap(),
2164 VerifyOutcome::Intact { .. }
2165 ));
2166 let count = store.fetch_event_rows().unwrap().len();
2167 assert!(!store
2168 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
2169 .unwrap());
2170 assert_eq!(store.fetch_event_rows().unwrap().len(), count);
2171 }
2172 }
2173
2174 #[test]
2175 fn audit_status_tracks_stale_sweep_with_and_without_approval() {
2176 for approved in [false, true] {
2177 let dir = tempdir().unwrap();
2178 let store = test_store(dir.path().join("tx.db"));
2179 let tx = store.record(queued_transaction()).unwrap();
2180 if approved {
2181 store
2182 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2183 .unwrap();
2184 }
2185 store.connection().unwrap().execute(
2186 "UPDATE transactions SET created_at = datetime('now', '-20 minutes') WHERE transaction_id = ?1",
2187 params![tx.transaction_id],
2188 ).unwrap();
2189 assert_eq!(store.cleanup_stale_queued().unwrap(), 1);
2190 assert_eq!(
2191 store.status_matches_chain().unwrap(),
2192 audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2193 );
2194 assert_eq!(
2195 store.fetch_event_rows().unwrap().last().unwrap().kind,
2196 "status_canceled"
2197 );
2198 let count = store.fetch_event_rows().unwrap().len();
2199 assert_eq!(store.cleanup_stale_queued().unwrap(), 0);
2200 assert_eq!(store.fetch_event_rows().unwrap().len(), count);
2201 }
2202 }
2203
2204 #[test]
2205 fn audit_status_only_historical_claims_and_cancellations_are_inconclusive() {
2206 let dir = tempdir().unwrap();
2207 let store = test_store(dir.path().join("tx.db"));
2208 let tx = store.record(queued_transaction()).unwrap();
2209 store
2212 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2213 .unwrap();
2214 store
2215 .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000))
2216 .unwrap();
2217 assert_eq!(
2218 store.status_matches_chain().unwrap(),
2219 audit_chain::StatusOutcome::Agrees { rows_checked: 1 }
2220 );
2221 for state in [
2222 JobState::Canceled,
2223 JobState::Running,
2224 JobState::Succeeded,
2225 JobState::Failed,
2226 JobState::RolledBack,
2227 JobState::NeedsReboot,
2228 ] {
2229 store
2230 .connection()
2231 .unwrap()
2232 .execute(
2233 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2234 params![serialize_field(&state).unwrap(), tx.transaction_id],
2235 )
2236 .unwrap();
2237 let outcome = store.status_matches_chain().unwrap();
2238 let historical = matches!(state, JobState::Running | JobState::Canceled);
2239 assert_eq!(
2240 serde_json::to_value(&outcome).unwrap()["state"],
2241 if historical {
2242 "cannot_verify"
2243 } else {
2244 "disagrees"
2245 },
2246 "unsigned state {state:?}"
2247 );
2248 assert_eq!(
2249 audit_chain::status_outcome_to_exit_code(&outcome),
2250 if historical { 2 } else { 1 },
2251 "unsigned state {state:?}"
2252 );
2253 }
2254 }
2255
2256 #[test]
2257 fn audit_status_new_event_contradictions_remain_broken() {
2258 let dir = tempdir().unwrap();
2259 let store = test_store(dir.path().join("tx.db"));
2260 let tx = store.record(queued_transaction()).unwrap();
2261 store
2262 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
2263 .unwrap();
2264 store
2265 .connection()
2266 .unwrap()
2267 .execute(
2268 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2269 params![
2270 serialize_field(&JobState::Queued).unwrap(),
2271 tx.transaction_id
2272 ],
2273 )
2274 .unwrap();
2275 let legacy = store.record(queued_transaction()).unwrap();
2278 store
2279 .connection()
2280 .unwrap()
2281 .execute(
2282 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2283 params![
2284 serialize_field(&JobState::Canceled).unwrap(),
2285 legacy.transaction_id
2286 ],
2287 )
2288 .unwrap();
2289 assert!(matches!(
2290 store.status_matches_chain().unwrap(),
2291 audit_chain::StatusOutcome::Disagrees { .. }
2292 ));
2293 }
2294
2295 #[test]
2296 fn a_terminal_outcome_is_chained_so_rewriting_status_is_detectable() {
2297 let dir = tempdir().unwrap();
2305 let key = AuditKey::from_bytes(vec![0x42; 32]);
2306 let store = test_store(dir.path().join("tx.db"));
2307
2308 let tx = store.record(queued_transaction()).unwrap();
2309 let receipt = store
2310 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2311 .unwrap()
2312 .expect("queued transaction approves");
2313 let digest = audit_chain::approval_receipt_digest(&receipt);
2314 assert!(store
2315 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
2316 .unwrap());
2317 store
2318 .update_status(&tx.transaction_id, JobState::Succeeded)
2319 .unwrap();
2320
2321 let kinds: Vec<String> = store
2323 .fetch_event_rows()
2324 .unwrap()
2325 .iter()
2326 .map(|e| e.kind.clone())
2327 .collect();
2328 assert!(
2329 kinds.iter().any(|k| k.contains("succeeded")),
2330 "no event records the terminal outcome; the chain covers only {kinds:?}"
2331 );
2332 assert!(
2333 matches!(
2334 store.status_matches_chain().unwrap(),
2335 audit_chain::StatusOutcome::Agrees { .. }
2336 ),
2337 "an untampered store must report agreement, got {:?}",
2338 store.status_matches_chain().unwrap()
2339 );
2340
2341 {
2344 let conn = rusqlite::Connection::open(dir.path().join("tx.db")).unwrap();
2345 let n = conn
2346 .execute(
2347 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
2348 rusqlite::params!["\"canceled\"", tx.transaction_id],
2349 )
2350 .unwrap();
2351 assert_eq!(n, 1, "the fixture did not rewrite the status column");
2352 }
2353 let audit_chain::StatusOutcome::Disagrees { detail } =
2354 store.status_matches_chain().unwrap()
2355 else {
2356 panic!("a rewritten status must be reported, not passed over");
2357 };
2358 assert!(
2359 detail.contains(&tx.transaction_id),
2360 "the report must name the transaction: {detail}"
2361 );
2362 assert!(
2365 matches!(
2366 store.verify_event_chain(&key).unwrap(),
2367 VerifyOutcome::Intact { .. }
2368 ),
2369 "event chain: {:?}, rows: {:?}",
2370 store.verify_event_chain(&key).unwrap(),
2371 store
2372 .fetch_event_rows()
2373 .unwrap()
2374 .iter()
2375 .map(|e| (e.seq, e.kind.clone()))
2376 .collect::<Vec<_>>()
2377 );
2378 }
2379
2380 #[test]
2381 fn a_failed_approval_appends_no_event() {
2382 let dir = tempdir().unwrap();
2386 let store = test_store(dir.path().join("tx.db"));
2387 let tx = store.record(queued_transaction()).unwrap();
2388 store
2389 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2390 .unwrap();
2391 store
2392 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
2393 .unwrap();
2394 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
2395
2396 assert!(!store
2398 .revoke_unconsumed_approval("no-such-tx", CallerPrincipal::Uid(1000))
2399 .unwrap());
2400 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
2401 }
2402
2403 #[test]
2404 fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
2405 let dir = tempdir().unwrap();
2411 let db_path = dir.path().join("tx.db");
2412 let key = AuditKey::from_bytes(vec![0x42; 32]);
2413 let store = test_store(&db_path);
2414
2415 let a = store.record(queued_transaction()).unwrap();
2416 store
2417 .approve_transaction(&a.transaction_id, CallerPrincipal::Uid(1000))
2418 .unwrap();
2419 store.record(queued_transaction()).unwrap();
2422
2423 assert_eq!(
2424 store.verify_event_chain(&key).unwrap(),
2425 VerifyOutcome::Intact { rows_checked: 1 }
2426 );
2427 assert_eq!(
2428 store.verify_event_binding().unwrap(),
2429 audit_chain::BindingOutcome::Consistent {
2430 bindings_checked: 1
2431 }
2432 );
2433
2434 let conn = Connection::open(&db_path).unwrap();
2436 conn.execute("DELETE FROM transaction_approvals", [])
2437 .unwrap();
2438 conn.execute("DELETE FROM audit_events", []).unwrap();
2439 drop(conn);
2440
2441 assert_eq!(
2445 store.verify_event_chain(&key).unwrap(),
2446 VerifyOutcome::Intact { rows_checked: 0 }
2447 );
2448 match store.verify_event_binding().unwrap() {
2449 audit_chain::BindingOutcome::MissingEvent {
2450 transaction_seq, ..
2451 } => assert_eq!(transaction_seq, 2),
2452 other => panic!("expected MissingEvent, got {other:?}"),
2453 }
2454 }
2455
2456 #[test]
2457 fn a_read_only_store_cannot_revoke_or_claim() {
2458 let dir = tempdir().unwrap();
2461 let db_path = dir.path().join("tx.db");
2462 let store = test_store(&db_path);
2463 store.record(queued_transaction()).unwrap();
2464
2465 let read_only = TransactionStore::open_read_only(&db_path).unwrap();
2466 assert!(matches!(
2467 read_only.revoke_unconsumed_approval("tx", CallerPrincipal::Uid(1000)),
2468 Err(TransactionStoreError::AuditChainMissing(_))
2469 ));
2470 assert!(matches!(
2471 read_only.claim_approved_for_execution("tx", "digest", CallerPrincipal::Uid(1000)),
2472 Err(TransactionStoreError::AuditChainMissing(_))
2473 ));
2474 }
2475
2476 #[test]
2483 fn record_persists_the_caller_principal_under_the_current_encoding() {
2484 let dir = tempdir().unwrap();
2485 let store = test_store(dir.path().join("tx.db"));
2486 let mut tx = queued_transaction();
2487 tx.caller_principal = CallerPrincipal::Uid(4242);
2488 store.record(tx).unwrap();
2489
2490 let conn = store.connection().unwrap();
2491 let (version, role, principal): (i64, Option<String>, Option<String>) = conn
2492 .query_row(
2493 "SELECT chain_version, caller_role, caller_principal FROM transactions",
2494 [],
2495 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2496 )
2497 .unwrap();
2498
2499 assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
2500 assert_eq!(role.as_deref(), Some("dev"));
2501 assert_eq!(
2502 principal.as_deref(),
2503 Some("uid:4242"),
2504 "the row must name the account the dispatcher resolved"
2505 );
2506
2507 let key = AuditKey::from_bytes(vec![0x42; 32]);
2509 assert!(matches!(
2510 store.verify_audit_chain(&key).unwrap(),
2511 audit_chain::VerifyOutcome::Intact { .. }
2512 ));
2513 }
2514
2515 #[test]
2516 fn record_writes_audit_chain_columns() {
2517 let dir = tempdir().unwrap();
2518 let store = test_store(dir.path().join("tx.db"));
2519 let _record = store.record(queued_transaction()).unwrap();
2520
2521 let conn = store.connection().unwrap();
2522 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
2523 .query_row(
2524 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
2525 [],
2526 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
2527 )
2528 .unwrap();
2529 assert_eq!(seq, 1, "first row gets seq=1");
2530 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
2531 assert_eq!(prev, "", "first row has empty prev_chain_hash");
2532 assert_eq!(
2533 chain_hash.len(),
2534 audit_chain::HASH_HEX_LEN,
2535 "chain_hash is a hex-encoded Ed25519 signature"
2536 );
2537 }
2538
2539 #[test]
2540 fn sequential_records_produce_chained_hashes() {
2541 let dir = tempdir().unwrap();
2542 let store = test_store(dir.path().join("tx.db"));
2543 store.record(queued_transaction()).unwrap();
2544 store.record(queued_transaction()).unwrap();
2545 store.record(queued_transaction()).unwrap();
2546
2547 let rows = store.fetch_chain_rows().unwrap();
2548 assert_eq!(rows.len(), 3);
2549 assert_eq!(rows[0].seq, 1);
2550 assert_eq!(rows[1].seq, 2);
2551 assert_eq!(rows[2].seq, 3);
2552 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
2554 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
2555 }
2556
2557 #[test]
2569 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
2570 const WORKERS: usize = 8;
2571 const RECORDS_PER_WORKER: usize = 10;
2572 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
2573
2574 let dir = tempdir().unwrap();
2575 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2576
2577 let mut handles = Vec::with_capacity(WORKERS);
2578 for w in 0..WORKERS {
2579 let store = std::sync::Arc::clone(&store);
2580 handles.push(std::thread::spawn(move || {
2581 for r in 0..RECORDS_PER_WORKER {
2582 let tx = NewTransaction {
2583 request_id: format!("worker-{w}-record-{r}"),
2584 request_hash: format!("hash-{w}-{r}"),
2585 action_name: "GetSystemState".to_string(),
2586 risk_level: RiskLevel::Low,
2587 summary: format!("worker {w} record {r}"),
2588 warnings: vec![],
2589 caller_role: CallerRole::Dev,
2590 caller_principal: CallerPrincipal::Uid(3000 + w as u32),
2594 };
2595 store
2596 .record(tx)
2597 .expect("record must succeed under contention");
2598 }
2599 }));
2600 }
2601 for h in handles {
2602 h.join().expect("worker thread did not panic");
2603 }
2604
2605 let key = AuditKey::from_bytes(vec![0x42; 32]);
2607 let outcome = store.verify_audit_chain(&key).unwrap();
2608 match outcome {
2609 VerifyOutcome::Intact { rows_checked } => {
2610 assert_eq!(
2611 rows_checked, TOTAL as u64,
2612 "expected {TOTAL} rows checked, got {rows_checked}"
2613 );
2614 }
2615 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
2616 }
2617
2618 {
2622 let conn = store.connection().unwrap();
2623 let mut stmt = conn
2624 .prepare("SELECT summary, caller_principal FROM transactions")
2625 .unwrap();
2626 let pairs: Vec<(String, Option<String>)> = stmt
2627 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
2628 .unwrap()
2629 .map(|r| r.unwrap())
2630 .collect();
2631 assert_eq!(pairs.len(), TOTAL);
2632 for (summary, principal) in pairs {
2633 let worker: u32 = summary
2634 .split_whitespace()
2635 .nth(1)
2636 .and_then(|w| w.parse().ok())
2637 .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
2638 assert_eq!(
2639 principal.as_deref(),
2640 Some(format!("uid:{}", 3000 + worker).as_str()),
2641 "row {summary:?} was attributed to the wrong account"
2642 );
2643 }
2644 }
2645
2646 let conn = store.connection().unwrap();
2648 let mut stmt = conn
2649 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
2650 .unwrap();
2651 let seqs: Vec<i64> = stmt
2652 .query_map([], |row| row.get::<_, i64>(0))
2653 .unwrap()
2654 .map(|r| r.unwrap())
2655 .collect();
2656 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
2657 for (i, s) in seqs.iter().enumerate() {
2658 assert_eq!(
2659 *s,
2660 (i as i64) + 1,
2661 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
2662 );
2663 }
2664 }
2665
2666 #[test]
2667 fn verify_audit_chain_intact_after_inserts() {
2668 let dir = tempdir().unwrap();
2669 let store = test_store(dir.path().join("tx.db"));
2670 for _ in 0..3 {
2671 store.record(queued_transaction()).unwrap();
2672 }
2673 let key = AuditKey::from_bytes(vec![0x42; 32]);
2674 let outcome = store.verify_audit_chain(&key).unwrap();
2675 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2676 }
2677
2678 #[test]
2679 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
2680 let dir = tempdir().unwrap();
2681 let store = test_store(dir.path().join("tx.db"));
2682 for _ in 0..3 {
2683 store.record(queued_transaction()).unwrap();
2684 }
2685 let key = AuditKey::from_bytes(vec![0x42; 32]);
2687 let outcome = store
2688 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
2689 .unwrap();
2690 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2691 }
2692
2693 #[test]
2694 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
2695 let dir = tempdir().unwrap();
2696 let store = test_store(dir.path().join("tx.db"));
2697 store.record(queued_transaction()).unwrap();
2698 let other = AuditKey::from_bytes(vec![0x99; 32]);
2700 let outcome = store
2701 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
2702 .unwrap();
2703 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2704 }
2705
2706 #[test]
2707 fn verify_detects_tampered_summary() {
2708 let dir = tempdir().unwrap();
2709 let store = test_store(dir.path().join("tx.db"));
2710 let tx = store.record(queued_transaction()).unwrap();
2711
2712 let conn = store.connection().unwrap();
2715 conn.execute(
2716 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
2717 params!["EVIL CHANGE", tx.transaction_id],
2718 )
2719 .unwrap();
2720
2721 let key = AuditKey::from_bytes(vec![0x42; 32]);
2722 let outcome = store.verify_audit_chain(&key).unwrap();
2723 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2724 }
2725
2726 #[test]
2727 fn status_update_does_not_break_chain() {
2728 let dir = tempdir().unwrap();
2730 let store = test_store(dir.path().join("tx.db"));
2731 let tx = store.record(queued_transaction()).unwrap();
2732 store
2733 .update_status(&tx.transaction_id, JobState::Running)
2734 .unwrap();
2735 store
2736 .update_status(&tx.transaction_id, JobState::Succeeded)
2737 .unwrap();
2738
2739 let key = AuditKey::from_bytes(vec![0x42; 32]);
2740 let outcome = store.verify_audit_chain(&key).unwrap();
2741 assert!(
2742 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
2743 "status mutation must not break the chain (status not in chain content): {outcome:?}"
2744 );
2745 }
2746
2747 #[test]
2748 fn open_read_only_rejects_record() {
2749 let dir = tempdir().unwrap();
2750 let key_path = dir.path().join("audit-key");
2751 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
2752 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2753
2754 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
2755 let result = store.record(queued_transaction());
2756 assert!(matches!(
2757 result,
2758 Err(TransactionStoreError::AuditChainMissing(_))
2759 ));
2760 }
2761
2762 #[test]
2763 fn ensure_private_dir_creates_with_0700_mode() {
2764 let root = tempdir().unwrap();
2765 let target = root.path().join("a/b/c");
2766 ensure_private_dir(&target).unwrap();
2767 assert!(target.is_dir());
2768 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2769 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
2770 }
2771
2772 #[test]
2780 fn the_audit_database_is_created_owner_only() {
2781 let dir = tempdir().unwrap();
2782 let db = dir.path().join("daemon.sqlite");
2783 let store = TransactionStore::open(&db).unwrap();
2784 store
2786 .record(NewTransaction {
2787 request_id: "mode-probe".to_string(),
2788 request_hash: "hash-mode-probe".to_string(),
2789 action_name: "GetSystemState".to_string(),
2790 risk_level: RiskLevel::Low,
2791 summary: "probe".to_string(),
2792 warnings: vec![],
2793 caller_role: CallerRole::Dev,
2794 caller_principal: CallerPrincipal::Uid(3000),
2795 })
2796 .unwrap();
2797
2798 for path in [db.clone(), db.with_extension("sqlite-wal")] {
2799 if !path.exists() {
2800 continue;
2801 }
2802 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2803 assert_eq!(
2804 mode,
2805 0o600,
2806 "{} must be owner-only, got {mode:o}",
2807 path.display()
2808 );
2809 }
2810 }
2811
2812 #[test]
2813 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
2814 let root = tempdir().unwrap();
2815 let target = root.path().join("preexisting");
2816 std::fs::create_dir(&target).unwrap();
2817 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
2818 ensure_private_dir(&target).unwrap();
2819 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2821 assert_eq!(mode, 0o755);
2822 }
2823
2824 #[test]
2825 fn open_creates_parent_with_private_mode() {
2826 let root = tempdir().unwrap();
2827 let db_path = root.path().join("nested/dirs/daemon.sqlite");
2828 let _store = test_store(&db_path);
2829 let parent = db_path.parent().unwrap();
2830 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
2831 assert_eq!(mode, 0o700);
2832 }
2833
2834 fn queued_transaction() -> NewTransaction {
2835 NewTransaction {
2836 request_id: "req-1".to_string(),
2837 request_hash: "hash-abc".to_string(),
2838 action_name: "UpdateSystem".to_string(),
2839 risk_level: RiskLevel::High,
2840 summary: "Upgrade the system".to_string(),
2841 warnings: vec![],
2842 caller_role: CallerRole::Dev,
2843 caller_principal: CallerPrincipal::Uid(1000),
2844 }
2845 }
2846
2847 #[test]
2848 fn update_status_transitions_queued_to_running() {
2849 let dir = tempdir().unwrap();
2850 let store = test_store(dir.path().join("tx.db"));
2851 let tx = store.record(queued_transaction()).unwrap();
2852
2853 store
2854 .update_status(&tx.transaction_id, JobState::Running)
2855 .unwrap();
2856
2857 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2858 assert_eq!(updated.status, JobState::Running);
2859 }
2860
2861 #[test]
2862 fn update_status_transitions_running_to_succeeded() {
2863 let dir = tempdir().unwrap();
2864 let store = test_store(dir.path().join("tx.db"));
2865 let tx = store.record(queued_transaction()).unwrap();
2866
2867 store
2868 .update_status(&tx.transaction_id, JobState::Running)
2869 .unwrap();
2870 store
2871 .update_status(&tx.transaction_id, JobState::Succeeded)
2872 .unwrap();
2873
2874 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2875 assert_eq!(updated.status, JobState::Succeeded);
2876 }
2877
2878 #[test]
2879 fn update_status_for_unknown_id_returns_not_found() {
2880 let dir = tempdir().unwrap();
2881 let store = test_store(dir.path().join("tx.db"));
2882
2883 let result = store.update_status("does-not-exist", JobState::Running);
2884 assert!(
2885 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2886 "expected NotFound, got: {result:?}"
2887 );
2888 }
2889
2890 #[test]
2891 fn update_status_leaves_other_fields_intact() {
2892 let dir = tempdir().unwrap();
2893 let store = test_store(dir.path().join("tx.db"));
2894 let tx = store.record(queued_transaction()).unwrap();
2895
2896 store
2897 .update_status(&tx.transaction_id, JobState::Running)
2898 .unwrap();
2899 store
2900 .update_status(&tx.transaction_id, JobState::Failed)
2901 .unwrap();
2902
2903 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2904 assert_eq!(updated.action_name, "UpdateSystem");
2905 assert_eq!(updated.risk_level, RiskLevel::High);
2906 assert_eq!(updated.status, JobState::Failed);
2907 }
2908
2909 #[test]
2918 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2919 let dir = tempdir().unwrap();
2920 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2921 let tx = store.record(queued_transaction()).unwrap();
2922 store
2923 .update_status(&tx.transaction_id, JobState::Running)
2924 .unwrap();
2925
2926 let store_a = std::sync::Arc::clone(&store);
2927 let id_a = tx.transaction_id.clone();
2928 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2929
2930 let store_b = std::sync::Arc::clone(&store);
2931 let id_b = tx.transaction_id.clone();
2932 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2933
2934 let succeed_result = succeed.join().unwrap();
2935 let fail_result = fail.join().unwrap();
2936
2937 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2939 .into_iter()
2940 .filter(|ok| *ok)
2941 .count();
2942 assert_eq!(
2943 oks, 1,
2944 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2945 fail={fail_result:?}"
2946 );
2947
2948 let loser = if succeed_result.is_ok() {
2964 &fail_result
2965 } else {
2966 &succeed_result
2967 };
2968 match loser {
2969 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
2970 assert_eq!(id, &tx.transaction_id);
2971 }
2972 Err(TransactionStoreError::InvalidTransition { from, to }) => {
2973 let winner = if succeed_result.is_ok() {
2975 JobState::Succeeded
2976 } else {
2977 JobState::Failed
2978 };
2979 assert_eq!(
2980 *from, winner,
2981 "the loser must have observed the winner's committed status"
2982 );
2983 assert_ne!(*to, winner, "the loser must be the other transition");
2984 }
2985 other => panic!(
2986 "the losing transition must be refused with either \
2987 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
2988 ),
2989 }
2990
2991 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
2994 if succeed_result.is_ok() {
2995 assert_eq!(final_status, JobState::Succeeded);
2996 } else {
2997 assert_eq!(final_status, JobState::Failed);
2998 }
2999 }
3000
3001 #[test]
3002 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
3003 let dir = tempdir().unwrap();
3006 let store = test_store(dir.path().join("tx.db"));
3007 let tx = store.record(queued_transaction()).unwrap();
3008 let receipt = store
3009 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3010 .unwrap()
3011 .expect("approved");
3012 let digest = audit_chain::approval_receipt_digest(&receipt);
3013
3014 assert!(
3015 store
3016 .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000))
3017 .unwrap(),
3018 "an unconsumed approval must be revocable"
3019 );
3020 assert!(
3021 !store
3022 .claim_approved_for_execution(
3023 &tx.transaction_id,
3024 &digest,
3025 CallerPrincipal::Uid(1000)
3026 )
3027 .unwrap(),
3028 "a revoked receipt must no longer be claimable"
3029 );
3030 }
3031
3032 #[test]
3033 fn revoke_cannot_retract_an_approval_that_was_already_used() {
3034 let dir = tempdir().unwrap();
3038 let store = test_store(dir.path().join("tx.db"));
3039 let tx = store.record(queued_transaction()).unwrap();
3040 let receipt = store
3041 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3042 .unwrap()
3043 .expect("approved");
3044 let digest = audit_chain::approval_receipt_digest(&receipt);
3045 assert!(store
3046 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
3047 .unwrap());
3048
3049 assert!(
3050 !store
3051 .revoke_unconsumed_approval(&tx.transaction_id, CallerPrincipal::Uid(1000))
3052 .unwrap(),
3053 "a consumed approval must not be revocable"
3054 );
3055 assert_eq!(
3056 store.get(&tx.transaction_id).unwrap().unwrap().status,
3057 JobState::Running,
3058 "a failed revocation must not disturb the running transaction"
3059 );
3060 }
3061
3062 #[test]
3063 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
3064 let dir = tempdir().unwrap();
3068 let store = test_store(dir.path().join("tx.db"));
3069 let tx = store.record(queued_transaction()).unwrap();
3070 let receipt = store
3071 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3072 .unwrap()
3073 .expect("approved");
3074 let digest = audit_chain::approval_receipt_digest(&receipt);
3075 assert!(store
3076 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
3077 .unwrap());
3078
3079 assert!(
3081 store
3082 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3083 .unwrap()
3084 .is_none(),
3085 "a Running transaction must not be approvable"
3086 );
3087
3088 store
3090 .update_status(&tx.transaction_id, JobState::Succeeded)
3091 .unwrap();
3092 assert!(
3093 store
3094 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3095 .unwrap()
3096 .is_none(),
3097 "a completed transaction must not be approvable"
3098 );
3099 }
3100
3101 #[test]
3102 fn only_one_of_two_concurrent_claims_can_execute() {
3103 let dir = tempdir().unwrap();
3108 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
3109 let tx = store.record(queued_transaction()).unwrap();
3110 let receipt = store
3111 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3112 .unwrap()
3113 .expect("approved");
3114 let digest = audit_chain::approval_receipt_digest(&receipt);
3115
3116 let mut handles = Vec::new();
3117 for _ in 0..2 {
3118 let store = std::sync::Arc::clone(&store);
3119 let id = tx.transaction_id.clone();
3120 let digest = digest.clone();
3121 handles.push(std::thread::spawn(move || {
3122 store.claim_approved_for_execution(&id, &digest, CallerPrincipal::Uid(1000))
3123 }));
3124 }
3125 let claims: Vec<bool> = handles
3126 .into_iter()
3127 .map(|h| h.join().unwrap().unwrap_or(false))
3128 .collect();
3129
3130 assert_eq!(
3131 claims.iter().filter(|c| **c).count(),
3132 1,
3133 "exactly one claim may win: {claims:?}"
3134 );
3135 assert_eq!(
3136 store.get(&tx.transaction_id).unwrap().unwrap().status,
3137 JobState::Running
3138 );
3139 }
3140
3141 #[test]
3142 fn cancel_queued_cancels_a_queued_transaction_once() {
3143 let dir = tempdir().unwrap();
3144 let store = test_store(dir.path().join("tx.db"));
3145 let tx = store.record(queued_transaction()).unwrap();
3146
3147 assert!(
3148 store
3149 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3150 .unwrap(),
3151 "a queued transaction is cancelable"
3152 );
3153 assert_eq!(
3154 store.get(&tx.transaction_id).unwrap().unwrap().status,
3155 JobState::Canceled
3156 );
3157 assert!(
3158 !store
3159 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3160 .unwrap(),
3161 "an already-canceled transaction is not cancelable again"
3162 );
3163 assert!(
3164 !store
3165 .cancel_queued("no-such-transaction", CallerPrincipal::Uid(1000))
3166 .unwrap(),
3167 "a missing transaction is not cancelable"
3168 );
3169 assert!(
3170 store
3171 .fetch_event_rows()
3172 .unwrap()
3173 .iter()
3174 .all(|event| event.kind == "status_canceled"),
3175 "canceling an unapproved transaction must not append an approval event"
3176 );
3177 }
3178
3179 #[test]
3180 fn cancel_queued_revokes_an_unconsumed_approval_and_appends_event() {
3181 let dir = tempdir().unwrap();
3182 let store = test_store(dir.path().join("tx.db"));
3183 let tx = store.record(queued_transaction()).unwrap();
3184 let receipt = store
3185 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3186 .unwrap()
3187 .expect("queued transaction is approvable");
3188 let digest = audit_chain::approval_receipt_digest(&receipt);
3189
3190 assert!(store
3191 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3192 .unwrap());
3193 assert_eq!(
3194 store.get(&tx.transaction_id).unwrap().unwrap().status,
3195 JobState::Canceled
3196 );
3197 assert!(
3198 !store
3199 .claim_approved_for_execution(
3200 &tx.transaction_id,
3201 &digest,
3202 CallerPrincipal::Uid(1000)
3203 )
3204 .unwrap(),
3205 "canceling must revoke the unconsumed receipt"
3206 );
3207 assert_eq!(
3208 store
3209 .fetch_event_rows()
3210 .unwrap()
3211 .iter()
3212 .map(|event| event.kind.as_str())
3213 .collect::<Vec<_>>(),
3214 vec!["approval_granted", "approval_revoked", "status_canceled"]
3215 );
3216 }
3217
3218 #[test]
3219 fn cancel_queued_refuses_a_running_transaction() {
3220 let dir = tempdir().unwrap();
3223 let store = test_store(dir.path().join("tx.db"));
3224 let tx = store.record(queued_transaction()).unwrap();
3225 let receipt = store
3226 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3227 .unwrap()
3228 .expect("approved");
3229 let digest = audit_chain::approval_receipt_digest(&receipt);
3230 assert!(store
3231 .claim_approved_for_execution(&tx.transaction_id, &digest, CallerPrincipal::Uid(1000))
3232 .unwrap());
3233
3234 assert!(
3235 !store
3236 .cancel_queued(&tx.transaction_id, CallerPrincipal::Uid(1000))
3237 .unwrap(),
3238 "a running transaction must not be cancelable"
3239 );
3240 assert_eq!(
3241 store.get(&tx.transaction_id).unwrap().unwrap().status,
3242 JobState::Running,
3243 "cancel must not disturb a running transaction"
3244 );
3245 }
3246
3247 #[test]
3248 fn approved_receipt_is_required_and_consumed_once() {
3249 let dir = tempdir().unwrap();
3250 let store = test_store(dir.path().join("tx.db"));
3251 let tx = store.record(queued_transaction()).unwrap();
3252
3253 assert!(
3254 !store
3255 .claim_approved_for_execution(
3256 &tx.transaction_id,
3257 "digest-a",
3258 CallerPrincipal::Uid(1000)
3259 )
3260 .unwrap(),
3261 "an unapproved preview must not execute"
3262 );
3263 let receipt = store
3264 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3265 .unwrap()
3266 .expect("first approval must succeed");
3267 let digest = audit_chain::approval_receipt_digest(&receipt);
3268 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
3269 assert!(
3270 store
3271 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3272 .unwrap()
3273 .is_none(),
3274 "approval is immutable once issued"
3275 );
3276 assert!(
3277 !store
3278 .claim_approved_for_execution(
3279 &tx.transaction_id,
3280 "wrong-digest",
3281 CallerPrincipal::Uid(1000)
3282 )
3283 .unwrap(),
3284 "a forged receipt must not execute"
3285 );
3286 assert!(
3287 store
3288 .claim_approved_for_execution(
3289 &tx.transaction_id,
3290 &digest,
3291 CallerPrincipal::Uid(1000)
3292 )
3293 .unwrap(),
3294 "the exact approved receipt must execute"
3295 );
3296 assert!(
3297 !store
3298 .claim_approved_for_execution(
3299 &tx.transaction_id,
3300 &digest,
3301 CallerPrincipal::Uid(1000)
3302 )
3303 .unwrap(),
3304 "the receipt must be one-time"
3305 );
3306 }
3307
3308 #[test]
3309 fn approval_commitment_is_covered_by_the_signed_chain() {
3310 let dir = tempdir().unwrap();
3311 let store = test_store(dir.path().join("tx.db"));
3312 let tx = store.record(queued_transaction()).unwrap();
3313 assert!(tx.approval_id.is_some());
3314
3315 store
3316 .connection()
3317 .unwrap()
3318 .execute(
3319 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
3320 params![tx.transaction_id],
3321 )
3322 .unwrap();
3323
3324 let key = AuditKey::from_bytes(vec![0x42; 32]);
3325 assert!(matches!(
3326 store.verify_audit_chain(&key).unwrap(),
3327 VerifyOutcome::Broken { .. }
3328 ));
3329 }
3330
3331 #[test]
3332 fn approve_rejects_a_forged_commitment_at_runtime() {
3333 let dir = tempdir().unwrap();
3337 let store = test_store(dir.path().join("tx.db"));
3338 let tx = store.record(queued_transaction()).unwrap();
3339
3340 store
3341 .connection()
3342 .unwrap()
3343 .execute(
3344 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
3345 params![tx.transaction_id],
3346 )
3347 .unwrap();
3348
3349 let err = store
3350 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3351 .expect_err("a forged commitment must be rejected, not approved");
3352 assert!(
3353 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
3354 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
3355 );
3356 }
3357
3358 #[test]
3359 fn stale_iso_timestamp_cannot_be_approved() {
3360 let dir = tempdir().unwrap();
3361 let store = test_store(dir.path().join("tx.db"));
3362 let tx = store.record(queued_transaction()).unwrap();
3363 let conn = store.connection().unwrap();
3364 conn.execute(
3365 "UPDATE transactions \
3366 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3367 WHERE transaction_id = ?1",
3368 params![tx.transaction_id],
3369 )
3370 .unwrap();
3371
3372 assert!(
3373 store
3374 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3375 .unwrap()
3376 .is_none(),
3377 "a production-format timestamp outside the TTL must not be approved"
3378 );
3379 }
3380
3381 #[test]
3382 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
3383 let dir = tempdir().unwrap();
3388 let store = test_store(dir.path().join("tx.db"));
3389 let tx = store.record(queued_transaction()).unwrap();
3390 let receipt = store
3391 .approve_transaction(&tx.transaction_id, CallerPrincipal::Uid(1000))
3392 .unwrap()
3393 .expect("a fresh approval succeeds");
3394 let digest = audit_chain::approval_receipt_digest(&receipt);
3395
3396 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 .claim_approved_for_execution(
3409 &tx.transaction_id,
3410 &digest,
3411 CallerPrincipal::Uid(1000)
3412 )
3413 .unwrap(),
3414 "an approval aged past the TTL must not be claimable at execute time"
3415 );
3416 assert_eq!(
3417 store.get(&tx.transaction_id).unwrap().unwrap().status,
3418 JobState::Queued,
3419 "a TTL-expired claim must leave the transaction Queued, never Running"
3420 );
3421 }
3422
3423 #[test]
3424 fn cleanup_stale_queued_cancels_old_records() {
3425 let dir = tempdir().unwrap();
3426 let store = test_store(dir.path().join("tx.db"));
3427
3428 let fresh = store.record(queued_transaction()).unwrap();
3430 let stale = store.record(queued_transaction()).unwrap();
3431
3432 let conn = store.connection().unwrap();
3434 conn.execute(
3435 "UPDATE transactions \
3436 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3437 WHERE transaction_id = ?1",
3438 params![stale.transaction_id],
3439 )
3440 .unwrap();
3441
3442 let canceled = store.cleanup_stale_queued().unwrap();
3443 assert_eq!(canceled, 1, "only the stale record should be canceled");
3444
3445 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
3447 assert_eq!(stale_record.status, JobState::Canceled);
3448
3449 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
3451 assert_eq!(fresh_record.status, JobState::Queued);
3452 }
3453
3454 #[test]
3455 fn cleanup_stale_queued_revokes_every_unconsumed_approval() {
3456 let dir = tempdir().unwrap();
3457 let store = test_store(dir.path().join("tx.db"));
3458 let transactions: Vec<_> = (0..3)
3459 .map(|_| {
3460 let transaction = store.record(queued_transaction()).unwrap();
3461 store
3462 .approve_transaction(&transaction.transaction_id, CallerPrincipal::Uid(1000))
3463 .unwrap()
3464 .expect("stale transaction is approvable");
3465 transaction
3466 })
3467 .collect();
3468
3469 let conn = store.connection().unwrap();
3470 for transaction in &transactions {
3471 conn.execute(
3472 "UPDATE transactions \
3473 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
3474 WHERE transaction_id = ?1",
3475 params![transaction.transaction_id],
3476 )
3477 .unwrap();
3478 }
3479
3480 assert_eq!(store.cleanup_stale_queued().unwrap(), 3);
3481 for transaction in &transactions {
3482 assert_eq!(
3483 store
3484 .get(&transaction.transaction_id)
3485 .unwrap()
3486 .unwrap()
3487 .status,
3488 JobState::Canceled
3489 );
3490 assert!(!store
3491 .revoke_unconsumed_approval(&transaction.transaction_id, CallerPrincipal::Uid(1000))
3492 .unwrap());
3493 }
3494 assert_eq!(
3495 store
3496 .fetch_event_rows()
3497 .unwrap()
3498 .iter()
3499 .map(|event| event.kind.as_str())
3500 .collect::<Vec<_>>(),
3501 vec![
3502 "approval_granted",
3503 "approval_granted",
3504 "approval_granted",
3505 "approval_revoked",
3506 "status_canceled",
3507 "approval_revoked",
3508 "status_canceled",
3509 "approval_revoked",
3510 "status_canceled"
3511 ]
3512 );
3513 }
3514
3515 #[test]
3518 fn update_status_rejects_queued_to_succeeded() {
3519 let dir = tempdir().unwrap();
3520 let store = test_store(dir.path().join("tx.db"));
3521 let tx = store.record(queued_transaction()).unwrap();
3522
3523 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
3524 assert!(
3525 matches!(
3526 result,
3527 Err(TransactionStoreError::InvalidTransition {
3528 from: JobState::Queued,
3529 to: JobState::Succeeded,
3530 })
3531 ),
3532 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
3533 );
3534 }
3535
3536 #[test]
3537 fn update_status_rejects_succeeded_to_running() {
3538 let dir = tempdir().unwrap();
3539 let store = test_store(dir.path().join("tx.db"));
3540 let tx = store.record(queued_transaction()).unwrap();
3541
3542 store
3543 .update_status(&tx.transaction_id, JobState::Running)
3544 .unwrap();
3545 store
3546 .update_status(&tx.transaction_id, JobState::Succeeded)
3547 .unwrap();
3548
3549 let result = store.update_status(&tx.transaction_id, JobState::Running);
3550 assert!(
3551 matches!(
3552 result,
3553 Err(TransactionStoreError::InvalidTransition {
3554 from: JobState::Succeeded,
3555 to: JobState::Running,
3556 })
3557 ),
3558 "Succeeded -> Running must be rejected (terminal state): {result:?}"
3559 );
3560 }
3561
3562 #[test]
3563 fn update_status_accepts_running_to_failed() {
3564 let dir = tempdir().unwrap();
3565 let store = test_store(dir.path().join("tx.db"));
3566 let tx = store.record(queued_transaction()).unwrap();
3567
3568 store
3569 .update_status(&tx.transaction_id, JobState::Running)
3570 .unwrap();
3571 store
3572 .update_status(&tx.transaction_id, JobState::Failed)
3573 .unwrap();
3574
3575 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3576 assert_eq!(updated.status, JobState::Failed);
3577 }
3578
3579 #[test]
3580 fn update_status_accepts_running_to_rolled_back() {
3581 let dir = tempdir().unwrap();
3582 let store = test_store(dir.path().join("tx.db"));
3583 let tx = store.record(queued_transaction()).unwrap();
3584
3585 store
3586 .update_status(&tx.transaction_id, JobState::Running)
3587 .unwrap();
3588 store
3589 .update_status(&tx.transaction_id, JobState::RolledBack)
3590 .unwrap();
3591
3592 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3593 assert_eq!(updated.status, JobState::RolledBack);
3594 }
3595
3596 #[test]
3599 fn list_transactions_returns_empty_for_fresh_store() {
3600 let dir = tempdir().unwrap();
3601 let store = test_store(dir.path().join("tx.db"));
3602 let results = store.list_transactions(10, None, None, None).unwrap();
3603 assert!(results.is_empty());
3604 }
3605
3606 #[test]
3607 fn list_transactions_returns_all_records_ordered_by_newest_first() {
3608 let dir = tempdir().unwrap();
3609 let store = test_store(dir.path().join("tx.db"));
3610 store.record(queued_transaction()).unwrap();
3611
3612 let mut second = queued_transaction();
3613 second.action_name = "GetDiskUsage".to_string();
3614 second.risk_level = RiskLevel::Low;
3615 store.record(second).unwrap();
3616
3617 let results = store.list_transactions(10, None, None, None).unwrap();
3618 assert_eq!(results.len(), 2);
3619 assert_eq!(results[0].action_name, "GetDiskUsage");
3621 assert_eq!(results[1].action_name, "UpdateSystem");
3622 }
3623
3624 #[test]
3625 fn list_history_populates_created_at_and_risk_level() {
3626 let dir = tempdir().unwrap();
3627 let store = test_store(dir.path().join("tx.db"));
3628 store.record(queued_transaction()).unwrap();
3629
3630 let entries = store.list_history(10, None, None, None).unwrap();
3631 assert_eq!(entries.len(), 1);
3632 let entry = &entries[0];
3633 assert_eq!(entry.action_name, "UpdateSystem");
3634 assert_eq!(entry.risk_level, RiskLevel::High);
3635 assert_eq!(entry.status, JobState::Queued);
3636 assert!(
3637 !entry.created_at.is_empty(),
3638 "created_at must be populated from the stored row, not left blank"
3639 );
3640 }
3641
3642 #[test]
3643 fn list_history_applies_the_same_filters_as_list_transactions() {
3644 let dir = tempdir().unwrap();
3645 let store = test_store(dir.path().join("tx.db"));
3646 store.record(queued_transaction()).unwrap();
3647 let mut low = queued_transaction();
3648 low.action_name = "GetDiskUsage".to_string();
3649 low.risk_level = RiskLevel::Low;
3650 store.record(low).unwrap();
3651
3652 let only = store
3653 .list_history(10, None, Some("GetDiskUsage"), None)
3654 .unwrap();
3655 assert_eq!(only.len(), 1);
3656 assert_eq!(only[0].action_name, "GetDiskUsage");
3657 }
3658
3659 #[test]
3660 fn list_transactions_respects_limit() {
3661 let dir = tempdir().unwrap();
3662 let store = test_store(dir.path().join("tx.db"));
3663 for _ in 0..5 {
3664 store.record(queued_transaction()).unwrap();
3665 }
3666 let results = store.list_transactions(3, None, None, None).unwrap();
3667 assert_eq!(results.len(), 3);
3668 }
3669
3670 #[test]
3671 fn list_transactions_filters_by_status() {
3672 let dir = tempdir().unwrap();
3673 let store = test_store(dir.path().join("tx.db"));
3674 let tx = store.record(queued_transaction()).unwrap();
3675 store
3676 .update_status(&tx.transaction_id, JobState::Running)
3677 .unwrap();
3678 store
3679 .update_status(&tx.transaction_id, JobState::Succeeded)
3680 .unwrap();
3681
3682 store.record(queued_transaction()).unwrap();
3684
3685 let succeeded = store
3686 .list_transactions(10, Some("succeeded"), None, None)
3687 .unwrap();
3688 assert_eq!(succeeded.len(), 1);
3689 assert_eq!(succeeded[0].status, JobState::Succeeded);
3690
3691 let queued = store
3692 .list_transactions(10, Some("queued"), None, None)
3693 .unwrap();
3694 assert_eq!(queued.len(), 1);
3695 assert_eq!(queued[0].status, JobState::Queued);
3696 }
3697
3698 #[test]
3699 fn list_transactions_filters_by_action_name() {
3700 let dir = tempdir().unwrap();
3701 let store = test_store(dir.path().join("tx.db"));
3702 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
3705 disk.action_name = "GetDiskUsage".to_string();
3706 store.record(disk).unwrap();
3707
3708 let results = store
3709 .list_transactions(10, None, Some("GetDiskUsage"), None)
3710 .unwrap();
3711 assert_eq!(results.len(), 1);
3712 assert_eq!(results[0].action_name, "GetDiskUsage");
3713 }
3714
3715 #[test]
3716 fn list_transactions_filters_by_since_hours() {
3717 let dir = tempdir().unwrap();
3718 let store = test_store(dir.path().join("tx.db"));
3719
3720 let old = store.record(queued_transaction()).unwrap();
3722 let conn = store.connection().unwrap();
3723 conn.execute(
3724 "UPDATE transactions \
3725 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
3726 WHERE transaction_id = ?1",
3727 params![old.transaction_id],
3728 )
3729 .unwrap();
3730
3731 store.record(queued_transaction()).unwrap();
3733
3734 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
3736 assert_eq!(results.len(), 1);
3737
3738 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
3740 assert_eq!(results.len(), 2);
3741 }
3742
3743 #[test]
3744 fn list_transactions_rejects_invalid_status_filter() {
3745 let dir = tempdir().unwrap();
3746 let store = test_store(dir.path().join("tx.db"));
3747 store.record(queued_transaction()).unwrap();
3748 let result = store.list_transactions(10, Some("bogus"), None, None);
3749 assert!(result.is_err(), "invalid status filter should return error");
3750 }
3751
3752 #[test]
3760 fn record_emits_one_watermark_per_entry() {
3761 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3762 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3763
3764 let dir = tempdir().unwrap();
3765 let store = test_store(dir.path().join("tx.db"));
3766 store.record(queued_transaction()).unwrap();
3767
3768 let calls = crate::audit_watermark::take_watermarks(&sink);
3769 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
3770 }
3771
3772 #[test]
3774 fn record_previewed_emits_one_watermark() {
3775 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3776 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3777
3778 let dir = tempdir().unwrap();
3779 let store = test_store(dir.path().join("tx.db"));
3780 let preview = PreviewEnvelope {
3781 summary: "Upgrade the system".to_string(),
3782 risk_level: RiskLevel::High,
3783 current_state: serde_json::Value::Null,
3784 proposed_change: serde_json::Value::Null,
3785 expected_side_effects: vec![],
3786 reboot_required: false,
3787 rollback_available: false,
3788 warnings: vec![],
3789 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
3790 };
3791 store
3792 .record_previewed(queued_transaction(), preview)
3793 .unwrap();
3794
3795 let calls = crate::audit_watermark::take_watermarks(&sink);
3796 assert_eq!(
3797 calls.len(),
3798 1,
3799 "expected exactly 1 watermark per record_previewed()"
3800 );
3801 }
3802
3803 #[test]
3805 fn watermark_seq_and_hash_match_chain_row() {
3806 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3807 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3808
3809 let dir = tempdir().unwrap();
3810 let store = test_store(dir.path().join("tx.db"));
3811 store.record(queued_transaction()).unwrap();
3812
3813 let rows = store.fetch_chain_rows().unwrap();
3814 assert_eq!(rows.len(), 1);
3815 let row = &rows[0];
3816
3817 let calls = crate::audit_watermark::take_watermarks(&sink);
3818 assert_eq!(calls.len(), 1);
3819 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
3820 assert_eq!(
3821 calls[0].chain_hash_hex, row.chain_hash,
3822 "watermark chain_hash_hex must match stored chain_hash"
3823 );
3824 }
3825
3826 #[test]
3828 fn multiple_records_produce_one_watermark_each() {
3829 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3830 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3831
3832 let dir = tempdir().unwrap();
3833 let store = test_store(dir.path().join("tx.db"));
3834 for _ in 0..3 {
3835 store.record(queued_transaction()).unwrap();
3836 }
3837
3838 let calls = crate::audit_watermark::take_watermarks(&sink);
3839 assert_eq!(calls.len(), 3, "one watermark per record call");
3840 assert_eq!(calls[0].seq, 1);
3841 assert_eq!(calls[1].seq, 2);
3842 assert_eq!(calls[2].seq, 3);
3843 }
3844
3845 #[test]
3859 fn no_watermark_emitted_before_any_record() {
3860 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3861 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3862
3863 let dir = tempdir().unwrap();
3864 let _store = test_store(dir.path().join("tx.db"));
3865
3866 let calls = crate::audit_watermark::take_watermarks(&sink);
3868 assert!(
3869 calls.is_empty(),
3870 "no watermark must be emitted without a record() call"
3871 );
3872 }
3873}