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 mut conn = conn;
531 let tx = conn.transaction()?;
532 let rows_affected = tx.execute(
533 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
534 params![
535 serialize_field(&new_status)?,
536 transaction_id,
537 current_status
538 ],
539 )?;
540 if rows_affected == 0 {
541 return Err(TransactionStoreError::ConcurrentStatusChange(
542 transaction_id.to_string(),
543 ));
544 }
545 let Some(key) = self.audit_key.as_ref() else {
551 return Err(TransactionStoreError::AuditChainMissing(
552 "update_status has no signing key, so the outcome could not be chained",
553 ));
554 };
555 Self::append_event(
556 &tx,
557 key,
558 AuditEventKind::from(new_status),
559 transaction_id,
560 "",
561 )?;
562 tx.commit()?;
563 Ok(())
564 }
565
566 pub fn status_matches_chain(
577 &self,
578 ) -> Result<audit_chain::StatusOutcome, TransactionStoreError> {
579 let conn = self.connection()?;
580 let mut stmt = conn.prepare(
581 "SELECT t.transaction_id, t.status, ( \
582 SELECT e.kind FROM audit_events e \
583 WHERE e.transaction_id = t.transaction_id AND e.kind LIKE 'status_%' \
584 ORDER BY e.seq DESC LIMIT 1 \
585 ) FROM transactions t",
586 )?;
587 let mut disagreements = Vec::new();
588 let mut checked: u64 = 0;
589 let rows = stmt.query_map([], |row| {
590 Ok((
591 row.get::<_, String>(0)?,
592 row.get::<_, String>(1)?,
593 row.get::<_, Option<String>>(2)?,
594 ))
595 })?;
596 for row in rows {
597 let (id, status_json, newest) = row?;
598 checked += 1;
599 let state: JobState = deserialize_field(&status_json)?;
600 let Some(newest) = newest else {
603 if state != JobState::Queued {
604 disagreements.push(format!(
605 "{id}: status is {state:?} and no status event was ever chained"
606 ));
607 }
608 continue;
609 };
610 let expected = AuditEventKind::from(state).as_str();
611 if newest != expected {
612 disagreements.push(format!(
613 "{id}: status column says {state:?} ({expected}) and the newest \
614 chained status event is {newest}"
615 ));
616 }
617 }
618 Ok(if disagreements.is_empty() {
619 audit_chain::StatusOutcome::Agrees {
620 rows_checked: checked,
621 }
622 } else {
623 audit_chain::StatusOutcome::Disagrees {
624 detail: disagreements.join("; "),
625 }
626 })
627 }
628
629 pub fn approve_transaction(
631 &self,
632 transaction_id: &str,
633 ) -> Result<Option<String>, TransactionStoreError> {
634 let key = self
635 .audit_key
636 .as_ref()
637 .ok_or(TransactionStoreError::AuditChainMissing(
638 "this TransactionStore was opened read-only; cannot approve",
639 ))?;
640 let Some(record) = self.get(transaction_id)? else {
641 return Ok(None);
642 };
643 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
644 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
645 let Some(committed_digest) = record.approval_id.as_deref() else {
646 return Err(TransactionStoreError::DatabaseInvariant(format!(
647 "transaction {transaction_id} has no signed approval commitment"
648 )));
649 };
650 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
651 return Err(TransactionStoreError::DatabaseInvariant(format!(
652 "transaction {transaction_id} approval commitment does not match its signed preview"
653 )));
654 }
655
656 let mut conn = self.connection()?;
659 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
660 let queued_json = serialize_field(&JobState::Queued)?;
661 let rows_affected = tx.execute(
662 &format!(
663 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
664 SELECT transaction_id, ?1 FROM transactions \
665 WHERE transaction_id = ?2 \
666 AND status = ?3 \
667 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
668 AND NOT EXISTS ( \
669 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
670 )"
671 ),
672 params![receipt_digest, transaction_id, queued_json],
673 )?;
674 if rows_affected > 0 {
675 Self::append_event(
676 &tx,
677 key,
678 AuditEventKind::ApprovalGranted,
679 transaction_id,
680 &receipt_digest,
681 )?;
682 }
683 tx.commit()?;
684 Ok((rows_affected > 0).then_some(receipt))
685 }
686
687 pub fn revoke_unconsumed_approval(
690 &self,
691 transaction_id: &str,
692 ) -> Result<bool, TransactionStoreError> {
693 let key = self
694 .audit_key
695 .as_ref()
696 .ok_or(TransactionStoreError::AuditChainMissing(
697 "this TransactionStore was opened read-only; cannot revoke",
698 ))?;
699 let mut conn = self.connection()?;
700 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
701 let revoked = Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?;
702 tx.commit()?;
703 Ok(revoked)
704 }
705
706 fn revoke_unconsumed_approval_in_tx(
707 conn: &Connection,
708 key: &AuditKey,
709 transaction_id: &str,
710 ) -> Result<bool, TransactionStoreError> {
711 let digest: Option<String> = query_optional(
714 conn,
715 "SELECT receipt_digest FROM transaction_approvals \
716 WHERE transaction_id = ?1 AND consumed_at IS NULL",
717 params![transaction_id],
718 |row| row.get(0),
719 )?;
720 let rows_affected = conn.execute(
721 "DELETE FROM transaction_approvals \
722 WHERE transaction_id = ?1 AND consumed_at IS NULL",
723 params![transaction_id],
724 )?;
725 if rows_affected > 0 {
726 let digest = digest.ok_or_else(|| {
727 TransactionStoreError::DatabaseInvariant(format!(
728 "revoked an approval for {transaction_id} that had no receipt digest"
729 ))
730 })?;
731 Self::append_event(
732 conn,
733 key,
734 AuditEventKind::ApprovalRevoked,
735 transaction_id,
736 &digest,
737 )?;
738 }
739 Ok(rows_affected > 0)
740 }
741
742 pub fn claim_approved_for_execution(
744 &self,
745 transaction_id: &str,
746 receipt_digest: &str,
747 ) -> Result<bool, TransactionStoreError> {
748 let key = self
749 .audit_key
750 .as_ref()
751 .ok_or(TransactionStoreError::AuditChainMissing(
752 "this TransactionStore was opened read-only; cannot claim",
753 ))?;
754 let mut conn = self.connection()?;
755 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
756 let queued_json = serialize_field(&JobState::Queued)?;
757 let running_json = serialize_field(&JobState::Running)?;
758 let rows_affected = tx.execute(
759 &format!(
760 "UPDATE transactions SET status = ?1 \
761 WHERE transaction_id = ?2 \
762 AND status = ?3 \
763 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
764 AND EXISTS ( \
765 SELECT 1 FROM transaction_approvals \
766 WHERE transaction_id = ?2 \
767 AND receipt_digest = ?4 \
768 AND consumed_at IS NULL \
769 )"
770 ),
771 params![running_json, transaction_id, queued_json, receipt_digest],
772 )?;
773 if rows_affected > 0 {
774 tx.execute(
775 "UPDATE transaction_approvals \
776 SET consumed_at = datetime('now') \
777 WHERE transaction_id = ?1 AND consumed_at IS NULL",
778 params![transaction_id],
779 )?;
780 Self::append_event(
781 &tx,
782 key,
783 AuditEventKind::ApprovalConsumed,
784 transaction_id,
785 receipt_digest,
786 )?;
787 }
788 tx.commit()?;
789 Ok(rows_affected > 0)
790 }
791
792 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
807 let key = self
808 .audit_key
809 .as_ref()
810 .ok_or(TransactionStoreError::AuditChainMissing(
811 "this TransactionStore was opened read-only; cannot clean up",
812 ))?;
813 let mut conn = self.connection()?;
814 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
815 let canceled_json = serialize_field(&JobState::Canceled)?;
816 let queued_json = serialize_field(&JobState::Queued)?;
817 let stale_ids: Vec<String> = {
818 let mut statement = tx.prepare(&format!(
819 "SELECT transaction_id FROM transactions \
820 WHERE status = ?1 \
821 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
822 ))?;
823 let stale_ids = statement
824 .query_map(params![queued_json], |row| row.get(0))?
825 .collect::<Result<_, _>>()?;
826 stale_ids
827 };
828 let mut canceled = 0;
829 for transaction_id in stale_ids {
830 let rows_affected = tx.execute(
831 "UPDATE transactions SET status = ?1 \
832 WHERE transaction_id = ?2 AND status = ?3",
833 params![canceled_json, transaction_id, queued_json],
834 )?;
835 if rows_affected > 0 {
836 Self::revoke_unconsumed_approval_in_tx(&tx, key, &transaction_id)?;
837 canceled += rows_affected;
838 }
839 }
840 tx.commit()?;
841 Ok(canceled as u64)
842 }
843
844 pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
853 let key = self
854 .audit_key
855 .as_ref()
856 .ok_or(TransactionStoreError::AuditChainMissing(
857 "this TransactionStore was opened read-only; cannot cancel",
858 ))?;
859 let mut conn = self.connection()?;
860 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
861 let canceled_json = serialize_field(&JobState::Canceled)?;
862 let queued_json = serialize_field(&JobState::Queued)?;
863 let rows_affected = tx.execute(
864 "UPDATE transactions SET status = ?1 \
865 WHERE transaction_id = ?2 AND status = ?3",
866 params![canceled_json, transaction_id, queued_json],
867 )?;
868 if rows_affected > 0 {
869 Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?;
870 }
871 tx.commit()?;
872 Ok(rows_affected > 0)
873 }
874
875 pub fn list_transactions(
884 &self,
885 limit: u32,
886 status_filter: Option<&str>,
887 action_filter: Option<&str>,
888 since_hours: Option<u32>,
889 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
890 let conn = self.connection()?;
891 let (filter_sql, param_values) =
892 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
893 let sql = format!(
894 "SELECT transaction_id, request_id, request_hash, action_name, \
895 risk_level, status, approval_id, summary, warnings_json \
896 FROM transactions WHERE 1=1{filter_sql}"
897 );
898 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
899 param_values.iter().map(|b| b.as_ref()).collect();
900
901 let mut stmt = conn.prepare(&sql)?;
902 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
903
904 let mut results = Vec::new();
905 for row in rows {
906 results.push(row??);
907 }
908 Ok(results)
909 }
910
911 pub fn list_history(
916 &self,
917 limit: u32,
918 status_filter: Option<&str>,
919 action_filter: Option<&str>,
920 since_hours: Option<u32>,
921 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
922 let conn = self.connection()?;
923 let (filter_sql, param_values) =
924 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
925 let sql = format!(
926 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
927 FROM transactions WHERE 1=1{filter_sql}"
928 );
929 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
930 param_values.iter().map(|b| b.as_ref()).collect();
931
932 let mut stmt = conn.prepare(&sql)?;
933 let rows = stmt.query_map(params_ref.as_slice(), |row| {
934 Ok((|| {
935 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
936 transaction_id: row.get(0)?,
937 action_name: row.get(1)?,
938 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
939 status: deserialize_field(&row.get::<_, String>(3)?)?,
940 summary: row.get(4)?,
941 created_at: row.get(5)?,
942 })
943 })())
944 })?;
945
946 let mut results = Vec::new();
947 for row in rows {
948 results.push(row??);
949 }
950 Ok(results)
951 }
952
953 fn build_history_filter(
958 limit: u32,
959 status_filter: Option<&str>,
960 action_filter: Option<&str>,
961 since_hours: Option<u32>,
962 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
963 let mut sql = String::new();
964 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
965
966 if let Some(status) = status_filter {
967 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
971 let status_json = serialize_field(&validated)?;
972 sql.push_str(" AND status = ?");
973 param_values.push(Box::new(status_json));
974 }
975
976 if let Some(action) = action_filter {
977 sql.push_str(" AND action_name = ?");
978 param_values.push(Box::new(action.to_string()));
979 }
980
981 if let Some(hours) = since_hours {
982 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
983 param_values.push(Box::new(hours));
984 }
985
986 sql.push_str(" ORDER BY seq DESC LIMIT ?");
987 param_values.push(Box::new(limit.min(100)));
988
989 Ok((sql, param_values))
990 }
991
992 fn connection(&self) -> Result<Connection, TransactionStoreError> {
993 let conn = Connection::open(&self.path)?;
994 conn.pragma_update(None, "journal_mode", "WAL")?;
1005 conn.pragma_update(None, "synchronous", "NORMAL")?;
1006 conn.pragma_update(None, "foreign_keys", "ON")?;
1007 conn.busy_timeout(std::time::Duration::from_secs(5))?;
1008 Ok(conn)
1009 }
1010
1011 fn initialize(&self) -> Result<(), TransactionStoreError> {
1012 let mut conn = self.connection()?;
1013 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
1014 tx.execute_batch(
1015 "CREATE TABLE IF NOT EXISTS schema_migrations (\
1016 version INTEGER PRIMARY KEY,\
1017 name TEXT NOT NULL,\
1018 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
1019 );",
1020 )?;
1021 let current: i64 = tx.query_row(
1022 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
1023 [],
1024 |row| row.get(0),
1025 )?;
1026 let latest = SQLITE_MIGRATIONS.last().map_or(0, |m| m.version);
1027 if current > latest {
1028 return Err(TransactionStoreError::DatabaseInvariant(format!(
1029 "sqlite schema version {current} is newer than this binary supports ({latest})"
1030 )));
1031 }
1032 for migration in SQLITE_MIGRATIONS.iter().filter(|m| m.version > current) {
1033 tx.execute_batch(migration.sql)?;
1034 tx.execute(
1035 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1036 params![migration.version, migration.name],
1037 )?;
1038 }
1039 tx.commit()?;
1040 Ok(())
1041 }
1042
1043 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
1045 let conn = self.connection()?;
1046 let mut stmt = conn.prepare(&format!(
1047 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions ORDER BY seq ASC"
1048 ))?;
1049 let rows = stmt.query_map([], chain_row_from_sqlite)?;
1050 let mut out = Vec::new();
1051 for row in rows {
1052 out.push(row?);
1053 }
1054 Ok(out)
1055 }
1056
1057 pub fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
1059 let conn = self.connection()?;
1060 let mut stmt = conn.prepare(
1061 "SELECT seq, key_id, kind, transaction_id, receipt_digest, \
1062 created_at, prev_chain_hash, chain_hash \
1063 FROM audit_events ORDER BY seq ASC",
1064 )?;
1065 let rows = stmt.query_map([], event_row_from_sqlite)?;
1066 let mut out = Vec::new();
1067 for row in rows {
1068 out.push(row?);
1069 }
1070 Ok(out)
1071 }
1072
1073 pub fn verify_audit_chain(
1075 &self,
1076 key: &AuditKey,
1077 ) -> Result<VerifyOutcome, TransactionStoreError> {
1078 let rows = self.fetch_chain_rows()?;
1079 Ok(audit_chain::verify_chain(key, &rows))
1080 }
1081
1082 pub fn verify_event_chain(
1084 &self,
1085 key: &AuditKey,
1086 ) -> Result<VerifyOutcome, TransactionStoreError> {
1087 let rows = self.fetch_event_rows()?;
1088 Ok(audit_chain::verify_event_chain(key, &rows))
1089 }
1090
1091 pub fn verify_event_binding(
1094 &self,
1095 ) -> Result<audit_chain::BindingOutcome, TransactionStoreError> {
1096 let tx_rows = self.fetch_chain_rows()?;
1097 let event_rows = self.fetch_event_rows()?;
1098 Ok(audit_chain::verify_event_binding(&tx_rows, &event_rows))
1099 }
1100
1101 pub fn verify_audit_chain_with_pubkey(
1104 &self,
1105 verifying_key_hex: &str,
1106 ) -> Result<VerifyOutcome, TransactionStoreError> {
1107 let rows = self.fetch_chain_rows()?;
1108 Ok(audit_chain::verify_chain_with_pubkey(
1109 verifying_key_hex,
1110 &rows,
1111 ))
1112 }
1113
1114 pub fn fetch_chain_row(
1117 &self,
1118 transaction_id: &str,
1119 ) -> Result<Option<ChainRow>, TransactionStoreError> {
1120 let conn = self.connection()?;
1121 let mut stmt = conn.prepare(&format!(
1122 "SELECT {CHAIN_ROW_COLUMNS} FROM transactions WHERE transaction_id = ?1"
1123 ))?;
1124 let mut rows = stmt.query(params![transaction_id])?;
1125 if let Some(row) = rows.next()? {
1126 Ok(Some(chain_row_from_sqlite(row)?))
1127 } else {
1128 Ok(None)
1129 }
1130 }
1131
1132 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
1136 let prev: Option<(i64, String)> = query_optional(
1137 conn,
1138 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
1139 [],
1140 |row| Ok((row.get(0)?, row.get(1)?)),
1141 )?;
1142 Ok(match prev {
1143 Some((seq, hash)) => ((seq as u64) + 1, hash),
1144 None => (1, String::new()),
1145 })
1146 }
1147
1148 fn event_chain_tip(conn: &Connection) -> Result<Option<String>, TransactionStoreError> {
1151 Ok(query_optional(
1152 conn,
1153 "SELECT chain_hash FROM audit_events ORDER BY seq DESC LIMIT 1",
1154 [],
1155 |row| row.get(0),
1156 )?)
1157 }
1158
1159 fn append_event(
1164 conn: &Connection,
1165 key: &AuditKey,
1166 kind: AuditEventKind,
1167 transaction_id: &str,
1168 receipt_digest: &str,
1169 ) -> Result<(), TransactionStoreError> {
1170 let prev_chain_hash = Self::event_chain_tip(conn)?.unwrap_or_default();
1171 let seq: i64 = conn.query_row(
1172 "SELECT COALESCE(MAX(seq), 0) + 1 FROM audit_events",
1173 [],
1174 |row| row.get(0),
1175 )?;
1176 let created_at: String =
1177 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1178 row.get(0)
1179 })?;
1180 let key_id = CURRENT_KEY_ID.to_string();
1181 let chain_hash = key.event_hash(
1182 &EventContent {
1183 seq: seq as u64,
1184 key_id: &key_id,
1185 kind,
1186 transaction_id,
1187 receipt_digest,
1188 created_at: &created_at,
1189 },
1190 &prev_chain_hash,
1191 );
1192 conn.execute(
1193 "INSERT INTO audit_events (
1194 seq, key_id, kind, transaction_id, receipt_digest,
1195 created_at, chain_hash, prev_chain_hash
1196 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1197 params![
1198 seq,
1199 key_id,
1200 kind.as_str(),
1201 transaction_id,
1202 receipt_digest,
1203 created_at,
1204 chain_hash,
1205 prev_chain_hash,
1206 ],
1207 )?;
1208 Ok(())
1209 }
1210
1211 fn insert_transaction(
1212 conn: &Connection,
1213 key: &AuditKey,
1214 transaction_id: &str,
1215 transaction: NewTransaction,
1216 ) -> Result<InsertedTransaction, TransactionStoreError> {
1217 let request_id = transaction.request_id;
1218 let request_hash = transaction.request_hash;
1219 let action_name = transaction.action_name;
1220 let risk_level = transaction.risk_level;
1221 let status = JobState::Queued;
1223 let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
1230 let summary = transaction.summary;
1231 let warnings = transaction.warnings;
1232 let warnings_json = serde_json::to_string(&warnings)?;
1233
1234 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
1237
1238 let created_at: String =
1242 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
1243 row.get(0)
1244 })?;
1245
1246 let key_id = CURRENT_KEY_ID.to_string();
1247 let event_tip = Self::event_chain_tip(conn)?.unwrap_or_default();
1251 let caller_role = transaction.caller_role.as_str();
1252 let caller_principal = transaction.caller_principal.as_signed_str();
1253 let identity = ChainIdentity::V3 {
1257 caller_role,
1258 event_tip: &event_tip,
1259 caller_principal: &caller_principal,
1260 };
1261 let chain_hash = key.chain_hash(
1262 &ChainContent {
1263 seq,
1264 key_id: &key_id,
1265 transaction_id,
1266 request_id: &request_id,
1267 request_hash: &request_hash,
1268 action_name: &action_name,
1269 risk_level,
1270 summary: &summary,
1271 approval_id: approval_id.as_deref(),
1272 warnings_json: &warnings_json,
1273 created_at: &created_at,
1274 identity,
1275 },
1276 &prev_chain_hash,
1277 );
1278
1279 let record = TransactionRecord {
1280 transaction_id: transaction_id.to_string(),
1281 request_id: request_id.clone(),
1282 request_hash: request_hash.clone(),
1283 action_name: action_name.clone(),
1284 risk_level,
1285 status,
1286 approval_id: approval_id.clone(),
1287 summary: summary.clone(),
1288 warnings: warnings.clone(),
1289 };
1290
1291 conn.execute(
1292 "INSERT INTO transactions (
1293 transaction_id,
1294 request_id,
1295 request_hash,
1296 action_name,
1297 risk_level,
1298 status,
1299 approval_id,
1300 summary,
1301 warnings_json,
1302 created_at,
1303 seq,
1304 key_id,
1305 chain_hash,
1306 prev_chain_hash,
1307 chain_version,
1308 caller_role,
1309 event_tip,
1310 caller_principal
1311 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1312 params![
1313 transaction_id,
1314 request_id,
1315 request_hash,
1316 action_name,
1317 serialize_field(&risk_level)?,
1318 serialize_field(&status)?,
1319 approval_id,
1320 summary,
1321 warnings_json,
1322 created_at,
1323 seq as i64,
1324 key_id,
1325 chain_hash,
1326 prev_chain_hash,
1327 identity.version() as i64,
1328 caller_role,
1329 event_tip,
1330 caller_principal,
1331 ],
1332 )?;
1333
1334 Ok(InsertedTransaction {
1335 record,
1336 seq,
1337 chain_hash,
1338 })
1339 }
1340
1341 fn insert_preview(
1342 conn: &Connection,
1343 transaction_id: &str,
1344 preview: &PreviewEnvelope,
1345 ) -> Result<(), TransactionStoreError> {
1346 conn.execute(
1347 "INSERT INTO transaction_previews (transaction_id, preview_json)
1348 VALUES (?1, ?2)",
1349 params![transaction_id, serde_json::to_string(preview)?],
1350 )?;
1351 Ok(())
1352 }
1353}
1354
1355fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
1362 use std::os::unix::fs::DirBuilderExt;
1363 if dir.exists() {
1364 return Ok(());
1365 }
1366 std::fs::DirBuilder::new()
1367 .recursive(true)
1368 .mode(0o700)
1369 .create(dir)
1370}
1371
1372fn restrict_db_file_mode(db_path: &Path) {
1386 use std::os::unix::fs::PermissionsExt;
1387
1388 let sidecars = [
1392 db_path.to_path_buf(),
1393 with_suffix(db_path, "-wal"),
1394 with_suffix(db_path, "-shm"),
1395 ];
1396 for path in sidecars {
1397 let Ok(metadata) = std::fs::metadata(&path) else {
1398 continue; };
1400 let mode = metadata.permissions().mode() & 0o777;
1401 if mode & 0o077 == 0 {
1402 continue; }
1404 if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
1405 eprintln!(
1406 "[sysknife-daemon] WARNING: {} is mode {mode:o} and could not be \
1407 narrowed to 0600 ({err}); it is readable by anyone who can reach \
1408 it. The parent directory should be 0700 — check it.",
1409 path.display()
1410 );
1411 }
1412 }
1413}
1414
1415fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
1418 let mut name = path.as_os_str().to_os_string();
1419 name.push(suffix);
1420 PathBuf::from(name)
1421}
1422
1423fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
1424 Ok(TransactionRecord {
1425 transaction_id: row.get(0)?,
1426 request_id: row.get(1)?,
1427 request_hash: row.get(2)?,
1428 action_name: row.get(3)?,
1429 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
1430 status: deserialize_field(&row.get::<_, String>(5)?)?,
1431 approval_id: row.get(6)?,
1432 summary: row.get(7)?,
1433 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
1434 })
1435}
1436
1437fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
1438 serde_json::to_string(value)
1439}
1440
1441fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
1442 serde_json::from_str(value)
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447 use super::*;
1448 use crate::audit_chain::CHAIN_VERSION_CURRENT;
1449 use crate::audit_chain::CHAIN_VERSION_LEGACY;
1450 use std::os::unix::fs::PermissionsExt;
1451 use tempfile::tempdir;
1452
1453 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
1457 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
1458 TransactionStore::open_with_key(path, key).unwrap()
1459 }
1460
1461 fn legacy_v1_database(path: &Path, key: &AuditKey) -> String {
1468 let conn = Connection::open(path).unwrap();
1469 conn.execute_batch(
1470 "CREATE TABLE schema_migrations (
1471 version INTEGER PRIMARY KEY,
1472 name TEXT NOT NULL,
1473 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1474 );",
1475 )
1476 .unwrap();
1477 conn.execute_batch(SQLITE_MIGRATIONS[0].sql).unwrap();
1478 conn.execute(
1479 "INSERT INTO schema_migrations (version, name) VALUES (1, ?1)",
1480 params![SQLITE_MIGRATIONS[0].name],
1481 )
1482 .unwrap();
1483
1484 let transaction_id = "tx-legacy";
1485 let created_at = "2026-04-24T12:00:00.000Z";
1486 let chain_hash = key.chain_hash(
1487 &ChainContent {
1488 seq: 1,
1489 key_id: CURRENT_KEY_ID,
1490 transaction_id,
1491 request_id: "req-legacy",
1492 request_hash: "hash-legacy",
1493 action_name: "UpdateSystem",
1494 risk_level: RiskLevel::High,
1495 summary: "Upgrade the system",
1496 approval_id: None,
1497 warnings_json: "[]",
1498 created_at,
1499 identity: ChainIdentity::LegacyV1,
1500 },
1501 "",
1502 );
1503 conn.execute(
1504 "INSERT INTO transactions (
1505 transaction_id, request_id, request_hash, action_name, risk_level,
1506 status, approval_id, summary, warnings_json, created_at,
1507 seq, key_id, chain_hash, prev_chain_hash
1508 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '')",
1509 params![
1510 transaction_id,
1511 "req-legacy",
1512 "hash-legacy",
1513 "UpdateSystem",
1514 serialize_field(&RiskLevel::High).unwrap(),
1515 serialize_field(&JobState::Queued).unwrap(),
1516 "Upgrade the system",
1517 "[]",
1518 created_at,
1519 CURRENT_KEY_ID,
1520 chain_hash,
1521 ],
1522 )
1523 .unwrap();
1524 transaction_id.to_string()
1525 }
1526
1527 fn v2_database(path: &Path, key: &AuditKey) -> String {
1534 let conn = Connection::open(path).unwrap();
1535 conn.execute_batch(
1536 "CREATE TABLE schema_migrations (
1537 version INTEGER PRIMARY KEY,
1538 name TEXT NOT NULL,
1539 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1540 );",
1541 )
1542 .unwrap();
1543 for migration in &SQLITE_MIGRATIONS[0..=1] {
1544 conn.execute_batch(migration.sql).unwrap();
1545 conn.execute(
1546 "INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
1547 params![migration.version, migration.name],
1548 )
1549 .unwrap();
1550 }
1551
1552 let transaction_id = "tx-v2";
1553 let created_at = "2026-07-01T12:00:00.000Z";
1554 let chain_hash = key.chain_hash(
1555 &ChainContent {
1556 seq: 1,
1557 key_id: CURRENT_KEY_ID,
1558 transaction_id,
1559 request_id: "req-v2",
1560 request_hash: "hash-v2",
1561 action_name: "UpdateSystem",
1562 risk_level: RiskLevel::High,
1563 summary: "Upgrade the system",
1564 approval_id: None,
1565 warnings_json: "[]",
1566 created_at,
1567 identity: ChainIdentity::V2 {
1568 caller_role: "admin",
1569 event_tip: "",
1570 },
1571 },
1572 "",
1573 );
1574 conn.execute(
1575 "INSERT INTO transactions (
1576 transaction_id, request_id, request_hash, action_name, risk_level,
1577 status, approval_id, summary, warnings_json, created_at,
1578 seq, key_id, chain_hash, prev_chain_hash,
1579 chain_version, caller_role, event_tip
1580 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, 1, ?10, ?11, '', 2, 'admin', '')",
1581 params![
1582 transaction_id,
1583 "req-v2",
1584 "hash-v2",
1585 "UpdateSystem",
1586 serialize_field(&RiskLevel::High).unwrap(),
1587 serialize_field(&JobState::Queued).unwrap(),
1588 "Upgrade the system",
1589 "[]",
1590 created_at,
1591 CURRENT_KEY_ID,
1592 chain_hash,
1593 ],
1594 )
1595 .unwrap();
1596 transaction_id.to_string()
1597 }
1598
1599 #[test]
1607 fn a_v2_database_keeps_verifying_and_accepts_v3_rows() {
1608 let dir = tempdir().unwrap();
1609 let db_path = dir.path().join("tx.db");
1610 let key = AuditKey::from_bytes(vec![0x42; 32]);
1611 let old_transaction = v2_database(&db_path, &key);
1612
1613 let store = test_store(&db_path);
1614
1615 let version: i64 = store
1616 .connection()
1617 .unwrap()
1618 .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
1619 row.get(0)
1620 })
1621 .unwrap();
1622 assert_eq!(version, 3, "opening a v2 database must apply migration 3");
1623
1624 let rows = store.fetch_chain_rows().unwrap();
1625 assert_eq!(rows.len(), 1);
1626 assert_eq!(rows[0].transaction_id, old_transaction);
1627 assert_eq!(rows[0].chain_version, audit_chain::CHAIN_VERSION_V2);
1628 assert_eq!(
1629 rows[0].caller_principal, None,
1630 "migration 3 must not invent a principal for a row signed without one"
1631 );
1632 assert!(matches!(
1633 store.verify_audit_chain(&key).unwrap(),
1634 audit_chain::VerifyOutcome::Intact { rows_checked: 1 }
1635 ));
1636
1637 let mut fresh = queued_transaction();
1639 fresh.caller_principal = CallerPrincipal::Uid(4242);
1640 store.record(fresh).unwrap();
1641
1642 let rows = store.fetch_chain_rows().unwrap();
1643 assert_eq!(rows.len(), 2);
1644 assert_eq!(rows[1].chain_version, audit_chain::CHAIN_VERSION_CURRENT);
1645 assert_eq!(rows[1].caller_principal.as_deref(), Some("uid:4242"));
1646 assert_eq!(
1647 rows[1].prev_chain_hash, rows[0].chain_hash,
1648 "the v3 row must chain onto the v2 row, not start a new chain"
1649 );
1650 assert!(matches!(
1651 store.verify_audit_chain(&key).unwrap(),
1652 audit_chain::VerifyOutcome::Intact { rows_checked: 2 }
1653 ));
1654
1655 let census = audit_chain::AttributionCensus::of(&rows);
1663 assert_eq!(census.rows(), 2);
1664 assert_eq!(census.named(), 1, "only the v3 row names uid:4242");
1665 assert_eq!(
1666 census.not_recorded(),
1667 1,
1668 "the v2 row's encoding never signed a principal"
1669 );
1670 assert_eq!(census.attribution_failed(), 0);
1671 assert_eq!(census.unattested(), 0);
1672 assert_eq!(census.unnamed(), 1);
1673 }
1674
1675 #[test]
1676 fn a_database_written_before_the_migration_still_verifies_after_upgrading() {
1677 let dir = tempdir().unwrap();
1682 let db_path = dir.path().join("tx.db");
1683 let key = AuditKey::from_bytes(vec![0x42; 32]);
1684 legacy_v1_database(&db_path, &key);
1685
1686 let store = test_store(&db_path);
1687 assert_eq!(
1688 store.verify_audit_chain(&key).unwrap(),
1689 VerifyOutcome::Intact { rows_checked: 1 }
1690 );
1691 let rows = store.fetch_chain_rows().unwrap();
1692 assert_eq!(rows[0].chain_version, CHAIN_VERSION_LEGACY);
1693 assert_eq!(rows[0].caller_role, None);
1694 }
1695
1696 #[test]
1697 fn rows_appended_after_the_migration_chain_onto_legacy_rows() {
1698 let dir = tempdir().unwrap();
1702 let db_path = dir.path().join("tx.db");
1703 let key = AuditKey::from_bytes(vec![0x42; 32]);
1704 legacy_v1_database(&db_path, &key);
1705
1706 let store = test_store(&db_path);
1707 store.record(queued_transaction()).unwrap();
1708
1709 let rows = store.fetch_chain_rows().unwrap();
1710 assert_eq!(rows.len(), 2);
1711 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1712 assert_eq!(rows[1].chain_version, CHAIN_VERSION_CURRENT);
1713 assert_eq!(
1714 store.verify_audit_chain(&key).unwrap(),
1715 VerifyOutcome::Intact { rows_checked: 2 }
1716 );
1717 }
1718
1719 #[test]
1720 fn a_schema_newer_than_this_binary_is_refused() {
1721 let dir = tempdir().unwrap();
1722 let db_path = dir.path().join("tx.db");
1723 let key = AuditKey::from_bytes(vec![0x42; 32]);
1724 legacy_v1_database(&db_path, &key);
1725 let conn = Connection::open(&db_path).unwrap();
1726 conn.execute(
1727 "INSERT INTO schema_migrations (version, name) VALUES (99, 'from the future')",
1728 [],
1729 )
1730 .unwrap();
1731 drop(conn);
1732
1733 let err = TransactionStore::open_with_key(&db_path, Arc::new(key)).unwrap_err();
1734 assert!(
1735 matches!(err, TransactionStoreError::DatabaseInvariant(ref m) if m.contains("99")),
1736 "expected a refusal naming the unsupported version, got {err:?}"
1737 );
1738 }
1739
1740 #[test]
1743 fn the_signed_row_records_which_role_asked() {
1744 let dir = tempdir().unwrap();
1745 let store = test_store(dir.path().join("tx.db"));
1746 let mut tx = queued_transaction();
1747 tx.caller_role = CallerRole::Admin;
1748 store.record(tx).unwrap();
1749
1750 let rows = store.fetch_chain_rows().unwrap();
1751 assert_eq!(rows[0].caller_role.as_deref(), Some("admin"));
1752 }
1753
1754 #[test]
1755 fn approving_consuming_and_revoking_each_append_a_chained_event() {
1756 let dir = tempdir().unwrap();
1757 let key = AuditKey::from_bytes(vec![0x42; 32]);
1758 let store = test_store(dir.path().join("tx.db"));
1759
1760 let a = store.record(queued_transaction()).unwrap();
1762 let receipt = store
1763 .approve_transaction(&a.transaction_id)
1764 .unwrap()
1765 .expect("queued transaction approves");
1766 let digest = audit_chain::approval_receipt_digest(&receipt);
1767 assert!(store
1768 .claim_approved_for_execution(&a.transaction_id, &digest)
1769 .unwrap());
1770
1771 let b = store.record(queued_transaction()).unwrap();
1773 store.approve_transaction(&b.transaction_id).unwrap();
1774 assert!(store.revoke_unconsumed_approval(&b.transaction_id).unwrap());
1775
1776 let events = store.fetch_event_rows().unwrap();
1777 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1778 assert_eq!(
1779 kinds,
1780 vec![
1781 "approval_granted",
1782 "approval_consumed",
1783 "approval_granted",
1784 "approval_revoked",
1785 ]
1786 );
1787 assert_eq!(
1788 store.verify_event_chain(&key).unwrap(),
1789 VerifyOutcome::Intact { rows_checked: 4 }
1790 );
1791 }
1792
1793 #[test]
1794 fn a_terminal_outcome_is_chained_so_rewriting_status_is_detectable() {
1795 let dir = tempdir().unwrap();
1803 let key = AuditKey::from_bytes(vec![0x42; 32]);
1804 let store = test_store(dir.path().join("tx.db"));
1805
1806 let tx = store.record(queued_transaction()).unwrap();
1807 let receipt = store
1808 .approve_transaction(&tx.transaction_id)
1809 .unwrap()
1810 .expect("queued transaction approves");
1811 let digest = audit_chain::approval_receipt_digest(&receipt);
1812 assert!(store
1813 .claim_approved_for_execution(&tx.transaction_id, &digest)
1814 .unwrap());
1815 store
1816 .update_status(&tx.transaction_id, JobState::Succeeded)
1817 .unwrap();
1818
1819 let kinds: Vec<String> = store
1821 .fetch_event_rows()
1822 .unwrap()
1823 .iter()
1824 .map(|e| e.kind.clone())
1825 .collect();
1826 assert!(
1827 kinds.iter().any(|k| k.contains("succeeded")),
1828 "no event records the terminal outcome; the chain covers only {kinds:?}"
1829 );
1830 assert!(
1831 matches!(
1832 store.status_matches_chain().unwrap(),
1833 audit_chain::StatusOutcome::Agrees { .. }
1834 ),
1835 "an untampered store must report agreement, got {:?}",
1836 store.status_matches_chain().unwrap()
1837 );
1838
1839 {
1842 let conn = rusqlite::Connection::open(dir.path().join("tx.db")).unwrap();
1843 let n = conn
1844 .execute(
1845 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
1846 rusqlite::params!["\"canceled\"", tx.transaction_id],
1847 )
1848 .unwrap();
1849 assert_eq!(n, 1, "the fixture did not rewrite the status column");
1850 }
1851 let audit_chain::StatusOutcome::Disagrees { detail } =
1852 store.status_matches_chain().unwrap()
1853 else {
1854 panic!("a rewritten status must be reported, not passed over");
1855 };
1856 assert!(
1857 detail.contains(&tx.transaction_id),
1858 "the report must name the transaction: {detail}"
1859 );
1860 assert!(
1863 matches!(
1864 store.verify_event_chain(&key).unwrap(),
1865 VerifyOutcome::Intact { .. }
1866 ),
1867 "event chain: {:?}, rows: {:?}",
1868 store.verify_event_chain(&key).unwrap(),
1869 store
1870 .fetch_event_rows()
1871 .unwrap()
1872 .iter()
1873 .map(|e| (e.seq, e.kind.clone()))
1874 .collect::<Vec<_>>()
1875 );
1876 }
1877
1878 #[test]
1879 fn a_failed_approval_appends_no_event() {
1880 let dir = tempdir().unwrap();
1884 let store = test_store(dir.path().join("tx.db"));
1885 let tx = store.record(queued_transaction()).unwrap();
1886 store.approve_transaction(&tx.transaction_id).unwrap();
1887 store.approve_transaction(&tx.transaction_id).unwrap();
1888 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1889
1890 assert!(!store.revoke_unconsumed_approval("no-such-tx").unwrap());
1892 assert_eq!(store.fetch_event_rows().unwrap().len(), 1);
1893 }
1894
1895 #[test]
1896 fn deleting_the_record_that_an_approval_happened_no_longer_goes_unnoticed() {
1897 let dir = tempdir().unwrap();
1903 let db_path = dir.path().join("tx.db");
1904 let key = AuditKey::from_bytes(vec![0x42; 32]);
1905 let store = test_store(&db_path);
1906
1907 let a = store.record(queued_transaction()).unwrap();
1908 store.approve_transaction(&a.transaction_id).unwrap();
1909 store.record(queued_transaction()).unwrap();
1912
1913 assert_eq!(
1914 store.verify_event_chain(&key).unwrap(),
1915 VerifyOutcome::Intact { rows_checked: 1 }
1916 );
1917 assert_eq!(
1918 store.verify_event_binding().unwrap(),
1919 audit_chain::BindingOutcome::Consistent {
1920 bindings_checked: 1
1921 }
1922 );
1923
1924 let conn = Connection::open(&db_path).unwrap();
1926 conn.execute("DELETE FROM transaction_approvals", [])
1927 .unwrap();
1928 conn.execute("DELETE FROM audit_events", []).unwrap();
1929 drop(conn);
1930
1931 assert_eq!(
1935 store.verify_event_chain(&key).unwrap(),
1936 VerifyOutcome::Intact { rows_checked: 0 }
1937 );
1938 match store.verify_event_binding().unwrap() {
1939 audit_chain::BindingOutcome::MissingEvent {
1940 transaction_seq, ..
1941 } => assert_eq!(transaction_seq, 2),
1942 other => panic!("expected MissingEvent, got {other:?}"),
1943 }
1944 }
1945
1946 #[test]
1947 fn a_read_only_store_cannot_revoke_or_claim() {
1948 let dir = tempdir().unwrap();
1951 let db_path = dir.path().join("tx.db");
1952 let store = test_store(&db_path);
1953 store.record(queued_transaction()).unwrap();
1954
1955 let read_only = TransactionStore::open_read_only(&db_path).unwrap();
1956 assert!(matches!(
1957 read_only.revoke_unconsumed_approval("tx"),
1958 Err(TransactionStoreError::AuditChainMissing(_))
1959 ));
1960 assert!(matches!(
1961 read_only.claim_approved_for_execution("tx", "digest"),
1962 Err(TransactionStoreError::AuditChainMissing(_))
1963 ));
1964 }
1965
1966 #[test]
1973 fn record_persists_the_caller_principal_under_the_current_encoding() {
1974 let dir = tempdir().unwrap();
1975 let store = test_store(dir.path().join("tx.db"));
1976 let mut tx = queued_transaction();
1977 tx.caller_principal = CallerPrincipal::Uid(4242);
1978 store.record(tx).unwrap();
1979
1980 let conn = store.connection().unwrap();
1981 let (version, role, principal): (i64, Option<String>, Option<String>) = conn
1982 .query_row(
1983 "SELECT chain_version, caller_role, caller_principal FROM transactions",
1984 [],
1985 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1986 )
1987 .unwrap();
1988
1989 assert_eq!(version, i64::from(audit_chain::CHAIN_VERSION_CURRENT));
1990 assert_eq!(role.as_deref(), Some("dev"));
1991 assert_eq!(
1992 principal.as_deref(),
1993 Some("uid:4242"),
1994 "the row must name the account the dispatcher resolved"
1995 );
1996
1997 let key = AuditKey::from_bytes(vec![0x42; 32]);
1999 assert!(matches!(
2000 store.verify_audit_chain(&key).unwrap(),
2001 audit_chain::VerifyOutcome::Intact { .. }
2002 ));
2003 }
2004
2005 #[test]
2006 fn record_writes_audit_chain_columns() {
2007 let dir = tempdir().unwrap();
2008 let store = test_store(dir.path().join("tx.db"));
2009 let _record = store.record(queued_transaction()).unwrap();
2010
2011 let conn = store.connection().unwrap();
2012 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
2013 .query_row(
2014 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
2015 [],
2016 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
2017 )
2018 .unwrap();
2019 assert_eq!(seq, 1, "first row gets seq=1");
2020 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
2021 assert_eq!(prev, "", "first row has empty prev_chain_hash");
2022 assert_eq!(
2023 chain_hash.len(),
2024 audit_chain::HASH_HEX_LEN,
2025 "chain_hash is a hex-encoded Ed25519 signature"
2026 );
2027 }
2028
2029 #[test]
2030 fn sequential_records_produce_chained_hashes() {
2031 let dir = tempdir().unwrap();
2032 let store = test_store(dir.path().join("tx.db"));
2033 store.record(queued_transaction()).unwrap();
2034 store.record(queued_transaction()).unwrap();
2035 store.record(queued_transaction()).unwrap();
2036
2037 let rows = store.fetch_chain_rows().unwrap();
2038 assert_eq!(rows.len(), 3);
2039 assert_eq!(rows[0].seq, 1);
2040 assert_eq!(rows[1].seq, 2);
2041 assert_eq!(rows[2].seq, 3);
2042 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
2044 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
2045 }
2046
2047 #[test]
2059 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
2060 const WORKERS: usize = 8;
2061 const RECORDS_PER_WORKER: usize = 10;
2062 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
2063
2064 let dir = tempdir().unwrap();
2065 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2066
2067 let mut handles = Vec::with_capacity(WORKERS);
2068 for w in 0..WORKERS {
2069 let store = std::sync::Arc::clone(&store);
2070 handles.push(std::thread::spawn(move || {
2071 for r in 0..RECORDS_PER_WORKER {
2072 let tx = NewTransaction {
2073 request_id: format!("worker-{w}-record-{r}"),
2074 request_hash: format!("hash-{w}-{r}"),
2075 action_name: "GetSystemState".to_string(),
2076 risk_level: RiskLevel::Low,
2077 summary: format!("worker {w} record {r}"),
2078 warnings: vec![],
2079 caller_role: CallerRole::Dev,
2080 caller_principal: CallerPrincipal::Uid(3000 + w as u32),
2084 };
2085 store
2086 .record(tx)
2087 .expect("record must succeed under contention");
2088 }
2089 }));
2090 }
2091 for h in handles {
2092 h.join().expect("worker thread did not panic");
2093 }
2094
2095 let key = AuditKey::from_bytes(vec![0x42; 32]);
2097 let outcome = store.verify_audit_chain(&key).unwrap();
2098 match outcome {
2099 VerifyOutcome::Intact { rows_checked } => {
2100 assert_eq!(
2101 rows_checked, TOTAL as u64,
2102 "expected {TOTAL} rows checked, got {rows_checked}"
2103 );
2104 }
2105 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
2106 }
2107
2108 {
2112 let conn = store.connection().unwrap();
2113 let mut stmt = conn
2114 .prepare("SELECT summary, caller_principal FROM transactions")
2115 .unwrap();
2116 let pairs: Vec<(String, Option<String>)> = stmt
2117 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
2118 .unwrap()
2119 .map(|r| r.unwrap())
2120 .collect();
2121 assert_eq!(pairs.len(), TOTAL);
2122 for (summary, principal) in pairs {
2123 let worker: u32 = summary
2124 .split_whitespace()
2125 .nth(1)
2126 .and_then(|w| w.parse().ok())
2127 .unwrap_or_else(|| panic!("unexpected summary {summary:?}"));
2128 assert_eq!(
2129 principal.as_deref(),
2130 Some(format!("uid:{}", 3000 + worker).as_str()),
2131 "row {summary:?} was attributed to the wrong account"
2132 );
2133 }
2134 }
2135
2136 let conn = store.connection().unwrap();
2138 let mut stmt = conn
2139 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
2140 .unwrap();
2141 let seqs: Vec<i64> = stmt
2142 .query_map([], |row| row.get::<_, i64>(0))
2143 .unwrap()
2144 .map(|r| r.unwrap())
2145 .collect();
2146 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
2147 for (i, s) in seqs.iter().enumerate() {
2148 assert_eq!(
2149 *s,
2150 (i as i64) + 1,
2151 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
2152 );
2153 }
2154 }
2155
2156 #[test]
2157 fn verify_audit_chain_intact_after_inserts() {
2158 let dir = tempdir().unwrap();
2159 let store = test_store(dir.path().join("tx.db"));
2160 for _ in 0..3 {
2161 store.record(queued_transaction()).unwrap();
2162 }
2163 let key = AuditKey::from_bytes(vec![0x42; 32]);
2164 let outcome = store.verify_audit_chain(&key).unwrap();
2165 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2166 }
2167
2168 #[test]
2169 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
2170 let dir = tempdir().unwrap();
2171 let store = test_store(dir.path().join("tx.db"));
2172 for _ in 0..3 {
2173 store.record(queued_transaction()).unwrap();
2174 }
2175 let key = AuditKey::from_bytes(vec![0x42; 32]);
2177 let outcome = store
2178 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
2179 .unwrap();
2180 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
2181 }
2182
2183 #[test]
2184 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
2185 let dir = tempdir().unwrap();
2186 let store = test_store(dir.path().join("tx.db"));
2187 store.record(queued_transaction()).unwrap();
2188 let other = AuditKey::from_bytes(vec![0x99; 32]);
2190 let outcome = store
2191 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
2192 .unwrap();
2193 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2194 }
2195
2196 #[test]
2197 fn verify_detects_tampered_summary() {
2198 let dir = tempdir().unwrap();
2199 let store = test_store(dir.path().join("tx.db"));
2200 let tx = store.record(queued_transaction()).unwrap();
2201
2202 let conn = store.connection().unwrap();
2205 conn.execute(
2206 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
2207 params!["EVIL CHANGE", tx.transaction_id],
2208 )
2209 .unwrap();
2210
2211 let key = AuditKey::from_bytes(vec![0x42; 32]);
2212 let outcome = store.verify_audit_chain(&key).unwrap();
2213 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2214 }
2215
2216 #[test]
2217 fn status_update_does_not_break_chain() {
2218 let dir = tempdir().unwrap();
2220 let store = test_store(dir.path().join("tx.db"));
2221 let tx = store.record(queued_transaction()).unwrap();
2222 store
2223 .update_status(&tx.transaction_id, JobState::Running)
2224 .unwrap();
2225 store
2226 .update_status(&tx.transaction_id, JobState::Succeeded)
2227 .unwrap();
2228
2229 let key = AuditKey::from_bytes(vec![0x42; 32]);
2230 let outcome = store.verify_audit_chain(&key).unwrap();
2231 assert!(
2232 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
2233 "status mutation must not break the chain (status not in chain content): {outcome:?}"
2234 );
2235 }
2236
2237 #[test]
2238 fn open_read_only_rejects_record() {
2239 let dir = tempdir().unwrap();
2240 let key_path = dir.path().join("audit-key");
2241 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
2242 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2243
2244 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
2245 let result = store.record(queued_transaction());
2246 assert!(matches!(
2247 result,
2248 Err(TransactionStoreError::AuditChainMissing(_))
2249 ));
2250 }
2251
2252 #[test]
2253 fn ensure_private_dir_creates_with_0700_mode() {
2254 let root = tempdir().unwrap();
2255 let target = root.path().join("a/b/c");
2256 ensure_private_dir(&target).unwrap();
2257 assert!(target.is_dir());
2258 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2259 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
2260 }
2261
2262 #[test]
2270 fn the_audit_database_is_created_owner_only() {
2271 let dir = tempdir().unwrap();
2272 let db = dir.path().join("daemon.sqlite");
2273 let store = TransactionStore::open(&db).unwrap();
2274 store
2276 .record(NewTransaction {
2277 request_id: "mode-probe".to_string(),
2278 request_hash: "hash-mode-probe".to_string(),
2279 action_name: "GetSystemState".to_string(),
2280 risk_level: RiskLevel::Low,
2281 summary: "probe".to_string(),
2282 warnings: vec![],
2283 caller_role: CallerRole::Dev,
2284 caller_principal: CallerPrincipal::Uid(3000),
2285 })
2286 .unwrap();
2287
2288 for path in [db.clone(), db.with_extension("sqlite-wal")] {
2289 if !path.exists() {
2290 continue;
2291 }
2292 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2293 assert_eq!(
2294 mode,
2295 0o600,
2296 "{} must be owner-only, got {mode:o}",
2297 path.display()
2298 );
2299 }
2300 }
2301
2302 #[test]
2303 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
2304 let root = tempdir().unwrap();
2305 let target = root.path().join("preexisting");
2306 std::fs::create_dir(&target).unwrap();
2307 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
2308 ensure_private_dir(&target).unwrap();
2309 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
2311 assert_eq!(mode, 0o755);
2312 }
2313
2314 #[test]
2315 fn open_creates_parent_with_private_mode() {
2316 let root = tempdir().unwrap();
2317 let db_path = root.path().join("nested/dirs/daemon.sqlite");
2318 let _store = test_store(&db_path);
2319 let parent = db_path.parent().unwrap();
2320 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
2321 assert_eq!(mode, 0o700);
2322 }
2323
2324 fn queued_transaction() -> NewTransaction {
2325 NewTransaction {
2326 request_id: "req-1".to_string(),
2327 request_hash: "hash-abc".to_string(),
2328 action_name: "UpdateSystem".to_string(),
2329 risk_level: RiskLevel::High,
2330 summary: "Upgrade the system".to_string(),
2331 warnings: vec![],
2332 caller_role: CallerRole::Dev,
2333 caller_principal: CallerPrincipal::Uid(1000),
2334 }
2335 }
2336
2337 #[test]
2338 fn update_status_transitions_queued_to_running() {
2339 let dir = tempdir().unwrap();
2340 let store = test_store(dir.path().join("tx.db"));
2341 let tx = store.record(queued_transaction()).unwrap();
2342
2343 store
2344 .update_status(&tx.transaction_id, JobState::Running)
2345 .unwrap();
2346
2347 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2348 assert_eq!(updated.status, JobState::Running);
2349 }
2350
2351 #[test]
2352 fn update_status_transitions_running_to_succeeded() {
2353 let dir = tempdir().unwrap();
2354 let store = test_store(dir.path().join("tx.db"));
2355 let tx = store.record(queued_transaction()).unwrap();
2356
2357 store
2358 .update_status(&tx.transaction_id, JobState::Running)
2359 .unwrap();
2360 store
2361 .update_status(&tx.transaction_id, JobState::Succeeded)
2362 .unwrap();
2363
2364 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2365 assert_eq!(updated.status, JobState::Succeeded);
2366 }
2367
2368 #[test]
2369 fn update_status_for_unknown_id_returns_not_found() {
2370 let dir = tempdir().unwrap();
2371 let store = test_store(dir.path().join("tx.db"));
2372
2373 let result = store.update_status("does-not-exist", JobState::Running);
2374 assert!(
2375 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
2376 "expected NotFound, got: {result:?}"
2377 );
2378 }
2379
2380 #[test]
2381 fn update_status_leaves_other_fields_intact() {
2382 let dir = tempdir().unwrap();
2383 let store = test_store(dir.path().join("tx.db"));
2384 let tx = store.record(queued_transaction()).unwrap();
2385
2386 store
2387 .update_status(&tx.transaction_id, JobState::Running)
2388 .unwrap();
2389 store
2390 .update_status(&tx.transaction_id, JobState::Failed)
2391 .unwrap();
2392
2393 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
2394 assert_eq!(updated.action_name, "UpdateSystem");
2395 assert_eq!(updated.risk_level, RiskLevel::High);
2396 assert_eq!(updated.status, JobState::Failed);
2397 }
2398
2399 #[test]
2408 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
2409 let dir = tempdir().unwrap();
2410 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2411 let tx = store.record(queued_transaction()).unwrap();
2412 store
2413 .update_status(&tx.transaction_id, JobState::Running)
2414 .unwrap();
2415
2416 let store_a = std::sync::Arc::clone(&store);
2417 let id_a = tx.transaction_id.clone();
2418 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
2419
2420 let store_b = std::sync::Arc::clone(&store);
2421 let id_b = tx.transaction_id.clone();
2422 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
2423
2424 let succeed_result = succeed.join().unwrap();
2425 let fail_result = fail.join().unwrap();
2426
2427 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
2429 .into_iter()
2430 .filter(|ok| *ok)
2431 .count();
2432 assert_eq!(
2433 oks, 1,
2434 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
2435 fail={fail_result:?}"
2436 );
2437
2438 let loser = if succeed_result.is_ok() {
2454 &fail_result
2455 } else {
2456 &succeed_result
2457 };
2458 match loser {
2459 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
2460 assert_eq!(id, &tx.transaction_id);
2461 }
2462 Err(TransactionStoreError::InvalidTransition { from, to }) => {
2463 let winner = if succeed_result.is_ok() {
2465 JobState::Succeeded
2466 } else {
2467 JobState::Failed
2468 };
2469 assert_eq!(
2470 *from, winner,
2471 "the loser must have observed the winner's committed status"
2472 );
2473 assert_ne!(*to, winner, "the loser must be the other transition");
2474 }
2475 other => panic!(
2476 "the losing transition must be refused with either \
2477 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
2478 ),
2479 }
2480
2481 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
2484 if succeed_result.is_ok() {
2485 assert_eq!(final_status, JobState::Succeeded);
2486 } else {
2487 assert_eq!(final_status, JobState::Failed);
2488 }
2489 }
2490
2491 #[test]
2492 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
2493 let dir = tempdir().unwrap();
2496 let store = test_store(dir.path().join("tx.db"));
2497 let tx = store.record(queued_transaction()).unwrap();
2498 let receipt = store
2499 .approve_transaction(&tx.transaction_id)
2500 .unwrap()
2501 .expect("approved");
2502 let digest = audit_chain::approval_receipt_digest(&receipt);
2503
2504 assert!(
2505 store
2506 .revoke_unconsumed_approval(&tx.transaction_id)
2507 .unwrap(),
2508 "an unconsumed approval must be revocable"
2509 );
2510 assert!(
2511 !store
2512 .claim_approved_for_execution(&tx.transaction_id, &digest)
2513 .unwrap(),
2514 "a revoked receipt must no longer be claimable"
2515 );
2516 }
2517
2518 #[test]
2519 fn revoke_cannot_retract_an_approval_that_was_already_used() {
2520 let dir = tempdir().unwrap();
2524 let store = test_store(dir.path().join("tx.db"));
2525 let tx = store.record(queued_transaction()).unwrap();
2526 let receipt = store
2527 .approve_transaction(&tx.transaction_id)
2528 .unwrap()
2529 .expect("approved");
2530 let digest = audit_chain::approval_receipt_digest(&receipt);
2531 assert!(store
2532 .claim_approved_for_execution(&tx.transaction_id, &digest)
2533 .unwrap());
2534
2535 assert!(
2536 !store
2537 .revoke_unconsumed_approval(&tx.transaction_id)
2538 .unwrap(),
2539 "a consumed approval must not be revocable"
2540 );
2541 assert_eq!(
2542 store.get(&tx.transaction_id).unwrap().unwrap().status,
2543 JobState::Running,
2544 "a failed revocation must not disturb the running transaction"
2545 );
2546 }
2547
2548 #[test]
2549 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
2550 let dir = tempdir().unwrap();
2554 let store = test_store(dir.path().join("tx.db"));
2555 let tx = store.record(queued_transaction()).unwrap();
2556 let receipt = store
2557 .approve_transaction(&tx.transaction_id)
2558 .unwrap()
2559 .expect("approved");
2560 let digest = audit_chain::approval_receipt_digest(&receipt);
2561 assert!(store
2562 .claim_approved_for_execution(&tx.transaction_id, &digest)
2563 .unwrap());
2564
2565 assert!(
2567 store
2568 .approve_transaction(&tx.transaction_id)
2569 .unwrap()
2570 .is_none(),
2571 "a Running transaction must not be approvable"
2572 );
2573
2574 store
2576 .update_status(&tx.transaction_id, JobState::Succeeded)
2577 .unwrap();
2578 assert!(
2579 store
2580 .approve_transaction(&tx.transaction_id)
2581 .unwrap()
2582 .is_none(),
2583 "a completed transaction must not be approvable"
2584 );
2585 }
2586
2587 #[test]
2588 fn only_one_of_two_concurrent_claims_can_execute() {
2589 let dir = tempdir().unwrap();
2594 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
2595 let tx = store.record(queued_transaction()).unwrap();
2596 let receipt = store
2597 .approve_transaction(&tx.transaction_id)
2598 .unwrap()
2599 .expect("approved");
2600 let digest = audit_chain::approval_receipt_digest(&receipt);
2601
2602 let mut handles = Vec::new();
2603 for _ in 0..2 {
2604 let store = std::sync::Arc::clone(&store);
2605 let id = tx.transaction_id.clone();
2606 let digest = digest.clone();
2607 handles.push(std::thread::spawn(move || {
2608 store.claim_approved_for_execution(&id, &digest)
2609 }));
2610 }
2611 let claims: Vec<bool> = handles
2612 .into_iter()
2613 .map(|h| h.join().unwrap().unwrap_or(false))
2614 .collect();
2615
2616 assert_eq!(
2617 claims.iter().filter(|c| **c).count(),
2618 1,
2619 "exactly one claim may win: {claims:?}"
2620 );
2621 assert_eq!(
2622 store.get(&tx.transaction_id).unwrap().unwrap().status,
2623 JobState::Running
2624 );
2625 }
2626
2627 #[test]
2628 fn cancel_queued_cancels_a_queued_transaction_once() {
2629 let dir = tempdir().unwrap();
2630 let store = test_store(dir.path().join("tx.db"));
2631 let tx = store.record(queued_transaction()).unwrap();
2632
2633 assert!(
2634 store.cancel_queued(&tx.transaction_id).unwrap(),
2635 "a queued transaction is cancelable"
2636 );
2637 assert_eq!(
2638 store.get(&tx.transaction_id).unwrap().unwrap().status,
2639 JobState::Canceled
2640 );
2641 assert!(
2642 !store.cancel_queued(&tx.transaction_id).unwrap(),
2643 "an already-canceled transaction is not cancelable again"
2644 );
2645 assert!(
2646 !store.cancel_queued("no-such-transaction").unwrap(),
2647 "a missing transaction is not cancelable"
2648 );
2649 assert!(
2650 store.fetch_event_rows().unwrap().is_empty(),
2651 "canceling an unapproved transaction must not append an approval event"
2652 );
2653 }
2654
2655 #[test]
2656 fn cancel_queued_revokes_an_unconsumed_approval_and_appends_event() {
2657 let dir = tempdir().unwrap();
2658 let store = test_store(dir.path().join("tx.db"));
2659 let tx = store.record(queued_transaction()).unwrap();
2660 let receipt = store
2661 .approve_transaction(&tx.transaction_id)
2662 .unwrap()
2663 .expect("queued transaction is approvable");
2664 let digest = audit_chain::approval_receipt_digest(&receipt);
2665
2666 assert!(store.cancel_queued(&tx.transaction_id).unwrap());
2667 assert_eq!(
2668 store.get(&tx.transaction_id).unwrap().unwrap().status,
2669 JobState::Canceled
2670 );
2671 assert!(
2672 !store
2673 .claim_approved_for_execution(&tx.transaction_id, &digest)
2674 .unwrap(),
2675 "canceling must revoke the unconsumed receipt"
2676 );
2677 assert_eq!(
2678 store
2679 .fetch_event_rows()
2680 .unwrap()
2681 .iter()
2682 .map(|event| event.kind.as_str())
2683 .collect::<Vec<_>>(),
2684 vec!["approval_granted", "approval_revoked"]
2685 );
2686 }
2687
2688 #[test]
2689 fn cancel_queued_refuses_a_running_transaction() {
2690 let dir = tempdir().unwrap();
2693 let store = test_store(dir.path().join("tx.db"));
2694 let tx = store.record(queued_transaction()).unwrap();
2695 let receipt = store
2696 .approve_transaction(&tx.transaction_id)
2697 .unwrap()
2698 .expect("approved");
2699 let digest = audit_chain::approval_receipt_digest(&receipt);
2700 assert!(store
2701 .claim_approved_for_execution(&tx.transaction_id, &digest)
2702 .unwrap());
2703
2704 assert!(
2705 !store.cancel_queued(&tx.transaction_id).unwrap(),
2706 "a running transaction must not be cancelable"
2707 );
2708 assert_eq!(
2709 store.get(&tx.transaction_id).unwrap().unwrap().status,
2710 JobState::Running,
2711 "cancel must not disturb a running transaction"
2712 );
2713 }
2714
2715 #[test]
2716 fn approved_receipt_is_required_and_consumed_once() {
2717 let dir = tempdir().unwrap();
2718 let store = test_store(dir.path().join("tx.db"));
2719 let tx = store.record(queued_transaction()).unwrap();
2720
2721 assert!(
2722 !store
2723 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
2724 .unwrap(),
2725 "an unapproved preview must not execute"
2726 );
2727 let receipt = store
2728 .approve_transaction(&tx.transaction_id)
2729 .unwrap()
2730 .expect("first approval must succeed");
2731 let digest = audit_chain::approval_receipt_digest(&receipt);
2732 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
2733 assert!(
2734 store
2735 .approve_transaction(&tx.transaction_id)
2736 .unwrap()
2737 .is_none(),
2738 "approval is immutable once issued"
2739 );
2740 assert!(
2741 !store
2742 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
2743 .unwrap(),
2744 "a forged receipt must not execute"
2745 );
2746 assert!(
2747 store
2748 .claim_approved_for_execution(&tx.transaction_id, &digest)
2749 .unwrap(),
2750 "the exact approved receipt must execute"
2751 );
2752 assert!(
2753 !store
2754 .claim_approved_for_execution(&tx.transaction_id, &digest)
2755 .unwrap(),
2756 "the receipt must be one-time"
2757 );
2758 }
2759
2760 #[test]
2761 fn approval_commitment_is_covered_by_the_signed_chain() {
2762 let dir = tempdir().unwrap();
2763 let store = test_store(dir.path().join("tx.db"));
2764 let tx = store.record(queued_transaction()).unwrap();
2765 assert!(tx.approval_id.is_some());
2766
2767 store
2768 .connection()
2769 .unwrap()
2770 .execute(
2771 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2772 params![tx.transaction_id],
2773 )
2774 .unwrap();
2775
2776 let key = AuditKey::from_bytes(vec![0x42; 32]);
2777 assert!(matches!(
2778 store.verify_audit_chain(&key).unwrap(),
2779 VerifyOutcome::Broken { .. }
2780 ));
2781 }
2782
2783 #[test]
2784 fn approve_rejects_a_forged_commitment_at_runtime() {
2785 let dir = tempdir().unwrap();
2789 let store = test_store(dir.path().join("tx.db"));
2790 let tx = store.record(queued_transaction()).unwrap();
2791
2792 store
2793 .connection()
2794 .unwrap()
2795 .execute(
2796 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2797 params![tx.transaction_id],
2798 )
2799 .unwrap();
2800
2801 let err = store
2802 .approve_transaction(&tx.transaction_id)
2803 .expect_err("a forged commitment must be rejected, not approved");
2804 assert!(
2805 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
2806 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
2807 );
2808 }
2809
2810 #[test]
2811 fn stale_iso_timestamp_cannot_be_approved() {
2812 let dir = tempdir().unwrap();
2813 let store = test_store(dir.path().join("tx.db"));
2814 let tx = store.record(queued_transaction()).unwrap();
2815 let conn = store.connection().unwrap();
2816 conn.execute(
2817 "UPDATE transactions \
2818 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2819 WHERE transaction_id = ?1",
2820 params![tx.transaction_id],
2821 )
2822 .unwrap();
2823
2824 assert!(
2825 store
2826 .approve_transaction(&tx.transaction_id)
2827 .unwrap()
2828 .is_none(),
2829 "a production-format timestamp outside the TTL must not be approved"
2830 );
2831 }
2832
2833 #[test]
2834 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
2835 let dir = tempdir().unwrap();
2840 let store = test_store(dir.path().join("tx.db"));
2841 let tx = store.record(queued_transaction()).unwrap();
2842 let receipt = store
2843 .approve_transaction(&tx.transaction_id)
2844 .unwrap()
2845 .expect("a fresh approval succeeds");
2846 let digest = audit_chain::approval_receipt_digest(&receipt);
2847
2848 let conn = store.connection().unwrap();
2850 conn.execute(
2851 "UPDATE transactions \
2852 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2853 WHERE transaction_id = ?1",
2854 params![tx.transaction_id],
2855 )
2856 .unwrap();
2857
2858 assert!(
2859 !store
2860 .claim_approved_for_execution(&tx.transaction_id, &digest)
2861 .unwrap(),
2862 "an approval aged past the TTL must not be claimable at execute time"
2863 );
2864 assert_eq!(
2865 store.get(&tx.transaction_id).unwrap().unwrap().status,
2866 JobState::Queued,
2867 "a TTL-expired claim must leave the transaction Queued, never Running"
2868 );
2869 }
2870
2871 #[test]
2872 fn cleanup_stale_queued_cancels_old_records() {
2873 let dir = tempdir().unwrap();
2874 let store = test_store(dir.path().join("tx.db"));
2875
2876 let fresh = store.record(queued_transaction()).unwrap();
2878 let stale = store.record(queued_transaction()).unwrap();
2879
2880 let conn = store.connection().unwrap();
2882 conn.execute(
2883 "UPDATE transactions \
2884 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2885 WHERE transaction_id = ?1",
2886 params![stale.transaction_id],
2887 )
2888 .unwrap();
2889
2890 let canceled = store.cleanup_stale_queued().unwrap();
2891 assert_eq!(canceled, 1, "only the stale record should be canceled");
2892
2893 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
2895 assert_eq!(stale_record.status, JobState::Canceled);
2896
2897 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
2899 assert_eq!(fresh_record.status, JobState::Queued);
2900 }
2901
2902 #[test]
2903 fn cleanup_stale_queued_revokes_every_unconsumed_approval() {
2904 let dir = tempdir().unwrap();
2905 let store = test_store(dir.path().join("tx.db"));
2906 let transactions: Vec<_> = (0..3)
2907 .map(|_| {
2908 let transaction = store.record(queued_transaction()).unwrap();
2909 store
2910 .approve_transaction(&transaction.transaction_id)
2911 .unwrap()
2912 .expect("stale transaction is approvable");
2913 transaction
2914 })
2915 .collect();
2916
2917 let conn = store.connection().unwrap();
2918 for transaction in &transactions {
2919 conn.execute(
2920 "UPDATE transactions \
2921 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
2922 WHERE transaction_id = ?1",
2923 params![transaction.transaction_id],
2924 )
2925 .unwrap();
2926 }
2927
2928 assert_eq!(store.cleanup_stale_queued().unwrap(), 3);
2929 for transaction in &transactions {
2930 assert_eq!(
2931 store
2932 .get(&transaction.transaction_id)
2933 .unwrap()
2934 .unwrap()
2935 .status,
2936 JobState::Canceled
2937 );
2938 assert!(!store
2939 .revoke_unconsumed_approval(&transaction.transaction_id)
2940 .unwrap());
2941 }
2942 assert_eq!(
2943 store
2944 .fetch_event_rows()
2945 .unwrap()
2946 .iter()
2947 .map(|event| event.kind.as_str())
2948 .collect::<Vec<_>>(),
2949 vec![
2950 "approval_granted",
2951 "approval_granted",
2952 "approval_granted",
2953 "approval_revoked",
2954 "approval_revoked",
2955 "approval_revoked"
2956 ]
2957 );
2958 }
2959
2960 #[test]
2963 fn update_status_rejects_queued_to_succeeded() {
2964 let dir = tempdir().unwrap();
2965 let store = test_store(dir.path().join("tx.db"));
2966 let tx = store.record(queued_transaction()).unwrap();
2967
2968 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
2969 assert!(
2970 matches!(
2971 result,
2972 Err(TransactionStoreError::InvalidTransition {
2973 from: JobState::Queued,
2974 to: JobState::Succeeded,
2975 })
2976 ),
2977 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
2978 );
2979 }
2980
2981 #[test]
2982 fn update_status_rejects_succeeded_to_running() {
2983 let dir = tempdir().unwrap();
2984 let store = test_store(dir.path().join("tx.db"));
2985 let tx = store.record(queued_transaction()).unwrap();
2986
2987 store
2988 .update_status(&tx.transaction_id, JobState::Running)
2989 .unwrap();
2990 store
2991 .update_status(&tx.transaction_id, JobState::Succeeded)
2992 .unwrap();
2993
2994 let result = store.update_status(&tx.transaction_id, JobState::Running);
2995 assert!(
2996 matches!(
2997 result,
2998 Err(TransactionStoreError::InvalidTransition {
2999 from: JobState::Succeeded,
3000 to: JobState::Running,
3001 })
3002 ),
3003 "Succeeded -> Running must be rejected (terminal state): {result:?}"
3004 );
3005 }
3006
3007 #[test]
3008 fn update_status_accepts_running_to_failed() {
3009 let dir = tempdir().unwrap();
3010 let store = test_store(dir.path().join("tx.db"));
3011 let tx = store.record(queued_transaction()).unwrap();
3012
3013 store
3014 .update_status(&tx.transaction_id, JobState::Running)
3015 .unwrap();
3016 store
3017 .update_status(&tx.transaction_id, JobState::Failed)
3018 .unwrap();
3019
3020 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3021 assert_eq!(updated.status, JobState::Failed);
3022 }
3023
3024 #[test]
3025 fn update_status_accepts_running_to_rolled_back() {
3026 let dir = tempdir().unwrap();
3027 let store = test_store(dir.path().join("tx.db"));
3028 let tx = store.record(queued_transaction()).unwrap();
3029
3030 store
3031 .update_status(&tx.transaction_id, JobState::Running)
3032 .unwrap();
3033 store
3034 .update_status(&tx.transaction_id, JobState::RolledBack)
3035 .unwrap();
3036
3037 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
3038 assert_eq!(updated.status, JobState::RolledBack);
3039 }
3040
3041 #[test]
3044 fn list_transactions_returns_empty_for_fresh_store() {
3045 let dir = tempdir().unwrap();
3046 let store = test_store(dir.path().join("tx.db"));
3047 let results = store.list_transactions(10, None, None, None).unwrap();
3048 assert!(results.is_empty());
3049 }
3050
3051 #[test]
3052 fn list_transactions_returns_all_records_ordered_by_newest_first() {
3053 let dir = tempdir().unwrap();
3054 let store = test_store(dir.path().join("tx.db"));
3055 store.record(queued_transaction()).unwrap();
3056
3057 let mut second = queued_transaction();
3058 second.action_name = "GetDiskUsage".to_string();
3059 second.risk_level = RiskLevel::Low;
3060 store.record(second).unwrap();
3061
3062 let results = store.list_transactions(10, None, None, None).unwrap();
3063 assert_eq!(results.len(), 2);
3064 assert_eq!(results[0].action_name, "GetDiskUsage");
3066 assert_eq!(results[1].action_name, "UpdateSystem");
3067 }
3068
3069 #[test]
3070 fn list_history_populates_created_at_and_risk_level() {
3071 let dir = tempdir().unwrap();
3072 let store = test_store(dir.path().join("tx.db"));
3073 store.record(queued_transaction()).unwrap();
3074
3075 let entries = store.list_history(10, None, None, None).unwrap();
3076 assert_eq!(entries.len(), 1);
3077 let entry = &entries[0];
3078 assert_eq!(entry.action_name, "UpdateSystem");
3079 assert_eq!(entry.risk_level, RiskLevel::High);
3080 assert_eq!(entry.status, JobState::Queued);
3081 assert!(
3082 !entry.created_at.is_empty(),
3083 "created_at must be populated from the stored row, not left blank"
3084 );
3085 }
3086
3087 #[test]
3088 fn list_history_applies_the_same_filters_as_list_transactions() {
3089 let dir = tempdir().unwrap();
3090 let store = test_store(dir.path().join("tx.db"));
3091 store.record(queued_transaction()).unwrap();
3092 let mut low = queued_transaction();
3093 low.action_name = "GetDiskUsage".to_string();
3094 low.risk_level = RiskLevel::Low;
3095 store.record(low).unwrap();
3096
3097 let only = store
3098 .list_history(10, None, Some("GetDiskUsage"), None)
3099 .unwrap();
3100 assert_eq!(only.len(), 1);
3101 assert_eq!(only[0].action_name, "GetDiskUsage");
3102 }
3103
3104 #[test]
3105 fn list_transactions_respects_limit() {
3106 let dir = tempdir().unwrap();
3107 let store = test_store(dir.path().join("tx.db"));
3108 for _ in 0..5 {
3109 store.record(queued_transaction()).unwrap();
3110 }
3111 let results = store.list_transactions(3, None, None, None).unwrap();
3112 assert_eq!(results.len(), 3);
3113 }
3114
3115 #[test]
3116 fn list_transactions_filters_by_status() {
3117 let dir = tempdir().unwrap();
3118 let store = test_store(dir.path().join("tx.db"));
3119 let tx = store.record(queued_transaction()).unwrap();
3120 store
3121 .update_status(&tx.transaction_id, JobState::Running)
3122 .unwrap();
3123 store
3124 .update_status(&tx.transaction_id, JobState::Succeeded)
3125 .unwrap();
3126
3127 store.record(queued_transaction()).unwrap();
3129
3130 let succeeded = store
3131 .list_transactions(10, Some("succeeded"), None, None)
3132 .unwrap();
3133 assert_eq!(succeeded.len(), 1);
3134 assert_eq!(succeeded[0].status, JobState::Succeeded);
3135
3136 let queued = store
3137 .list_transactions(10, Some("queued"), None, None)
3138 .unwrap();
3139 assert_eq!(queued.len(), 1);
3140 assert_eq!(queued[0].status, JobState::Queued);
3141 }
3142
3143 #[test]
3144 fn list_transactions_filters_by_action_name() {
3145 let dir = tempdir().unwrap();
3146 let store = test_store(dir.path().join("tx.db"));
3147 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
3150 disk.action_name = "GetDiskUsage".to_string();
3151 store.record(disk).unwrap();
3152
3153 let results = store
3154 .list_transactions(10, None, Some("GetDiskUsage"), None)
3155 .unwrap();
3156 assert_eq!(results.len(), 1);
3157 assert_eq!(results[0].action_name, "GetDiskUsage");
3158 }
3159
3160 #[test]
3161 fn list_transactions_filters_by_since_hours() {
3162 let dir = tempdir().unwrap();
3163 let store = test_store(dir.path().join("tx.db"));
3164
3165 let old = store.record(queued_transaction()).unwrap();
3167 let conn = store.connection().unwrap();
3168 conn.execute(
3169 "UPDATE transactions \
3170 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
3171 WHERE transaction_id = ?1",
3172 params![old.transaction_id],
3173 )
3174 .unwrap();
3175
3176 store.record(queued_transaction()).unwrap();
3178
3179 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
3181 assert_eq!(results.len(), 1);
3182
3183 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
3185 assert_eq!(results.len(), 2);
3186 }
3187
3188 #[test]
3189 fn list_transactions_rejects_invalid_status_filter() {
3190 let dir = tempdir().unwrap();
3191 let store = test_store(dir.path().join("tx.db"));
3192 store.record(queued_transaction()).unwrap();
3193 let result = store.list_transactions(10, Some("bogus"), None, None);
3194 assert!(result.is_err(), "invalid status filter should return error");
3195 }
3196
3197 #[test]
3205 fn record_emits_one_watermark_per_entry() {
3206 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3207 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3208
3209 let dir = tempdir().unwrap();
3210 let store = test_store(dir.path().join("tx.db"));
3211 store.record(queued_transaction()).unwrap();
3212
3213 let calls = crate::audit_watermark::take_watermarks(&sink);
3214 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
3215 }
3216
3217 #[test]
3219 fn record_previewed_emits_one_watermark() {
3220 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3221 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3222
3223 let dir = tempdir().unwrap();
3224 let store = test_store(dir.path().join("tx.db"));
3225 let preview = PreviewEnvelope {
3226 summary: "Upgrade the system".to_string(),
3227 risk_level: RiskLevel::High,
3228 current_state: serde_json::Value::Null,
3229 proposed_change: serde_json::Value::Null,
3230 expected_side_effects: vec![],
3231 reboot_required: false,
3232 rollback_available: false,
3233 warnings: vec![],
3234 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
3235 };
3236 store
3237 .record_previewed(queued_transaction(), preview)
3238 .unwrap();
3239
3240 let calls = crate::audit_watermark::take_watermarks(&sink);
3241 assert_eq!(
3242 calls.len(),
3243 1,
3244 "expected exactly 1 watermark per record_previewed()"
3245 );
3246 }
3247
3248 #[test]
3250 fn watermark_seq_and_hash_match_chain_row() {
3251 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3252 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3253
3254 let dir = tempdir().unwrap();
3255 let store = test_store(dir.path().join("tx.db"));
3256 store.record(queued_transaction()).unwrap();
3257
3258 let rows = store.fetch_chain_rows().unwrap();
3259 assert_eq!(rows.len(), 1);
3260 let row = &rows[0];
3261
3262 let calls = crate::audit_watermark::take_watermarks(&sink);
3263 assert_eq!(calls.len(), 1);
3264 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
3265 assert_eq!(
3266 calls[0].chain_hash_hex, row.chain_hash,
3267 "watermark chain_hash_hex must match stored chain_hash"
3268 );
3269 }
3270
3271 #[test]
3273 fn multiple_records_produce_one_watermark_each() {
3274 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3275 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3276
3277 let dir = tempdir().unwrap();
3278 let store = test_store(dir.path().join("tx.db"));
3279 for _ in 0..3 {
3280 store.record(queued_transaction()).unwrap();
3281 }
3282
3283 let calls = crate::audit_watermark::take_watermarks(&sink);
3284 assert_eq!(calls.len(), 3, "one watermark per record call");
3285 assert_eq!(calls[0].seq, 1);
3286 assert_eq!(calls[1].seq, 2);
3287 assert_eq!(calls[2].seq, 3);
3288 }
3289
3290 #[test]
3304 fn no_watermark_emitted_before_any_record() {
3305 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3306 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
3307
3308 let dir = tempdir().unwrap();
3309 let _store = test_store(dir.path().join("tx.db"));
3310
3311 let calls = crate::audit_watermark::take_watermarks(&sink);
3313 assert!(
3314 calls.is_empty(),
3315 "no watermark must be emitted without a record() call"
3316 );
3317 }
3318}