1use crate::audit_chain::{
2 self, AuditEventKind, AuditKey, ChainContent, ChainIdentity, ChainRow, EventContent, EventRow,
3 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];
206
207use crate::store::CHAIN_ROW_COLUMNS;
211
212fn chain_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainRow> {
213 Ok(ChainRow {
214 seq: row.get::<_, i64>(0)? as u64,
215 key_id: row.get(1)?,
216 transaction_id: row.get(2)?,
217 request_id: row.get(3)?,
218 request_hash: row.get(4)?,
219 action_name: row.get(5)?,
220 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
221 rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
222 })?,
223 summary: row.get(7)?,
224 approval_id: row.get(8)?,
225 warnings_json: row.get(9)?,
226 created_at: row.get(10)?,
227 prev_chain_hash: row.get(11)?,
228 chain_hash: row.get(12)?,
229 chain_version: row.get::<_, i64>(13)? as u32,
230 caller_role: row.get(14)?,
231 event_tip: row.get(15)?,
232 caller_principal: row.get(16)?,
233 })
234}
235
236fn event_row_from_sqlite(row: &rusqlite::Row<'_>) -> rusqlite::Result<EventRow> {
237 Ok(EventRow {
238 seq: row.get::<_, i64>(0)? as u64,
239 key_id: row.get(1)?,
240 kind: row.get(2)?,
241 transaction_id: row.get(3)?,
242 receipt_digest: row.get(4)?,
243 created_at: row.get(5)?,
244 prev_chain_hash: row.get(6)?,
245 chain_hash: row.get(7)?,
246 })
247}
248
249fn query_optional<T>(
255 conn: &Connection,
256 sql: &str,
257 params: impl rusqlite::Params,
258 f: impl FnOnce(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
259) -> rusqlite::Result<Option<T>> {
260 match conn.query_row(sql, params, f) {
261 Ok(v) => Ok(Some(v)),
262 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
263 Err(e) => Err(e),
264 }
265}
266
267#[derive(Clone, Debug)]
268pub struct TransactionStore {
269 path: PathBuf,
270 audit_key: Option<Arc<AuditKey>>,
273}
274
275#[derive(Clone, Debug, PartialEq)]
276pub struct RecordedPreviewedTransaction {
277 pub transaction: TransactionRecord,
278 pub preview: PreviewEnvelope,
279}
280
281struct InsertedTransaction {
282 record: TransactionRecord,
283 seq: u64,
284 chain_hash: String,
285}
286
287#[derive(Debug, thiserror::Error)]
288pub enum TransactionStoreError {
289 #[error("io error: {0}")]
290 Io(#[from] std::io::Error),
291
292 #[error("sqlite error: {0}")]
293 Sqlite(#[from] rusqlite::Error),
294
295 #[error("json error: {0}")]
296 Json(#[from] serde_json::Error),
297
298 #[error("database invariant violation: {0}")]
299 DatabaseInvariant(String),
300
301 #[error("transaction not found: {0}")]
302 NotFound(String),
303
304 #[error("invalid transition from {from:?} to {to:?}")]
305 InvalidTransition { from: JobState, to: JobState },
306
307 #[error("audit chain misconfiguration: {0}")]
308 AuditChainMissing(&'static str),
309
310 #[error("transaction {0} status changed concurrently; retry")]
318 ConcurrentStatusChange(String),
319}
320
321impl TransactionStore {
322 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
326 let path = path.as_ref().to_path_buf();
327 if let Some(parent) = path.parent() {
328 ensure_private_dir(parent)?;
329 }
330
331 let store = Self {
332 path,
333 audit_key: None,
334 };
335 store.initialize()?;
336 Ok(store)
337 }
338
339 pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
346 let db_path = path.as_ref();
347 let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
348 .map(PathBuf::from)
349 .unwrap_or_else(|_| {
350 db_path
351 .parent()
352 .unwrap_or_else(|| Path::new("."))
353 .join("audit-key")
354 });
355 let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
356 TransactionStoreError::Io(std::io::Error::new(
357 std::io::ErrorKind::InvalidData,
358 format!("audit key load failed: {e}"),
359 ))
360 })?;
361 Self::open_with_key(path, Arc::new(key))
362 }
363
364 pub fn open_with_key(
367 path: impl AsRef<Path>,
368 audit_key: Arc<AuditKey>,
369 ) -> Result<Self, TransactionStoreError> {
370 let path = path.as_ref().to_path_buf();
371 if let Some(parent) = path.parent() {
372 ensure_private_dir(parent)?;
373 }
374
375 let store = Self {
376 path,
377 audit_key: Some(audit_key),
378 };
379 store.initialize()?;
380 restrict_db_file_mode(&store.path);
381 Ok(store)
382 }
383
384 fn record_with(
393 &self,
394 transaction: NewTransaction,
395 in_tx: impl FnOnce(&Connection, &InsertedTransaction) -> Result<(), TransactionStoreError>,
396 ) -> Result<InsertedTransaction, TransactionStoreError> {
397 let key = self
398 .audit_key
399 .as_ref()
400 .ok_or(TransactionStoreError::AuditChainMissing(
401 "this TransactionStore was opened read-only; cannot record",
402 ))?;
403 let mut conn = self.connection()?;
404 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
409 let transaction_id = Uuid::new_v4().to_string();
410 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
411 in_tx(&tx, &inserted)?;
412 tx.commit()?;
413 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
414 Ok(inserted)
415 }
416
417 pub fn record(
418 &self,
419 transaction: NewTransaction,
420 ) -> Result<TransactionRecord, TransactionStoreError> {
421 let inserted = self.record_with(transaction, |_, _| Ok(()))?;
422 Ok(inserted.record)
423 }
424
425 pub fn record_previewed(
426 &self,
427 transaction: NewTransaction,
428 preview: PreviewEnvelope,
429 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
430 let inserted = self.record_with(transaction, |conn, inserted| {
431 Self::insert_preview(conn, &inserted.record.transaction_id, &preview)
432 })?;
433 Ok(RecordedPreviewedTransaction {
434 transaction: inserted.record,
435 preview,
436 })
437 }
438
439 pub fn get(
440 &self,
441 transaction_id: &str,
442 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
443 let conn = self.connection()?;
444 let mut stmt = conn.prepare(
445 "SELECT
446 transaction_id,
447 request_id,
448 request_hash,
449 action_name,
450 risk_level,
451 status,
452 approval_id,
453 summary,
454 warnings_json
455 FROM transactions
456 WHERE transaction_id = ?1",
457 )?;
458 let mut rows = stmt.query(params![transaction_id])?;
459 if let Some(row) = rows.next()? {
460 Ok(Some(row_to_record(row)?))
461 } else {
462 Ok(None)
463 }
464 }
465
466 pub fn get_preview(
467 &self,
468 transaction_id: &str,
469 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
470 let conn = self.connection()?;
471 let mut stmt = conn.prepare(
472 "SELECT preview_json
473 FROM transaction_previews
474 WHERE transaction_id = ?1",
475 )?;
476 let mut rows = stmt.query(params![transaction_id])?;
477 if let Some(row) = rows.next()? {
478 let preview_json: String = row.get(0)?;
479 Ok(Some(serde_json::from_str(&preview_json)?))
480 } else {
481 Ok(None)
482 }
483 }
484
485 pub fn update_status(
486 &self,
487 transaction_id: &str,
488 new_status: JobState,
489 ) -> Result<(), TransactionStoreError> {
490 let conn = self.connection()?;
491
492 let current_status: String = conn
494 .query_row(
495 "SELECT status FROM transactions WHERE transaction_id = ?1",
496 params![transaction_id],
497 |row| row.get(0),
498 )
499 .map_err(|e| match e {
500 rusqlite::Error::QueryReturnedNoRows => {
501 TransactionStoreError::NotFound(transaction_id.to_string())
502 }
503 other => TransactionStoreError::Sqlite(other),
504 })?;
505
506 let current: JobState = deserialize_field(¤t_status)?;
507 if !crate::jobs::allowed_transition(¤t, &new_status) {
508 return Err(TransactionStoreError::InvalidTransition {
509 from: current,
510 to: new_status,
511 });
512 }
513
514 let rows_affected = conn.execute(
526 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
527 params![
528 serialize_field(&new_status)?,
529 transaction_id,
530 current_status
531 ],
532 )?;
533 if rows_affected == 0 {
534 return Err(TransactionStoreError::ConcurrentStatusChange(
535 transaction_id.to_string(),
536 ));
537 }
538 Ok(())
539 }
540
541 pub fn approve_transaction(
543 &self,
544 transaction_id: &str,
545 ) -> Result<Option<String>, TransactionStoreError> {
546 let key = self
547 .audit_key
548 .as_ref()
549 .ok_or(TransactionStoreError::AuditChainMissing(
550 "this TransactionStore was opened read-only; cannot approve",
551 ))?;
552 let Some(record) = self.get(transaction_id)? else {
553 return Ok(None);
554 };
555 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
556 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
557 let Some(committed_digest) = record.approval_id.as_deref() else {
558 return Err(TransactionStoreError::DatabaseInvariant(format!(
559 "transaction {transaction_id} has no signed approval commitment"
560 )));
561 };
562 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
563 return Err(TransactionStoreError::DatabaseInvariant(format!(
564 "transaction {transaction_id} approval commitment does not match its signed preview"
565 )));
566 }
567
568 let mut conn = self.connection()?;
571 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
572 let queued_json = serialize_field(&JobState::Queued)?;
573 let rows_affected = tx.execute(
574 &format!(
575 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
576 SELECT transaction_id, ?1 FROM transactions \
577 WHERE transaction_id = ?2 \
578 AND status = ?3 \
579 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
580 AND NOT EXISTS ( \
581 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
582 )"
583 ),
584 params![receipt_digest, transaction_id, queued_json],
585 )?;
586 if rows_affected > 0 {
587 Self::append_event(
588 &tx,
589 key,
590 AuditEventKind::ApprovalGranted,
591 transaction_id,
592 &receipt_digest,
593 )?;
594 }
595 tx.commit()?;
596 Ok((rows_affected > 0).then_some(receipt))
597 }
598
599 pub fn revoke_unconsumed_approval(
602 &self,
603 transaction_id: &str,
604 ) -> Result<bool, TransactionStoreError> {
605 let key = self
606 .audit_key
607 .as_ref()
608 .ok_or(TransactionStoreError::AuditChainMissing(
609 "this TransactionStore was opened read-only; cannot revoke",
610 ))?;
611 let mut conn = self.connection()?;
612 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
613 let revoked = Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?;
614 tx.commit()?;
615 Ok(revoked)
616 }
617
618 fn revoke_unconsumed_approval_in_tx(
619 conn: &Connection,
620 key: &AuditKey,
621 transaction_id: &str,
622 ) -> Result<bool, TransactionStoreError> {
623 let digest: Option<String> = query_optional(
626 conn,
627 "SELECT receipt_digest FROM transaction_approvals \
628 WHERE transaction_id = ?1 AND consumed_at IS NULL",
629 params![transaction_id],
630 |row| row.get(0),
631 )?;
632 let rows_affected = conn.execute(
633 "DELETE FROM transaction_approvals \
634 WHERE transaction_id = ?1 AND consumed_at IS NULL",
635 params![transaction_id],
636 )?;
637 if rows_affected > 0 {
638 let digest = digest.ok_or_else(|| {
639 TransactionStoreError::DatabaseInvariant(format!(
640 "revoked an approval for {transaction_id} that had no receipt digest"
641 ))
642 })?;
643 Self::append_event(
644 conn,
645 key,
646 AuditEventKind::ApprovalRevoked,
647 transaction_id,
648 &digest,
649 )?;
650 }
651 Ok(rows_affected > 0)
652 }
653
654 pub fn claim_approved_for_execution(
656 &self,
657 transaction_id: &str,
658 receipt_digest: &str,
659 ) -> Result<bool, TransactionStoreError> {
660 let key = self
661 .audit_key
662 .as_ref()
663 .ok_or(TransactionStoreError::AuditChainMissing(
664 "this TransactionStore was opened read-only; cannot claim",
665 ))?;
666 let mut conn = self.connection()?;
667 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
668 let queued_json = serialize_field(&JobState::Queued)?;
669 let running_json = serialize_field(&JobState::Running)?;
670 let rows_affected = tx.execute(
671 &format!(
672 "UPDATE transactions SET status = ?1 \
673 WHERE transaction_id = ?2 \
674 AND status = ?3 \
675 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
676 AND EXISTS ( \
677 SELECT 1 FROM transaction_approvals \
678 WHERE transaction_id = ?2 \
679 AND receipt_digest = ?4 \
680 AND consumed_at IS NULL \
681 )"
682 ),
683 params![running_json, transaction_id, queued_json, receipt_digest],
684 )?;
685 if rows_affected > 0 {
686 tx.execute(
687 "UPDATE transaction_approvals \
688 SET consumed_at = datetime('now') \
689 WHERE transaction_id = ?1 AND consumed_at IS NULL",
690 params![transaction_id],
691 )?;
692 Self::append_event(
693 &tx,
694 key,
695 AuditEventKind::ApprovalConsumed,
696 transaction_id,
697 receipt_digest,
698 )?;
699 }
700 tx.commit()?;
701 Ok(rows_affected > 0)
702 }
703
704 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
719 let key = self
720 .audit_key
721 .as_ref()
722 .ok_or(TransactionStoreError::AuditChainMissing(
723 "this TransactionStore was opened read-only; cannot clean up",
724 ))?;
725 let mut conn = self.connection()?;
726 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
727 let canceled_json = serialize_field(&JobState::Canceled)?;
728 let queued_json = serialize_field(&JobState::Queued)?;
729 let stale_ids: Vec<String> = {
730 let mut statement = tx.prepare(&format!(
731 "SELECT transaction_id FROM transactions \
732 WHERE status = ?1 \
733 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
734 ))?;
735 let stale_ids = statement
736 .query_map(params![queued_json], |row| row.get(0))?
737 .collect::<Result<_, _>>()?;
738 stale_ids
739 };
740 let mut canceled = 0;
741 for transaction_id in stale_ids {
742 let rows_affected = tx.execute(
743 "UPDATE transactions SET status = ?1 \
744 WHERE transaction_id = ?2 AND status = ?3",
745 params![canceled_json, transaction_id, queued_json],
746 )?;
747 if rows_affected > 0 {
748 Self::revoke_unconsumed_approval_in_tx(&tx, key, &transaction_id)?;
749 canceled += rows_affected;
750 }
751 }
752 tx.commit()?;
753 Ok(canceled as u64)
754 }
755
756 pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
765 let key = self
766 .audit_key
767 .as_ref()
768 .ok_or(TransactionStoreError::AuditChainMissing(
769 "this TransactionStore was opened read-only; cannot cancel",
770 ))?;
771 let mut conn = self.connection()?;
772 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
773 let canceled_json = serialize_field(&JobState::Canceled)?;
774 let queued_json = serialize_field(&JobState::Queued)?;
775 let rows_affected = tx.execute(
776 "UPDATE transactions SET status = ?1 \
777 WHERE transaction_id = ?2 AND status = ?3",
778 params![canceled_json, transaction_id, queued_json],
779 )?;
780 if rows_affected > 0 {
781 Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?;
782 }
783 tx.commit()?;
784 Ok(rows_affected > 0)
785 }
786
787 pub fn list_transactions(
796 &self,
797 limit: u32,
798 status_filter: Option<&str>,
799 action_filter: Option<&str>,
800 since_hours: Option<u32>,
801 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
802 let conn = self.connection()?;
803 let (filter_sql, param_values) =
804 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
805 let sql = format!(
806 "SELECT transaction_id, request_id, request_hash, action_name, \
807 risk_level, status, approval_id, summary, warnings_json \
808 FROM transactions WHERE 1=1{filter_sql}"
809 );
810 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
811 param_values.iter().map(|b| b.as_ref()).collect();
812
813 let mut stmt = conn.prepare(&sql)?;
814 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
815
816 let mut results = Vec::new();
817 for row in rows {
818 results.push(row??);
819 }
820 Ok(results)
821 }
822
823 pub fn list_history(
828 &self,
829 limit: u32,
830 status_filter: Option<&str>,
831 action_filter: Option<&str>,
832 since_hours: Option<u32>,
833 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
834 let conn = self.connection()?;
835 let (filter_sql, param_values) =
836 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
837 let sql = format!(
838 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
839 FROM transactions WHERE 1=1{filter_sql}"
840 );
841 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
842 param_values.iter().map(|b| b.as_ref()).collect();
843
844 let mut stmt = conn.prepare(&sql)?;
845 let rows = stmt.query_map(params_ref.as_slice(), |row| {
846 Ok((|| {
847 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
848 transaction_id: row.get(0)?,
849 action_name: row.get(1)?,
850 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
851 status: deserialize_field(&row.get::<_, String>(3)?)?,
852 summary: row.get(4)?,
853 created_at: row.get(5)?,
854 })
855 })())
856 })?;
857
858 let mut results = Vec::new();
859 for row in rows {
860 results.push(row??);
861 }
862 Ok(results)
863 }
864
865 fn build_history_filter(
870 limit: u32,
871 status_filter: Option<&str>,
872 action_filter: Option<&str>,
873 since_hours: Option<u32>,
874 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
875 let mut sql = String::new();
876 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
877
878 if let Some(status) = status_filter {
879 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
883 let status_json = serialize_field(&validated)?;
884 sql.push_str(" AND status = ?");
885 param_values.push(Box::new(status_json));
886 }
887
888 if let Some(action) = action_filter {
889 sql.push_str(" AND action_name = ?");
890 param_values.push(Box::new(action.to_string()));
891 }
892
893 if let Some(hours) = since_hours {
894 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
895 param_values.push(Box::new(hours));
896 }
897
898 sql.push_str(" ORDER BY seq DESC LIMIT ?");
899 param_values.push(Box::new(limit.min(100)));
900
901 Ok((sql, param_values))
902 }
903
904 fn connection(&self) -> Result<Connection, TransactionStoreError> {
905 let conn = Connection::open(&self.path)?;
906 conn.pragma_update(None, "journal_mode", "WAL")?;
917 conn.pragma_update(None, "synchronous", "NORMAL")?;
918 conn.pragma_update(None, "foreign_keys", "ON")?;
919 conn.busy_timeout(std::time::Duration::from_secs(5))?;
920 Ok(conn)
921 }
922
923 fn initialize(&self) -> Result<(), TransactionStoreError> {
924 let mut conn = self.connection()?;
925 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
926 tx.execute_batch(
927 "CREATE TABLE IF NOT EXISTS schema_migrations (\
928 version INTEGER PRIMARY KEY,\
929 name TEXT NOT NULL,\
930 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
931 );",
932 )?;
933 let current: i64 = tx.query_row(
934 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
935 [],
936 |row| row.get(0),
937 )?;
938 let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
939 if current > latest {
940 return Err(TransactionStoreError::DatabaseInvariant(format!(
941 "sqlite schema version {current} is newer than this binary supports ({latest})"
942 )));
943 }
944 for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
945 tx.execute_batch(migration.sql)?;
946 tx.execute(
947 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
948 params![migration.version, migration.name],
949 )?;
950 }
951 tx.commit()?;
952 Ok(())
953 }
954
955 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
957 let conn = self.connection()?;
958 let mut stmt = conn.prepare(&format!(
959 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
960 ))?;
961 let rows = stmt.query_map([], chain_row_from_sqlite)?;
962 let mut out = Vec::new();
963 for row in rows {
964 out.push(row?);
965 }
966 Ok(out)
967 }
968
969 pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
971 let conn = self.connection()?;
972 let mut stmt = conn.prepare(
973 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
974 created_at, prev_chain_hash, chain_hash \
975 FROM audit_events ORDER BY seq ASC",
976 )?;
977 let rows = stmt.query_map([], event_row_from_sqlite)?;
978 let mut out = Vec::new();
979 for row in rows {
980 out.push(row?);
981 }
982 Ok(out)
983 }
984
985 pub fn verify_audit_chain(
987 &self,
988 key: &AuditKey,
989 ) -> Result<VerifyOutcome, TransactionStoreError> {
990 let rows = self.fetch_chain_rows()?;
991 Ok(audit_chain::verify_chain(key, &rows))
992 }
993
994 pub fn verify_event_chain(
996 &self,
997 key: &AuditKey,
998 ) -> Result<VerifyOutcome, TransactionStoreError> {
999 let rows = self.fetch_event_rows()?;
1000 Ok(audit_chain::verify_event_chain(key, &rows))
1001 }
1002
1003 pub fn verify_event_binding(
1006 &self,
1007 ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
1008 let tx_rows = self.fetch_chain_rows()?;
1009 let event_rows = self.fetch_event_rows()?;
1010 Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
1011 }
1012
1013 pub fn verify_audit_chain_with_pubkey(
1016 &self,
1017 verifying_key_hex: &str,
1018 ) -> Result<VerifyOutcome, TransactionStoreError> {
1019 let rows = self.fetch_chain_rows()?;
1020 Ok(audit_chain::verify_chain_with_pubkey(
1021 verifying_key_hex,
1022 &rows,
1023 ))
1024 }
1025
1026 pub fn fetch_chain_row(
1029 &self,
1030 transaction_id: &str,
1031 ) -> Result<Option<ChainRow>, TransactionStoreError> {
1032 let conn = self.connection()?;
1033 let mut stmt = conn.prepare(&format!(
1034 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
1035 ))?;
1036 let mut rows = stmt.query(params![transaction_id])?;
1037 if let Some(row) = rows.next()? {
1038 Ok(Some(chain_row_from_sqlite(row)?))
1039 } else {
1040 Ok(None)
1041 }
1042 }
1043
1044 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
1048 let prev: Option<(i64, String)> = query_optional(
1049 conn,
1050 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
1051 [],
1052 |row| Ok((row.get(0)?, row.get(1)?)),
1053 )?;
1054 Ok(match prev {
1055 Some((seq, hash)) => ((seq as u64) + 1, hash),
1056 None => (1, String::new()),
1057 })
1058 }
1059
1060 fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
1063 Ok(query_optional(
1064 conn,
1065 "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
1066 [],
1067 |row| row.get(0),
1068 )?)
1069 }
1070
1071 fn append_event(
1076 conn: &Connection,
1077 key: &AuditKey,
1078 kind: AuditEventKind,
1079 transaction_id: &str,
1080 receipt_digest: &str,
1081 ) -> Result<(), TransactionStoreError> {
1082 let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1083 let seq: i64 = conn.query_row(
1084 "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1085 [],
1086 |row| row.get(0),
1087 )?;
1088 let created_at: String =
1089 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1090 row.get(0)
1091 })?;
1092 let key_id = CURRENT_KEY_ID.to_string();
1093 let chain_hash = key.event_hash(
1094 &EventContent {
1095 seq: seq as u64,
1096 key_id: &key_id,
1097 kind,
1098 transaction_id,
1099 receipt_digest,
1100 created_at: &created_at,
1101 },
1102 &prev_chain_hash,
1103 );
1104 conn.execute(
1105 "INSERT INTO audit_events (
1106 seq, key_id, kind, transaction_id, receipt_digest,
1107 created_at, chain_hash, prev_chain_hash
1108 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1109 params![
1110 seq,
1111 key_id,
1112 kind.as_str(),
1113 transaction_id,
1114 receipt_digest,
1115 created_at,
1116 chain_hash,
1117 prev_chain_hash,
1118 ],
1119 )?;
1120 Ok(())
1121 }
1122
1123 fn insert_transaction(
1124 conn: &Connection,
1125 key: &AuditKey,
1126 transaction_id: &str,
1127 transaction: NewTransaction,
1128 ) -> Result<InsertedTransaction, TransactionStoreError> {
1129 let request_id = transaction.request_id;
1130 let request_hash = transaction.request_hash;
1131 let action_name = transaction.action_name;
1132 let risk_level = transaction.risk_level;
1133 let status = JobState::Queued;
1135 let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1142 let summary = transaction.summary;
1143 let warnings = transaction.warnings;
1144 let warnings_json = serde_json::to_string(&warnings)?;
1145
1146 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1149
1150 let created_at: String =
1154 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1155 row.get(0)
1156 })?;
1157
1158 let key_id = CURRENT_KEY_ID.to_string();
1159 let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1163 let caller_role = transaction.caller_role.as_str();
1164 let caller_principal = transaction.caller_principal.as_signed_str();
1165 let identity = ChainIdentity::V3 {
1169 caller_role,
1170 event_tip: &event_tip,
1171 caller_principal: &caller_principal,
1172 };
1173 let chain_hash = key.chain_hash(
1174 &ChainContent {
1175 seq,
1176 key_id: &key_id,
1177 transaction_id,
1178 request_id: &request_id,
1179 request_hash: &request_hash,
1180 action_name: &action_name,
1181 risk_level,
1182 summary: &summary,
1183 approval_id: approval_id.as_deref(),
1184 warnings_json: &warnings_json,
1185 created_at: &created_at,
1186 identity,
1187 },
1188 &prev_chain_hash,
1189 );
1190
1191 let record = TransactionRecord {
1192 transaction_id: transaction_id.to_string(),
1193 request_id: request_id.clone(),
1194 request_hash: request_hash.clone(),
1195 action_name: action_name.clone(),
1196 risk_level,
1197 status,
1198 approval_id: approval_id.clone(),
1199 summary: summary.clone(),
1200 warnings: warnings.clone(),
1201 };
1202
1203 conn.execute(
1204 "INSERT INTO transactions (
1205 transaction_id,
1206 request_id,
1207 request_hash,
1208 action_name,
1209 risk_level,
1210 status,
1211 approval_id,
1212 summary,
1213 warnings_json,
1214 created_at,
1215 seq,
1216 key_id,
1217 chain_hash,
1218 prev_chain_hash,
1219 chain_version,
1220 caller_role,
1221 event_tip,
1222 caller_principal
1223 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1224 params![
1225 transaction_id,
1226 request_id,
1227 request_hash,
1228 action_name,
1229 serialize_field(&risk_level)?,
1230 serialize_field(&status)?,
1231 approval_id,
1232 summary,
1233 warnings_json,
1234 created_at,
1235 seq as i64,
1236 key_id,
1237 chain_hash,
1238 prev_chain_hash,
1239 identity.version() as i64,
1240 caller_role,
1241 event_tip,
1242 caller_principal,
1243 ],
1244 )?;
1245
1246 Ok(InsertedTransaction {
1247 record,
1248 seq,
1249 chain_hash,
1250 })
1251 }
1252
1253 fn insert_preview(
1254 conn: &Connection,
1255 transaction_id: &str,
1256 preview: &PreviewEnvelope,
1257 ) -> Result<(), TransactionStoreError> {
1258 conn.execute(
1259 "INSERT INTO transaction_previews (transaction_id, preview_json)
1260 VALUES (?1, ?2)",
1261 params![transaction_id, serde_json::to_string(preview)?],
1262 )?;
1263 Ok(())
1264 }
1265}
1266
1267fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1274 use std::os::unix::fs::DirBuilderExt;
1275 if dir.exists() {
1276 return Ok(());
1277 }
1278 std::fs::DirBuilder::new()
1279 .recursive(true)
1280 .mode(0o700)
1281 .create(dir)
1282}
1283
1284fn restrict_db_file_mode(db_path: &Path) {
1298 use std::os::unix::fs::PermissionsExt;
1299
1300 let sidecars = [
1304 db_path.to_path_buf(),
1305 with_suffix(db_path, "-wal"),
1306 with_suffix(db_path, "-shm"),
1307 ];
1308 for path in sidecars {
1309 let Ok(metadata) = std::fs::metadata(&path) else {
1310 continue; };
1312 let mode = metadata.permissions().mode() & 0o777;
1313 if mode & 0o077 == 0 {
1314 continue; }
1316 if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
1317 eprintln!(
1318 "[sysknife-daemon] WARNING: {} is mode {mode:o} and could not be \
1319 narrowed to 0600 ({err}); it is readable by anyone who can reach \
1320 it. The parent directory should be 0700 — check it.",
1321 path.display()
1322 );
1323 }
1324 }
1325}
1326
1327fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
1330 let mut name = path.as_os_str().to_os_string();
1331 name.push(suffix);
1332 PathBuf::from(name)
1333}
1334
1335fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1336 Ok(TransactionRecord {
1337 transaction_id: row.get(0)?,
1338 request_id: row.get(1)?,
1339 request_hash: row.get(2)?,
1340 action_name: row.get(3)?,
1341 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1342 status: deserialize_field(&row.get::<_, String>(5)?)?,
1343 approval_id: row.get(6)?,
1344 summary: row.get(7)?,
1345 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1346 })
1347}
1348
1349fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1350 serde_json::to_string(value)
1351}
1352
1353fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1354 serde_json::from_str(value)
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359 use super::*;
1360 use crate::audit_chain::CHAIN_VERSION_CURRENT;
1361 use crate::audit_chain::CHAIN_VERSION_LEGACY;
1362 use std::os::unix::fs::PermissionsExt;
1363 use tempfile::tempdir;
1364
1365 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1369 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1370 TransactionStore::open_with_key(path, key).unwrap()
1371 }
1372
1373 fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1380 let conn = Connection::open(path).unwrap();
1381 conn.execute_batch(
1382 "CREATE TABLE schema_migrations (
1383 version INTEGER PRIMARY KEY,
1384 name TEXT NOT NULL,
1385 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1386 );",
1387 )
1388 .unwrap();
1389 conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1390 conn.execute(
1391 "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1392 params![SQLITE_MIGRATIONS[0].name],
1393 )
1394 .unwrap();
1395
1396 let transaction_id = "tx-legacy";
1397 let created_at = "2026-04-24T12:00:00.000Z";
1398 let chain_hash = key.chain_hash(
1399 &ChainContent {
1400 seq: 1,
1401 key_id: CURRENT_KEY_ID,
1402 transaction_id,
1403 request_id: "req-legacy",
1404 request_hash: "hash-legacy",
1405 action_name: "UpdateSystem",
1406 risk_level: RiskLevel::High,
1407 summary: "Upgrade the system",
1408 approval_id: None,
1409 warnings_json: "[]",
1410 created_at,
1411 identity: ChainIdentity::LegacyV1,
1412 },
1413 "",
1414 );
1415 conn.execute(
1416 "INSERT INTO transactions (
1417 transaction_id, request_id, request_hash, action_name, risk_level,
1418 status, approval_id, summary, warnings_json, created_at,
1419 seq, key_id, chain_hash, prev_chain_hash
1420 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1421 params![
1422 transaction_id,
1423 "req-legacy",
1424 "hash-legacy",
1425 "UpdateSystem",
1426 serialize_field(&RiskLevel::High).unwrap(),
1427 serialize_field(&JobState::Queued).unwrap(),
1428 "Upgrade the system",
1429 "[]",
1430 created_at,
1431 CURRENT_KEY_ID,
1432 chain_hash,
1433 ],
1434 )
1435 .unwrap();
1436 transaction_id.to_string()
1437 }
1438
1439 fn v2_database(path: &Path, key: &AuditKey) -> String {
1446 let conn = Connection::open(path).unwrap();
1447 conn.execute_batch(
1448 "CREATE TABLE schema_migrations (
1449 version INTEGER PRIMARY KEY,
1450 name TEXT NOT NULL,
1451 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1452 );",
1453 )
1454 .unwrap();
1455 for migration in &SQLITE_MIGRATIONS[0..=1] {
1456 conn.execute_batch(migration.sql).unwrap();
1457 conn.execute(
1458 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1459 params![migration.version, migration.name],
1460 )
1461 .unwrap();
1462 }
1463
1464 let transaction_id = "tx-v2";
1465 let created_at = "2026-07-01T12:00:00.000Z";
1466 let chain_hash = key.chain_hash(
1467 &ChainContent {
1468 seq: 1,
1469 key_id: CURRENT_KEY_ID,
1470 transaction_id,
1471 request_id: "req-v2",
1472 request_hash: "hash-v2",
1473 action_name: "UpdateSystem",
1474 risk_level: RiskLevel::High,
1475 summary: "Upgrade the system",
1476 approval_id: None,
1477 warnings_json: "[]",
1478 created_at,
1479 identity: ChainIdentity::V2 {
1480 caller_role: "admin",
1481 event_tip: "",
1482 },
1483 },
1484 "",
1485 );
1486 conn.execute(
1487 "INSERT INTO transactions (
1488 transaction_id, request_id, request_hash, action_name, risk_level,
1489 status, approval_id, summary, warnings_json, created_at,
1490 seq, key_id, chain_hash, prev_chain_hash,
1491 chain_version, caller_role, event_tip
1492 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
1493 params![
1494 transaction_id,
1495 "req-v2",
1496 "hash-v2",
1497 "UpdateSystem",
1498 serialize_field(&RiskLevel::High).unwrap(),
1499 serialize_field(&JobState::Queued).unwrap(),
1500 "Upgrade the system",
1501 "[]",
1502 created_at,
1503 CURRENT_KEY_ID,
1504 chain_hash,
1505 ],
1506 )
1507 .unwrap();
1508 transaction_id.to_string()
1509 }
1510
1511 #[test]
1519 fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
1520 let dir = tempdir().unwrap();
1521 let db_path = dir.path().join("tx.db");
1522 let key = AuditKey::from_bytes(vec![0x42; 32]);
1523 let old_transaction = v2_database(&db_path, &key);
1524
1525 let store = test_store(&db_path);
1526
1527 let version: i64 = store
1528 .connection()
1529 .unwrap()
1530 .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
1531 row.get(0)
1532 })
1533 .unwrap();
1534 assert_eq!(version, 3, "opening a v2 database must apply migration 3");
1535
1536 let rows = store.fetch_chain_rows().unwrap();
1537 assert_eq!(rows.len(), 1);
1538 assert_eq!(rows[0].transaction_id, old_transaction);
1539 assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
1540 assert_eq!(
1541 rows[0].caller_principal, None,
1542 "migration 3 must not invent a principal for a row signed without one"
1543 );
1544 assert!(matches!(
1545 store.verify_audit_chain(&key).unwrap(),
1546 audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
1547 ));
1548
1549 let mut fresh = queued_transaction();
1551 fresh.caller_principal = CallerPrincipal::Uid(4242);
1552 store.record(fresh).unwrap();
1553
1554 let rows = store.fetch_chain_rows().unwrap();
1555 assert_eq!(rows.len(), 2);
1556 assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
1557 assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
1558 assert_eq!(
1559 rows[1].prev_chain_hash, rows[0].chain_hash,
1560 "the v3 row must chain onto the v2 row, not start a new chain"
1561 );
1562 assert!(matches!(
1563 store.verify_audit_chain(&key).unwrap(),
1564 audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
1565 ));
1566
1567 let census = audit_chain::AttributionCensus::of(&rows);
1575 assert_eq!(census.rows(), 2);
1576 assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
1577 assert_eq!(
1578 census.not_recorded(),
1579 1,
1580 "the v2 row's encoding never signed a principal"
1581 );
1582 assert_eq!(census.attribution_failed(), 0);
1583 assert_eq!(census.unattested(), 0);
1584 assert_eq!(census.unnamed(), 1);
1585 }
1586
1587 #[test]
1588 fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1589 let dir = tempdir().unwrap();
1594 let db_path = dir.path().join("tx.db");
1595 let key = AuditKey::from_bytes(vec![0x42; 32]);
1596 legacy_v1_database(&db_path, &key);
1597
1598 let store = test_store(&db_path);
1599 assert_eq!(
1600 store.verify_audit_chain(&key).unwrap(),
1601 VerifyOutcome::Intact { rows_checked: 1 }
1602 );
1603 let rows = store.fetch_chain_rows().unwrap();
1604 assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1605 assert_eq!(rows[0].caller_role, None);
1606 }
1607
1608 #[test]
1609 fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1610 let dir = tempdir().unwrap();
1614 let db_path = dir.path().join("tx.db");
1615 let key = AuditKey::from_bytes(vec![0x42; 32]);
1616 legacy_v1_database(&db_path, &key);
1617
1618 let store = test_store(&db_path);
1619 store.record(queued_transaction()).unwrap();
1620
1621 let rows = store.fetch_chain_rows().unwrap();
1622 assert_eq!(rows.len(), 2);
1623 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1624 assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1625 assert_eq!(
1626 store.verify_audit_chain(&key).unwrap(),
1627 VerifyOutcome::Intact { rows_checked: 2 }
1628 );
1629 }
1630
1631 #[test]
1632 fn a_schema_newer_than_this_binary_is_refused() {
1633 let dir = tempdir().unwrap();
1634 let db_path = dir.path().join("tx.db");
1635 let key = AuditKey::from_bytes(vec![0x42; 32]);
1636 legacy_v1_database(&db_path, &key);
1637 let conn = Connection::open(&db_path).unwrap();
1638 conn.execute(
1639 "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1640 [],
1641 )
1642 .unwrap();
1643 drop(conn);
1644
1645 let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1646 assert!(
1647 matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1648 "expected a refusal naming the unsupported version, got {err:?}"
1649 );
1650 }
1651
1652 #[test]
1655 fn the_signed_row_records_which_role_asked() {
1656 let dir = tempdir().unwrap();
1657 let store = test_store(dir.path().join("tx.db"));
1658 let mut tx = queued_transaction();
1659 tx.caller_role = CallerRole::Admin;
1660 store.record(tx).unwrap();
1661
1662 let rows = store.fetch_chain_rows().unwrap();
1663 assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1664 }
1665
1666 #[test]
1667 fn approving_consuming_and_revoking_each_append_a_chained_event() {
1668 let dir = tempdir().unwrap();
1669 let key = AuditKey::from_bytes(vec![0x42; 32]);
1670 let store = test_store(dir.path().join("tx.db"));
1671
1672 let a = store.record(queued_transaction()).unwrap();
1674 let receipt = store
1675 .approve_transaction(&a.transaction_id)
1676 .unwrap()
1677 .expect("queued transaction approves");
1678 let digest = audit_chain::approval_receipt_digest(&receipt);
1679 assert!(store
1680 .claim_approved_for_execution(&a.transaction_id, &digest)
1681 .unwrap());
1682
1683 let b = store.record(queued_transaction()).unwrap();
1685 store.approve_transaction(&b.transaction_id).unwrap();
1686 assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap());
1687
1688 let events = store.fetch_event_rows().unwrap();
1689 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1690 assert_eq!(
1691 kinds,
1692 vec![
1693 "approval_granted",
1694 "approval_consumed",
1695 "approval_granted",
1696 "approval_revoked",
1697 ]
1698 );
1699 assert_eq!(
1700 store.verify_event_chain(&key).unwrap(),
1701 VerifyOutcome::Intact { rows_checked: 4 }
1702 );
1703 }
1704
1705 #[test]
1706 fn a_failed_approval_appends_no_event() {
1707 let dir = tempdir().unwrap();
1711 let store = test_store(dir.path().join("tx.db"));
1712 let tx = store.record(queued_transaction()).unwrap();
1713 store.approve_transaction(&tx.transaction_id).unwrap();
1714 store.approve_transaction(&tx.transaction_id).unwrap();
1715 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1716
1717 assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap());
1719 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1720 }
1721
1722 #[test]
1723 fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
1724 let dir = tempdir().unwrap();
1730 let db_path = dir.path().join("tx.db");
1731 let key = AuditKey::from_bytes(vec![0x42; 32]);
1732 let store = test_store(&db_path);
1733
1734 let a = store.record(queued_transaction()).unwrap();
1735 store.approve_transaction(&a.transaction_id).unwrap();
1736 store.record(queued_transaction()).unwrap();
1739
1740 assert_eq!(
1741 store.verify_event_chain(&key).unwrap(),
1742 VerifyOutcome::Intact { rows_checked: 1 }
1743 );
1744 assert_eq!(
1745 store.verify_event_binding().unwrap(),
1746 audit_chain::BindingOutcome::Consistent {
1747 bindings_checked: 1
1748 }
1749 );
1750
1751 let conn = Connection::open(&db_path).unwrap();
1753 conn.execute("DELETE FROM transaction_approvals", [])
1754 .unwrap();
1755 conn.execute("DELETE FROM audit_events", []).unwrap();
1756 drop(conn);
1757
1758 assert_eq!(
1762 store.verify_event_chain(&key).unwrap(),
1763 VerifyOutcome::Intact { rows_checked: 0 }
1764 );
1765 match store.verify_event_binding().unwrap() {
1766 audit_chain::BindingOutcome::MissingEvent {
1767 transaction_seq, ..
1768 } => assert_eq!(transaction_seq, 2),
1769 other => panic!("expected MissingEvent, got {other:?}"),
1770 }
1771 }
1772
1773 #[test]
1774 fn a_read_only_store_cannot_revoke_or_claim() {
1775 let dir = tempdir().unwrap();
1778 let db_path = dir.path().join("tx.db");
1779 let store = test_store(&db_path);
1780 store.record(queued_transaction()).unwrap();
1781
1782 let read_only = TransactionStore::open_read_only(&db_path).unwrap();
1783 assert!(matches!(
1784 read_only.revoke_unconsumed_approval("tx"),
1785 Err(TransactionStoreError::AuditChainMissing(_))
1786 ));
1787 assert!(matches!(
1788 read_only.claim_approved_for_execution("tx", "digest"),
1789 Err(TransactionStoreError::AuditChainMissing(_))
1790 ));
1791 }
1792
1793 #[test]
1800 fn record_persists_the_caller_principal_under_the_current_encoding() {
1801 let dir = tempdir().unwrap();
1802 let store = test_store(dir.path().join("tx.db"));
1803 let mut tx = queued_transaction();
1804 tx.caller_principal = CallerPrincipal::Uid(4242);
1805 store.record(tx).unwrap();
1806
1807 let conn = store.connection().unwrap();
1808 let (version, role, principal): (i64, Option<String>, Option<String>) = conn
1809 .query_row(
1810 "SELECT chain_version, caller_role, caller_principal FROM transactions",
1811 [],
1812 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1813 )
1814 .unwrap();
1815
1816 assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
1817 assert_eq!(role.as_deref(), Some("dev"));
1818 assert_eq!(
1819 principal.as_deref(),
1820 Some("uid:4242"),
1821 "the row must name the account the dispatcher resolved"
1822 );
1823
1824 let key = AuditKey::from_bytes(vec![0x42; 32]);
1826 assert!(matches!(
1827 store.verify_audit_chain(&key).unwrap(),
1828 audit_chain::VerifyOutcome::Intact { .. }
1829 ));
1830 }
1831
1832 #[test]
1833 fn record_writes_audit_chain_columns() {
1834 let dir = tempdir().unwrap();
1835 let store = test_store(dir.path().join("tx.db"));
1836 let _record = store.record(queued_transaction()).unwrap();
1837
1838 let conn = store.connection().unwrap();
1839 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
1840 .query_row(
1841 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
1842 [],
1843 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1844 )
1845 .unwrap();
1846 assert_eq!(seq, 1, "first row gets seq=1");
1847 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
1848 assert_eq!(prev, "", "first row has empty prev_chain_hash");
1849 assert_eq!(
1850 chain_hash.len(),
1851 audit_chain::HASH_HEX_LEN,
1852 "chain_hash is a hex-encoded Ed25519 signature"
1853 );
1854 }
1855
1856 #[test]
1857 fn sequential_records_produce_chained_hashes() {
1858 let dir = tempdir().unwrap();
1859 let store = test_store(dir.path().join("tx.db"));
1860 store.record(queued_transaction()).unwrap();
1861 store.record(queued_transaction()).unwrap();
1862 store.record(queued_transaction()).unwrap();
1863
1864 let rows = store.fetch_chain_rows().unwrap();
1865 assert_eq!(rows.len(), 3);
1866 assert_eq!(rows[0].seq, 1);
1867 assert_eq!(rows[1].seq, 2);
1868 assert_eq!(rows[2].seq, 3);
1869 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1871 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
1872 }
1873
1874 #[test]
1886 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
1887 const WORKERS: usize = 8;
1888 const RECORDS_PER_WORKER: usize = 10;
1889 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
1890
1891 let dir = tempdir().unwrap();
1892 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1893
1894 let mut handles = Vec::with_capacity(WORKERS);
1895 for w in 0..WORKERS {
1896 let store = std::sync::Arc::clone(&store);
1897 handles.push(std::thread::spawn(move || {
1898 for r in 0..RECORDS_PER_WORKER {
1899 let tx = NewTransaction {
1900 request_id: format!("worker-{w}-record-{r}"),
1901 request_hash: format!("hash-{w}-{r}"),
1902 action_name: "GetSystemState".to_string(),
1903 risk_level: RiskLevel::Low,
1904 summary: format!("worker {w} record {r}"),
1905 warnings: vec![],
1906 caller_role: CallerRole::Dev,
1907 caller_principal: CallerPrincipal::Uid(3000 + w as u32),
1911 };
1912 store
1913 .record(tx)
1914 .expect("record must succeed under contention");
1915 }
1916 }));
1917 }
1918 for h in handles {
1919 h.join().expect("worker thread did not panic");
1920 }
1921
1922 let key = AuditKey::from_bytes(vec![0x42; 32]);
1924 let outcome = store.verify_audit_chain(&key).unwrap();
1925 match outcome {
1926 VerifyOutcome::Intact { rows_checked } => {
1927 assert_eq!(
1928 rows_checked, TOTAL as u64,
1929 "expected {TOTAL} rows checked, got {rows_checked}"
1930 );
1931 }
1932 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
1933 }
1934
1935 {
1939 let conn = store.connection().unwrap();
1940 let mut stmt = conn
1941 .prepare("SELECT summary, caller_principal FROM transactions")
1942 .unwrap();
1943 let pairs: Vec<(String, Option<String>)> = stmt
1944 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1945 .unwrap()
1946 .map(|r| r.unwrap())
1947 .collect();
1948 assert_eq!(pairs.len(), TOTAL);
1949 for (summary, principal) in pairs {
1950 let worker: u32 = summary
1951 .split_whitespace()
1952 .nth(1)
1953 .and_then(|w| w.parse().ok())
1954 .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
1955 assert_eq!(
1956 principal.as_deref(),
1957 Some(format!("uid:{}", 3000 + worker).as_str()),
1958 "row {summary:?} was attributed to the wrong account"
1959 );
1960 }
1961 }
1962
1963 let conn = store.connection().unwrap();
1965 let mut stmt = conn
1966 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
1967 .unwrap();
1968 let seqs: Vec<i64> = stmt
1969 .query_map([], |row| row.get::<_, i64>(0))
1970 .unwrap()
1971 .map(|r| r.unwrap())
1972 .collect();
1973 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
1974 for (i, s) in seqs.iter().enumerate() {
1975 assert_eq!(
1976 *s,
1977 (i as i64) + 1,
1978 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
1979 );
1980 }
1981 }
1982
1983 #[test]
1984 fn verify_audit_chain_intact_after_inserts() {
1985 let dir = tempdir().unwrap();
1986 let store = test_store(dir.path().join("tx.db"));
1987 for _ in 0..3 {
1988 store.record(queued_transaction()).unwrap();
1989 }
1990 let key = AuditKey::from_bytes(vec![0x42; 32]);
1991 let outcome = store.verify_audit_chain(&key).unwrap();
1992 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1993 }
1994
1995 #[test]
1996 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
1997 let dir = tempdir().unwrap();
1998 let store = test_store(dir.path().join("tx.db"));
1999 for _ in 0..3 {
2000 store.record(queued_transaction()).unwrap();
2001 }
2002 let key = AuditKey::from_bytes(vec![0x42; 32]);
2004 let outcome = store
2005 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
2006 .unwrap();
2007 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2008 }
2009
2010 #[test]
2011 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
2012 let dir = tempdir().unwrap();
2013 let store = test_store(dir.path().join("tx.db"));
2014 store.record(queued_transaction()).unwrap();
2015 let other = AuditKey::from_bytes(vec![0x99; 32]);
2017 let outcome = store
2018 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
2019 .unwrap();
2020 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2021 }
2022
2023 #[test]
2024 fn verify_detects_tampered_summary() {
2025 let dir = tempdir().unwrap();
2026 let store = test_store(dir.path().join("tx.db"));
2027 let tx = store.record(queued_transaction()).unwrap();
2028
2029 let conn = store.connection().unwrap();
2032 conn.execute(
2033 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
2034 params!["EVIL CHANGE", tx.transaction_id],
2035 )
2036 .unwrap();
2037
2038 let key = AuditKey::from_bytes(vec![0x42; 32]);
2039 let outcome = store.verify_audit_chain(&key).unwrap();
2040 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2041 }
2042
2043 #[test]
2044 fn status_update_does_not_break_chain() {
2045 let dir = tempdir().unwrap();
2047 let store = test_store(dir.path().join("tx.db"));
2048 let tx = store.record(queued_transaction()).unwrap();
2049 store
2050 .update_status(&tx.transaction_id, JobState::Running)
2051 .unwrap();
2052 store
2053 .update_status(&tx.transaction_id, JobState::Succeeded)
2054 .unwrap();
2055
2056 let key = AuditKey::from_bytes(vec![0x42; 32]);
2057 let outcome = store.verify_audit_chain(&key).unwrap();
2058 assert!(
2059 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
2060 "status mutation must not break the chain (status not in chain content): {outcome:?}"
2061 );
2062 }
2063
2064 #[test]
2065 fn open_read_only_rejects_record() {
2066 let dir = tempdir().unwrap();
2067 let key_path = dir.path().join("audit-key");
2068 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
2069 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2070
2071 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
2072 let result = store.record(queued_transaction());
2073 assert!(matches!(
2074 result,
2075 Err(TransactionStoreError::AuditChainMissing(_))
2076 ));
2077 }
2078
2079 #[test]
2080 fn ensure_private_dir_creates_with_0700_mode() {
2081 let root = tempdir().unwrap();
2082 let target = root.path().join("a/b/c");
2083 ensure_private_dir(&target).unwrap();
2084 assert!(target.is_dir());
2085 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2086 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
2087 }
2088
2089 #[test]
2097 fn the_audit_database_is_created_owner_only() {
2098 let dir = tempdir().unwrap();
2099 let db = dir.path().join("daemon.sqlite");
2100 let store = TransactionStore::open(&db).unwrap();
2101 store
2103 .record(NewTransaction {
2104 request_id: "mode-probe".to_string(),
2105 request_hash: "hash-mode-probe".to_string(),
2106 action_name: "GetSystemState".to_string(),
2107 risk_level: RiskLevel::Low,
2108 summary: "probe".to_string(),
2109 warnings: vec![],
2110 caller_role: CallerRole::Dev,
2111 caller_principal: CallerPrincipal::Uid(3000),
2112 })
2113 .unwrap();
2114
2115 for path in [db.clone(), db.with_extension("sqlite-wal")] {
2116 if !path.exists() {
2117 continue;
2118 }
2119 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2120 assert_eq!(
2121 mode,
2122 0o600,
2123 "{} must be owner-only, got {mode:o}",
2124 path.display()
2125 );
2126 }
2127 }
2128
2129 #[test]
2130 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
2131 let root = tempdir().unwrap();
2132 let target = root.path().join("preexisting");
2133 std::fs::create_dir(&target).unwrap();
2134 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
2135 ensure_private_dir(&target).unwrap();
2136 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2138 assert_eq!(mode, 0o755);
2139 }
2140
2141 #[test]
2142 fn open_creates_parent_with_private_mode() {
2143 let root = tempdir().unwrap();
2144 let db_path = root.path().join("nested/dirs/daemon.sqlite");
2145 let _store = test_store(&db_path);
2146 let parent = db_path.parent().unwrap();
2147 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
2148 assert_eq!(mode, 0o700);
2149 }
2150
2151 fn queued_transaction() -> NewTransaction {
2152 NewTransaction {
2153 request_id: "req-1".to_string(),
2154 request_hash: "hash-abc".to_string(),
2155 action_name: "UpdateSystem".to_string(),
2156 risk_level: RiskLevel::High,
2157 summary: "Upgrade the system".to_string(),
2158 warnings: vec![],
2159 caller_role: CallerRole::Dev,
2160 caller_principal: CallerPrincipal::Uid(1000),
2161 }
2162 }
2163
2164 #[test]
2165 fn update_status_transitions_queued_to_running() {
2166 let dir = tempdir().unwrap();
2167 let store = test_store(dir.path().join("tx.db"));
2168 let tx = store.record(queued_transaction()).unwrap();
2169
2170 store
2171 .update_status(&tx.transaction_id, JobState::Running)
2172 .unwrap();
2173
2174 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2175 assert_eq!(updated.status, JobState::Running);
2176 }
2177
2178 #[test]
2179 fn update_status_transitions_running_to_succeeded() {
2180 let dir = tempdir().unwrap();
2181 let store = test_store(dir.path().join("tx.db"));
2182 let tx = store.record(queued_transaction()).unwrap();
2183
2184 store
2185 .update_status(&tx.transaction_id, JobState::Running)
2186 .unwrap();
2187 store
2188 .update_status(&tx.transaction_id, JobState::Succeeded)
2189 .unwrap();
2190
2191 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2192 assert_eq!(updated.status, JobState::Succeeded);
2193 }
2194
2195 #[test]
2196 fn update_status_for_unknown_id_returns_not_found() {
2197 let dir = tempdir().unwrap();
2198 let store = test_store(dir.path().join("tx.db"));
2199
2200 let result = store.update_status("does-not-exist", JobState::Running);
2201 assert!(
2202 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2203 "expected NotFound, got: {result:?}"
2204 );
2205 }
2206
2207 #[test]
2208 fn update_status_leaves_other_fields_intact() {
2209 let dir = tempdir().unwrap();
2210 let store = test_store(dir.path().join("tx.db"));
2211 let tx = store.record(queued_transaction()).unwrap();
2212
2213 store
2214 .update_status(&tx.transaction_id, JobState::Running)
2215 .unwrap();
2216 store
2217 .update_status(&tx.transaction_id, JobState::Failed)
2218 .unwrap();
2219
2220 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2221 assert_eq!(updated.action_name, "UpdateSystem");
2222 assert_eq!(updated.risk_level, RiskLevel::High);
2223 assert_eq!(updated.status, JobState::Failed);
2224 }
2225
2226 #[test]
2235 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2236 let dir = tempdir().unwrap();
2237 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2238 let tx = store.record(queued_transaction()).unwrap();
2239 store
2240 .update_status(&tx.transaction_id, JobState::Running)
2241 .unwrap();
2242
2243 let store_a = std::sync::Arc::clone(&store);
2244 let id_a = tx.transaction_id.clone();
2245 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2246
2247 let store_b = std::sync::Arc::clone(&store);
2248 let id_b = tx.transaction_id.clone();
2249 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2250
2251 let succeed_result = succeed.join().unwrap();
2252 let fail_result = fail.join().unwrap();
2253
2254 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2256 .into_iter()
2257 .filter(|ok| *ok)
2258 .count();
2259 assert_eq!(
2260 oks, 1,
2261 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2262 fail={fail_result:?}"
2263 );
2264
2265 let loser = if succeed_result.is_ok() {
2281 &fail_result
2282 } else {
2283 &succeed_result
2284 };
2285 match loser {
2286 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
2287 assert_eq!(id, &tx.transaction_id);
2288 }
2289 Err(TransactionStoreError::InvalidTransition { from, to }) => {
2290 let winner = if succeed_result.is_ok() {
2292 JobState::Succeeded
2293 } else {
2294 JobState::Failed
2295 };
2296 assert_eq!(
2297 *from, winner,
2298 "the loser must have observed the winner's committed status"
2299 );
2300 assert_ne!(*to, winner, "the loser must be the other transition");
2301 }
2302 other => panic!(
2303 "the losing transition must be refused with either \
2304 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
2305 ),
2306 }
2307
2308 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
2311 if succeed_result.is_ok() {
2312 assert_eq!(final_status, JobState::Succeeded);
2313 } else {
2314 assert_eq!(final_status, JobState::Failed);
2315 }
2316 }
2317
2318 #[test]
2319 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
2320 let dir = tempdir().unwrap();
2323 let store = test_store(dir.path().join("tx.db"));
2324 let tx = store.record(queued_transaction()).unwrap();
2325 let receipt = store
2326 .approve_transaction(&tx.transaction_id)
2327 .unwrap()
2328 .expect("approved");
2329 let digest = audit_chain::approval_receipt_digest(&receipt);
2330
2331 assert!(
2332 store
2333 .revoke_unconsumed_approval(&tx.transaction_id)
2334 .unwrap(),
2335 "an unconsumed approval must be revocable"
2336 );
2337 assert!(
2338 !store
2339 .claim_approved_for_execution(&tx.transaction_id, &digest)
2340 .unwrap(),
2341 "a revoked receipt must no longer be claimable"
2342 );
2343 }
2344
2345 #[test]
2346 fn revoke_cannot_retract_an_approval_that_was_already_used() {
2347 let dir = tempdir().unwrap();
2351 let store = test_store(dir.path().join("tx.db"));
2352 let tx = store.record(queued_transaction()).unwrap();
2353 let receipt = store
2354 .approve_transaction(&tx.transaction_id)
2355 .unwrap()
2356 .expect("approved");
2357 let digest = audit_chain::approval_receipt_digest(&receipt);
2358 assert!(store
2359 .claim_approved_for_execution(&tx.transaction_id, &digest)
2360 .unwrap());
2361
2362 assert!(
2363 !store
2364 .revoke_unconsumed_approval(&tx.transaction_id)
2365 .unwrap(),
2366 "a consumed approval must not be revocable"
2367 );
2368 assert_eq!(
2369 store.get(&tx.transaction_id).unwrap().unwrap().status,
2370 JobState::Running,
2371 "a failed revocation must not disturb the running transaction"
2372 );
2373 }
2374
2375 #[test]
2376 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
2377 let dir = tempdir().unwrap();
2381 let store = test_store(dir.path().join("tx.db"));
2382 let tx = store.record(queued_transaction()).unwrap();
2383 let receipt = store
2384 .approve_transaction(&tx.transaction_id)
2385 .unwrap()
2386 .expect("approved");
2387 let digest = audit_chain::approval_receipt_digest(&receipt);
2388 assert!(store
2389 .claim_approved_for_execution(&tx.transaction_id, &digest)
2390 .unwrap());
2391
2392 assert!(
2394 store
2395 .approve_transaction(&tx.transaction_id)
2396 .unwrap()
2397 .is_none(),
2398 "a Running transaction must not be approvable"
2399 );
2400
2401 store
2403 .update_status(&tx.transaction_id, JobState::Succeeded)
2404 .unwrap();
2405 assert!(
2406 store
2407 .approve_transaction(&tx.transaction_id)
2408 .unwrap()
2409 .is_none(),
2410 "a completed transaction must not be approvable"
2411 );
2412 }
2413
2414 #[test]
2415 fn only_one_of_two_concurrent_claims_can_execute() {
2416 let dir = tempdir().unwrap();
2421 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2422 let tx = store.record(queued_transaction()).unwrap();
2423 let receipt = store
2424 .approve_transaction(&tx.transaction_id)
2425 .unwrap()
2426 .expect("approved");
2427 let digest = audit_chain::approval_receipt_digest(&receipt);
2428
2429 let mut handles = Vec::new();
2430 for _ in 0..2 {
2431 let store = std::sync::Arc::clone(&store);
2432 let id = tx.transaction_id.clone();
2433 let digest = digest.clone();
2434 handles.push(std::thread::spawn(move || {
2435 store.claim_approved_for_execution(&id, &digest)
2436 }));
2437 }
2438 let claims: Vec<bool> = handles
2439 .into_iter()
2440 .map(|h| h.join().unwrap().unwrap_or(false))
2441 .collect();
2442
2443 assert_eq!(
2444 claims.iter().filter(|c| **c).count(),
2445 1,
2446 "exactly one claim may win: {claims:?}"
2447 );
2448 assert_eq!(
2449 store.get(&tx.transaction_id).unwrap().unwrap().status,
2450 JobState::Running
2451 );
2452 }
2453
2454 #[test]
2455 fn cancel_queued_cancels_a_queued_transaction_once() {
2456 let dir = tempdir().unwrap();
2457 let store = test_store(dir.path().join("tx.db"));
2458 let tx = store.record(queued_transaction()).unwrap();
2459
2460 assert!(
2461 store.cancel_queued(&tx.transaction_id).unwrap(),
2462 "a queued transaction is cancelable"
2463 );
2464 assert_eq!(
2465 store.get(&tx.transaction_id).unwrap().unwrap().status,
2466 JobState::Canceled
2467 );
2468 assert!(
2469 !store.cancel_queued(&tx.transaction_id).unwrap(),
2470 "an already-canceled transaction is not cancelable again"
2471 );
2472 assert!(
2473 !store.cancel_queued("no-such-transaction").unwrap(),
2474 "a missing transaction is not cancelable"
2475 );
2476 assert!(
2477 store.fetch_event_rows().unwrap().is_empty(),
2478 "canceling an unapproved transaction must not append an approval event"
2479 );
2480 }
2481
2482 #[test]
2483 fn cancel_queued_revokes_an_unconsumed_approval_and_appends_event() {
2484 let dir = tempdir().unwrap();
2485 let store = test_store(dir.path().join("tx.db"));
2486 let tx = store.record(queued_transaction()).unwrap();
2487 let receipt = store
2488 .approve_transaction(&tx.transaction_id)
2489 .unwrap()
2490 .expect("queued transaction is approvable");
2491 let digest = audit_chain::approval_receipt_digest(&receipt);
2492
2493 assert!(store.cancel_queued(&tx.transaction_id).unwrap());
2494 assert_eq!(
2495 store.get(&tx.transaction_id).unwrap().unwrap().status,
2496 JobState::Canceled
2497 );
2498 assert!(
2499 !store
2500 .claim_approved_for_execution(&tx.transaction_id, &digest)
2501 .unwrap(),
2502 "canceling must revoke the unconsumed receipt"
2503 );
2504 assert_eq!(
2505 store
2506 .fetch_event_rows()
2507 .unwrap()
2508 .iter()
2509 .map(|event| event.kind.as_str())
2510 .collect::<Vec<_>>(),
2511 vec!["approval_granted", "approval_revoked"]
2512 );
2513 }
2514
2515 #[test]
2516 fn cancel_queued_refuses_a_running_transaction() {
2517 let dir = tempdir().unwrap();
2520 let store = test_store(dir.path().join("tx.db"));
2521 let tx = store.record(queued_transaction()).unwrap();
2522 let receipt = store
2523 .approve_transaction(&tx.transaction_id)
2524 .unwrap()
2525 .expect("approved");
2526 let digest = audit_chain::approval_receipt_digest(&receipt);
2527 assert!(store
2528 .claim_approved_for_execution(&tx.transaction_id, &digest)
2529 .unwrap());
2530
2531 assert!(
2532 !store.cancel_queued(&tx.transaction_id).unwrap(),
2533 "a running transaction must not be cancelable"
2534 );
2535 assert_eq!(
2536 store.get(&tx.transaction_id).unwrap().unwrap().status,
2537 JobState::Running,
2538 "cancel must not disturb a running transaction"
2539 );
2540 }
2541
2542 #[test]
2543 fn approved_receipt_is_required_and_consumed_once() {
2544 let dir = tempdir().unwrap();
2545 let store = test_store(dir.path().join("tx.db"));
2546 let tx = store.record(queued_transaction()).unwrap();
2547
2548 assert!(
2549 !store
2550 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
2551 .unwrap(),
2552 "an unapproved preview must not execute"
2553 );
2554 let receipt = store
2555 .approve_transaction(&tx.transaction_id)
2556 .unwrap()
2557 .expect("first approval must succeed");
2558 let digest = audit_chain::approval_receipt_digest(&receipt);
2559 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
2560 assert!(
2561 store
2562 .approve_transaction(&tx.transaction_id)
2563 .unwrap()
2564 .is_none(),
2565 "approval is immutable once issued"
2566 );
2567 assert!(
2568 !store
2569 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
2570 .unwrap(),
2571 "a forged receipt must not execute"
2572 );
2573 assert!(
2574 store
2575 .claim_approved_for_execution(&tx.transaction_id, &digest)
2576 .unwrap(),
2577 "the exact approved receipt must execute"
2578 );
2579 assert!(
2580 !store
2581 .claim_approved_for_execution(&tx.transaction_id, &digest)
2582 .unwrap(),
2583 "the receipt must be one-time"
2584 );
2585 }
2586
2587 #[test]
2588 fn approval_commitment_is_covered_by_the_signed_chain() {
2589 let dir = tempdir().unwrap();
2590 let store = test_store(dir.path().join("tx.db"));
2591 let tx = store.record(queued_transaction()).unwrap();
2592 assert!(tx.approval_id.is_some());
2593
2594 store
2595 .connection()
2596 .unwrap()
2597 .execute(
2598 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2599 params![tx.transaction_id],
2600 )
2601 .unwrap();
2602
2603 let key = AuditKey::from_bytes(vec![0x42; 32]);
2604 assert!(matches!(
2605 store.verify_audit_chain(&key).unwrap(),
2606 VerifyOutcome::Broken { .. }
2607 ));
2608 }
2609
2610 #[test]
2611 fn approve_rejects_a_forged_commitment_at_runtime() {
2612 let dir = tempdir().unwrap();
2616 let store = test_store(dir.path().join("tx.db"));
2617 let tx = store.record(queued_transaction()).unwrap();
2618
2619 store
2620 .connection()
2621 .unwrap()
2622 .execute(
2623 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2624 params![tx.transaction_id],
2625 )
2626 .unwrap();
2627
2628 let err = store
2629 .approve_transaction(&tx.transaction_id)
2630 .expect_err("a forged commitment must be rejected, not approved");
2631 assert!(
2632 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
2633 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
2634 );
2635 }
2636
2637 #[test]
2638 fn stale_iso_timestamp_cannot_be_approved() {
2639 let dir = tempdir().unwrap();
2640 let store = test_store(dir.path().join("tx.db"));
2641 let tx = store.record(queued_transaction()).unwrap();
2642 let conn = store.connection().unwrap();
2643 conn.execute(
2644 "UPDATE transactions \
2645 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2646 WHERE transaction_id = ?1",
2647 params![tx.transaction_id],
2648 )
2649 .unwrap();
2650
2651 assert!(
2652 store
2653 .approve_transaction(&tx.transaction_id)
2654 .unwrap()
2655 .is_none(),
2656 "a production-format timestamp outside the TTL must not be approved"
2657 );
2658 }
2659
2660 #[test]
2661 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
2662 let dir = tempdir().unwrap();
2667 let store = test_store(dir.path().join("tx.db"));
2668 let tx = store.record(queued_transaction()).unwrap();
2669 let receipt = store
2670 .approve_transaction(&tx.transaction_id)
2671 .unwrap()
2672 .expect("a fresh approval succeeds");
2673 let digest = audit_chain::approval_receipt_digest(&receipt);
2674
2675 let conn = store.connection().unwrap();
2677 conn.execute(
2678 "UPDATE transactions \
2679 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2680 WHERE transaction_id = ?1",
2681 params![tx.transaction_id],
2682 )
2683 .unwrap();
2684
2685 assert!(
2686 !store
2687 .claim_approved_for_execution(&tx.transaction_id, &digest)
2688 .unwrap(),
2689 "an approval aged past the TTL must not be claimable at execute time"
2690 );
2691 assert_eq!(
2692 store.get(&tx.transaction_id).unwrap().unwrap().status,
2693 JobState::Queued,
2694 "a TTL-expired claim must leave the transaction Queued, never Running"
2695 );
2696 }
2697
2698 #[test]
2699 fn cleanup_stale_queued_cancels_old_records() {
2700 let dir = tempdir().unwrap();
2701 let store = test_store(dir.path().join("tx.db"));
2702
2703 let fresh = store.record(queued_transaction()).unwrap();
2705 let stale = store.record(queued_transaction()).unwrap();
2706
2707 let conn = store.connection().unwrap();
2709 conn.execute(
2710 "UPDATE transactions \
2711 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2712 WHERE transaction_id = ?1",
2713 params![stale.transaction_id],
2714 )
2715 .unwrap();
2716
2717 let canceled = store.cleanup_stale_queued().unwrap();
2718 assert_eq!(canceled, 1, "only the stale record should be canceled");
2719
2720 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
2722 assert_eq!(stale_record.status, JobState::Canceled);
2723
2724 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
2726 assert_eq!(fresh_record.status, JobState::Queued);
2727 }
2728
2729 #[test]
2730 fn cleanup_stale_queued_revokes_every_unconsumed_approval() {
2731 let dir = tempdir().unwrap();
2732 let store = test_store(dir.path().join("tx.db"));
2733 let transactions: Vec<_> = (0..3)
2734 .map(|_| {
2735 let transaction = store.record(queued_transaction()).unwrap();
2736 store
2737 .approve_transaction(&transaction.transaction_id)
2738 .unwrap()
2739 .expect("stale transaction is approvable");
2740 transaction
2741 })
2742 .collect();
2743
2744 let conn = store.connection().unwrap();
2745 for transaction in &transactions {
2746 conn.execute(
2747 "UPDATE transactions \
2748 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2749 WHERE transaction_id = ?1",
2750 params![transaction.transaction_id],
2751 )
2752 .unwrap();
2753 }
2754
2755 assert_eq!(store.cleanup_stale_queued().unwrap(), 3);
2756 for transaction in &transactions {
2757 assert_eq!(
2758 store
2759 .get(&transaction.transaction_id)
2760 .unwrap()
2761 .unwrap()
2762 .status,
2763 JobState::Canceled
2764 );
2765 assert!(!store
2766 .revoke_unconsumed_approval(&transaction.transaction_id)
2767 .unwrap());
2768 }
2769 assert_eq!(
2770 store
2771 .fetch_event_rows()
2772 .unwrap()
2773 .iter()
2774 .map(|event| event.kind.as_str())
2775 .collect::<Vec<_>>(),
2776 vec![
2777 "approval_granted",
2778 "approval_granted",
2779 "approval_granted",
2780 "approval_revoked",
2781 "approval_revoked",
2782 "approval_revoked"
2783 ]
2784 );
2785 }
2786
2787 #[test]
2790 fn update_status_rejects_queued_to_succeeded() {
2791 let dir = tempdir().unwrap();
2792 let store = test_store(dir.path().join("tx.db"));
2793 let tx = store.record(queued_transaction()).unwrap();
2794
2795 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
2796 assert!(
2797 matches!(
2798 result,
2799 Err(TransactionStoreError::InvalidTransition {
2800 from: JobState::Queued,
2801 to: JobState::Succeeded,
2802 })
2803 ),
2804 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
2805 );
2806 }
2807
2808 #[test]
2809 fn update_status_rejects_succeeded_to_running() {
2810 let dir = tempdir().unwrap();
2811 let store = test_store(dir.path().join("tx.db"));
2812 let tx = store.record(queued_transaction()).unwrap();
2813
2814 store
2815 .update_status(&tx.transaction_id, JobState::Running)
2816 .unwrap();
2817 store
2818 .update_status(&tx.transaction_id, JobState::Succeeded)
2819 .unwrap();
2820
2821 let result = store.update_status(&tx.transaction_id, JobState::Running);
2822 assert!(
2823 matches!(
2824 result,
2825 Err(TransactionStoreError::InvalidTransition {
2826 from: JobState::Succeeded,
2827 to: JobState::Running,
2828 })
2829 ),
2830 "Succeeded -> Running must be rejected (terminal state): {result:?}"
2831 );
2832 }
2833
2834 #[test]
2835 fn update_status_accepts_running_to_failed() {
2836 let dir = tempdir().unwrap();
2837 let store = test_store(dir.path().join("tx.db"));
2838 let tx = store.record(queued_transaction()).unwrap();
2839
2840 store
2841 .update_status(&tx.transaction_id, JobState::Running)
2842 .unwrap();
2843 store
2844 .update_status(&tx.transaction_id, JobState::Failed)
2845 .unwrap();
2846
2847 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2848 assert_eq!(updated.status, JobState::Failed);
2849 }
2850
2851 #[test]
2852 fn update_status_accepts_running_to_rolled_back() {
2853 let dir = tempdir().unwrap();
2854 let store = test_store(dir.path().join("tx.db"));
2855 let tx = store.record(queued_transaction()).unwrap();
2856
2857 store
2858 .update_status(&tx.transaction_id, JobState::Running)
2859 .unwrap();
2860 store
2861 .update_status(&tx.transaction_id, JobState::RolledBack)
2862 .unwrap();
2863
2864 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2865 assert_eq!(updated.status, JobState::RolledBack);
2866 }
2867
2868 #[test]
2871 fn list_transactions_returns_empty_for_fresh_store() {
2872 let dir = tempdir().unwrap();
2873 let store = test_store(dir.path().join("tx.db"));
2874 let results = store.list_transactions(10, None, None, None).unwrap();
2875 assert!(results.is_empty());
2876 }
2877
2878 #[test]
2879 fn list_transactions_returns_all_records_ordered_by_newest_first() {
2880 let dir = tempdir().unwrap();
2881 let store = test_store(dir.path().join("tx.db"));
2882 store.record(queued_transaction()).unwrap();
2883
2884 let mut second = queued_transaction();
2885 second.action_name = "GetDiskUsage".to_string();
2886 second.risk_level = RiskLevel::Low;
2887 store.record(second).unwrap();
2888
2889 let results = store.list_transactions(10, None, None, None).unwrap();
2890 assert_eq!(results.len(), 2);
2891 assert_eq!(results[0].action_name, "GetDiskUsage");
2893 assert_eq!(results[1].action_name, "UpdateSystem");
2894 }
2895
2896 #[test]
2897 fn list_history_populates_created_at_and_risk_level() {
2898 let dir = tempdir().unwrap();
2899 let store = test_store(dir.path().join("tx.db"));
2900 store.record(queued_transaction()).unwrap();
2901
2902 let entries = store.list_history(10, None, None, None).unwrap();
2903 assert_eq!(entries.len(), 1);
2904 let entry = &entries[0];
2905 assert_eq!(entry.action_name, "UpdateSystem");
2906 assert_eq!(entry.risk_level, RiskLevel::High);
2907 assert_eq!(entry.status, JobState::Queued);
2908 assert!(
2909 !entry.created_at.is_empty(),
2910 "created_at must be populated from the stored row, not left blank"
2911 );
2912 }
2913
2914 #[test]
2915 fn list_history_applies_the_same_filters_as_list_transactions() {
2916 let dir = tempdir().unwrap();
2917 let store = test_store(dir.path().join("tx.db"));
2918 store.record(queued_transaction()).unwrap();
2919 let mut low = queued_transaction();
2920 low.action_name = "GetDiskUsage".to_string();
2921 low.risk_level = RiskLevel::Low;
2922 store.record(low).unwrap();
2923
2924 let only = store
2925 .list_history(10, None, Some("GetDiskUsage"), None)
2926 .unwrap();
2927 assert_eq!(only.len(), 1);
2928 assert_eq!(only[0].action_name, "GetDiskUsage");
2929 }
2930
2931 #[test]
2932 fn list_transactions_respects_limit() {
2933 let dir = tempdir().unwrap();
2934 let store = test_store(dir.path().join("tx.db"));
2935 for _ in 0..5 {
2936 store.record(queued_transaction()).unwrap();
2937 }
2938 let results = store.list_transactions(3, None, None, None).unwrap();
2939 assert_eq!(results.len(), 3);
2940 }
2941
2942 #[test]
2943 fn list_transactions_filters_by_status() {
2944 let dir = tempdir().unwrap();
2945 let store = test_store(dir.path().join("tx.db"));
2946 let tx = store.record(queued_transaction()).unwrap();
2947 store
2948 .update_status(&tx.transaction_id, JobState::Running)
2949 .unwrap();
2950 store
2951 .update_status(&tx.transaction_id, JobState::Succeeded)
2952 .unwrap();
2953
2954 store.record(queued_transaction()).unwrap();
2956
2957 let succeeded = store
2958 .list_transactions(10, Some("succeeded"), None, None)
2959 .unwrap();
2960 assert_eq!(succeeded.len(), 1);
2961 assert_eq!(succeeded[0].status, JobState::Succeeded);
2962
2963 let queued = store
2964 .list_transactions(10, Some("queued"), None, None)
2965 .unwrap();
2966 assert_eq!(queued.len(), 1);
2967 assert_eq!(queued[0].status, JobState::Queued);
2968 }
2969
2970 #[test]
2971 fn list_transactions_filters_by_action_name() {
2972 let dir = tempdir().unwrap();
2973 let store = test_store(dir.path().join("tx.db"));
2974 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
2977 disk.action_name = "GetDiskUsage".to_string();
2978 store.record(disk).unwrap();
2979
2980 let results = store
2981 .list_transactions(10, None, Some("GetDiskUsage"), None)
2982 .unwrap();
2983 assert_eq!(results.len(), 1);
2984 assert_eq!(results[0].action_name, "GetDiskUsage");
2985 }
2986
2987 #[test]
2988 fn list_transactions_filters_by_since_hours() {
2989 let dir = tempdir().unwrap();
2990 let store = test_store(dir.path().join("tx.db"));
2991
2992 let old = store.record(queued_transaction()).unwrap();
2994 let conn = store.connection().unwrap();
2995 conn.execute(
2996 "UPDATE transactions \
2997 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
2998 WHERE transaction_id = ?1",
2999 params![old.transaction_id],
3000 )
3001 .unwrap();
3002
3003 store.record(queued_transaction()).unwrap();
3005
3006 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
3008 assert_eq!(results.len(), 1);
3009
3010 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
3012 assert_eq!(results.len(), 2);
3013 }
3014
3015 #[test]
3016 fn list_transactions_rejects_invalid_status_filter() {
3017 let dir = tempdir().unwrap();
3018 let store = test_store(dir.path().join("tx.db"));
3019 store.record(queued_transaction()).unwrap();
3020 let result = store.list_transactions(10, Some("bogus"), None, None);
3021 assert!(result.is_err(), "invalid status filter should return error");
3022 }
3023
3024 #[test]
3032 fn record_emits_one_watermark_per_entry() {
3033 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3034 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3035
3036 let dir = tempdir().unwrap();
3037 let store = test_store(dir.path().join("tx.db"));
3038 store.record(queued_transaction()).unwrap();
3039
3040 let calls = crate::audit_watermark::take_watermarks(&sink);
3041 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
3042 }
3043
3044 #[test]
3046 fn record_previewed_emits_one_watermark() {
3047 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3048 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3049
3050 let dir = tempdir().unwrap();
3051 let store = test_store(dir.path().join("tx.db"));
3052 let preview = PreviewEnvelope {
3053 summary: "Upgrade the system".to_string(),
3054 risk_level: RiskLevel::High,
3055 current_state: serde_json::Value::Null,
3056 proposed_change: serde_json::Value::Null,
3057 expected_side_effects: vec![],
3058 reboot_required: false,
3059 rollback_available: false,
3060 warnings: vec![],
3061 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
3062 };
3063 store
3064 .record_previewed(queued_transaction(), preview)
3065 .unwrap();
3066
3067 let calls = crate::audit_watermark::take_watermarks(&sink);
3068 assert_eq!(
3069 calls.len(),
3070 1,
3071 "expected exactly 1 watermark per record_previewed()"
3072 );
3073 }
3074
3075 #[test]
3077 fn watermark_seq_and_hash_match_chain_row() {
3078 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3079 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3080
3081 let dir = tempdir().unwrap();
3082 let store = test_store(dir.path().join("tx.db"));
3083 store.record(queued_transaction()).unwrap();
3084
3085 let rows = store.fetch_chain_rows().unwrap();
3086 assert_eq!(rows.len(), 1);
3087 let row = &rows[0];
3088
3089 let calls = crate::audit_watermark::take_watermarks(&sink);
3090 assert_eq!(calls.len(), 1);
3091 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
3092 assert_eq!(
3093 calls[0].chain_hash_hex, row.chain_hash,
3094 "watermark chain_hash_hex must match stored chain_hash"
3095 );
3096 }
3097
3098 #[test]
3100 fn multiple_records_produce_one_watermark_each() {
3101 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3102 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3103
3104 let dir = tempdir().unwrap();
3105 let store = test_store(dir.path().join("tx.db"));
3106 for _ in 0..3 {
3107 store.record(queued_transaction()).unwrap();
3108 }
3109
3110 let calls = crate::audit_watermark::take_watermarks(&sink);
3111 assert_eq!(calls.len(), 3, "one watermark per record call");
3112 assert_eq!(calls[0].seq, 1);
3113 assert_eq!(calls[1].seq, 2);
3114 assert_eq!(calls[2].seq, 3);
3115 }
3116
3117 #[test]
3131 fn no_watermark_emitted_before_any_record() {
3132 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3133 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3134
3135 let dir = tempdir().unwrap();
3136 let _store = test_store(dir.path().join("tx.db"));
3137
3138 let calls = crate::audit_watermark::take_watermarks(&sink);
3140 assert!(
3141 calls.is_empty(),
3142 "no watermark must be emitted without a record() call"
3143 );
3144 }
3145}