1use crate::audit_chain::{self, AuditKey, ChainContent, ChainRow, VerifyOutcome, CURRENT_KEY_ID};
2use crate::audit_watermark::emit_chain_tip_watermark;
3use rusqlite::{params, Connection, TransactionBehavior};
4use serde::{de::DeserializeOwned, Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use subtle::ConstantTimeEq;
8use sysknife_types::{JobState, PreviewEnvelope, RiskLevel, TransactionRecord};
9use uuid::Uuid;
10
11pub(crate) const APPROVAL_RECEIPT_TTL_MINUTES: i64 = 15;
22
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31pub struct JobHistoryEntry {
32 pub transaction_id: String,
33 pub action_name: String,
34 pub risk_level: RiskLevel,
35 pub status: JobState,
36 pub summary: String,
37 pub created_at: String,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct NewTransaction {
47 pub request_id: String,
48 pub request_hash: String,
49 pub action_name: String,
50 pub risk_level: RiskLevel,
51 pub summary: String,
71 pub warnings: Vec<String>,
72}
73
74#[derive(Clone, Debug)]
75pub struct TransactionStore {
76 path: PathBuf,
77 audit_key: Option<Arc<AuditKey>>,
80}
81
82#[derive(Clone, Debug, PartialEq)]
83pub struct RecordedPreviewedTransaction {
84 pub transaction: TransactionRecord,
85 pub preview: PreviewEnvelope,
86}
87
88struct InsertedTransaction {
89 record: TransactionRecord,
90 seq: u64,
91 chain_hash: String,
92}
93
94#[derive(Debug, thiserror::Error)]
95pub enum TransactionStoreError {
96 #[error("io error: {0}")]
97 Io(#[from] std::io::Error),
98
99 #[error("sqlite error: {0}")]
100 Sqlite(#[from] rusqlite::Error),
101
102 #[error("json error: {0}")]
103 Json(#[from] serde_json::Error),
104
105 #[error("database invariant violation: {0}")]
106 DatabaseInvariant(String),
107
108 #[error("transaction not found: {0}")]
109 NotFound(String),
110
111 #[error("invalid transition from {from:?} to {to:?}")]
112 InvalidTransition { from: JobState, to: JobState },
113
114 #[error("audit chain misconfiguration: {0}")]
115 AuditChainMissing(&'static str),
116
117 #[error("transaction {0} status changed concurrently; retry")]
125 ConcurrentStatusChange(String),
126}
127
128impl TransactionStore {
129 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
133 let path = path.as_ref().to_path_buf();
134 if let Some(parent) = path.parent() {
135 ensure_private_dir(parent)?;
136 }
137
138 let store = Self {
139 path,
140 audit_key: None,
141 };
142 store.initialize()?;
143 Ok(store)
144 }
145
146 pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
153 let db_path = path.as_ref();
154 let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
155 .map(PathBuf::from)
156 .unwrap_or_else(|_| {
157 db_path
158 .parent()
159 .unwrap_or_else(|| Path::new("."))
160 .join("audit-key")
161 });
162 let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
163 TransactionStoreError::Io(std::io::Error::new(
164 std::io::ErrorKind::InvalidData,
165 format!("audit key load failed: {e}"),
166 ))
167 })?;
168 Self::open_with_key(path, Arc::new(key))
169 }
170
171 pub fn open_with_key(
174 path: impl AsRef<Path>,
175 audit_key: Arc<AuditKey>,
176 ) -> Result<Self, TransactionStoreError> {
177 let path = path.as_ref().to_path_buf();
178 if let Some(parent) = path.parent() {
179 ensure_private_dir(parent)?;
180 }
181
182 let store = Self {
183 path,
184 audit_key: Some(audit_key),
185 };
186 store.initialize()?;
187 Ok(store)
188 }
189
190 pub fn record(
191 &self,
192 transaction: NewTransaction,
193 ) -> Result<TransactionRecord, TransactionStoreError> {
194 let key = self
195 .audit_key
196 .as_ref()
197 .ok_or(TransactionStoreError::AuditChainMissing(
198 "this TransactionStore was opened read-only; cannot record",
199 ))?;
200 let mut conn = self.connection()?;
201 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
206 let transaction_id = Uuid::new_v4().to_string();
207 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
208 tx.commit()?;
209 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
210 Ok(inserted.record)
211 }
212
213 pub fn record_previewed(
214 &self,
215 transaction: NewTransaction,
216 preview: PreviewEnvelope,
217 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
218 let key = self
219 .audit_key
220 .as_ref()
221 .ok_or(TransactionStoreError::AuditChainMissing(
222 "this TransactionStore was opened read-only; cannot record",
223 ))?;
224 let mut conn = self.connection()?;
225 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
230 let transaction_id = Uuid::new_v4().to_string();
231 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
232 Self::insert_preview(&tx, &inserted.record.transaction_id, &preview)?;
233 tx.commit()?;
234 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
235
236 Ok(RecordedPreviewedTransaction {
237 transaction: inserted.record,
238 preview,
239 })
240 }
241
242 pub fn get(
243 &self,
244 transaction_id: &str,
245 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
246 let conn = self.connection()?;
247 let mut stmt = conn.prepare(
248 "SELECT
249 transaction_id,
250 request_id,
251 request_hash,
252 action_name,
253 risk_level,
254 status,
255 approval_id,
256 summary,
257 warnings_json
258 FROM transactions
259 WHERE transaction_id = ?1",
260 )?;
261 let mut rows = stmt.query(params![transaction_id])?;
262 if let Some(row) = rows.next()? {
263 Ok(Some(row_to_record(row)?))
264 } else {
265 Ok(None)
266 }
267 }
268
269 pub fn get_preview(
270 &self,
271 transaction_id: &str,
272 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
273 let conn = self.connection()?;
274 let mut stmt = conn.prepare(
275 "SELECT preview_json
276 FROM transaction_previews
277 WHERE transaction_id = ?1",
278 )?;
279 let mut rows = stmt.query(params![transaction_id])?;
280 if let Some(row) = rows.next()? {
281 let preview_json: String = row.get(0)?;
282 Ok(Some(serde_json::from_str(&preview_json)?))
283 } else {
284 Ok(None)
285 }
286 }
287
288 pub fn update_status(
289 &self,
290 transaction_id: &str,
291 new_status: JobState,
292 ) -> Result<(), TransactionStoreError> {
293 let conn = self.connection()?;
294
295 let current_status: String = conn
297 .query_row(
298 "SELECT status FROM transactions WHERE transaction_id = ?1",
299 params![transaction_id],
300 |row| row.get(0),
301 )
302 .map_err(|e| match e {
303 rusqlite::Error::QueryReturnedNoRows => {
304 TransactionStoreError::NotFound(transaction_id.to_string())
305 }
306 other => TransactionStoreError::Sqlite(other),
307 })?;
308
309 let current: JobState = deserialize_field(¤t_status)?;
310 if !crate::jobs::allowed_transition(¤t, &new_status) {
311 return Err(TransactionStoreError::InvalidTransition {
312 from: current,
313 to: new_status,
314 });
315 }
316
317 let rows_affected = conn.execute(
329 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2 AND status = ?3",
330 params![
331 serialize_field(&new_status)?,
332 transaction_id,
333 current_status
334 ],
335 )?;
336 if rows_affected == 0 {
337 return Err(TransactionStoreError::ConcurrentStatusChange(
338 transaction_id.to_string(),
339 ));
340 }
341 Ok(())
342 }
343
344 pub fn approve_transaction(
346 &self,
347 transaction_id: &str,
348 ) -> Result<Option<String>, TransactionStoreError> {
349 let key = self
350 .audit_key
351 .as_ref()
352 .ok_or(TransactionStoreError::AuditChainMissing(
353 "this TransactionStore was opened read-only; cannot approve",
354 ))?;
355 let Some(record) = self.get(transaction_id)? else {
356 return Ok(None);
357 };
358 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
359 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
360 let Some(committed_digest) = record.approval_id.as_deref() else {
361 return Err(TransactionStoreError::DatabaseInvariant(format!(
362 "transaction {transaction_id} has no signed approval commitment"
363 )));
364 };
365 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
366 return Err(TransactionStoreError::DatabaseInvariant(format!(
367 "transaction {transaction_id} approval commitment does not match its signed preview"
368 )));
369 }
370
371 let conn = self.connection()?;
372 let queued_json = serialize_field(&JobState::Queued)?;
373 let rows_affected = conn.execute(
374 &format!(
375 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
376 SELECT transaction_id, ?1 FROM transactions \
377 WHERE transaction_id = ?2 \
378 AND status = ?3 \
379 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
380 AND NOT EXISTS ( \
381 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
382 )"
383 ),
384 params![receipt_digest, transaction_id, queued_json],
385 )?;
386 Ok((rows_affected > 0).then_some(receipt))
387 }
388
389 pub fn revoke_unconsumed_approval(
392 &self,
393 transaction_id: &str,
394 ) -> Result<bool, TransactionStoreError> {
395 let conn = self.connection()?;
396 let rows_affected = conn.execute(
397 "DELETE FROM transaction_approvals \
398 WHERE transaction_id = ?1 AND consumed_at IS NULL",
399 params![transaction_id],
400 )?;
401 Ok(rows_affected > 0)
402 }
403
404 pub fn claim_approved_for_execution(
406 &self,
407 transaction_id: &str,
408 receipt_digest: &str,
409 ) -> Result<bool, TransactionStoreError> {
410 let mut conn = self.connection()?;
411 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
412 let queued_json = serialize_field(&JobState::Queued)?;
413 let running_json = serialize_field(&JobState::Running)?;
414 let rows_affected = tx.execute(
415 &format!(
416 "UPDATE transactions SET status = ?1 \
417 WHERE transaction_id = ?2 \
418 AND status = ?3 \
419 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
420 AND EXISTS ( \
421 SELECT 1 FROM transaction_approvals \
422 WHERE transaction_id = ?2 \
423 AND receipt_digest = ?4 \
424 AND consumed_at IS NULL \
425 )"
426 ),
427 params![running_json, transaction_id, queued_json, receipt_digest],
428 )?;
429 if rows_affected > 0 {
430 tx.execute(
431 "UPDATE transaction_approvals \
432 SET consumed_at = datetime('now') \
433 WHERE transaction_id = ?1 AND consumed_at IS NULL",
434 params![transaction_id],
435 )?;
436 }
437 tx.commit()?;
438 Ok(rows_affected > 0)
439 }
440
441 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
456 let conn = self.connection()?;
457 let canceled_json = serialize_field(&JobState::Canceled)?;
458 let queued_json = serialize_field(&JobState::Queued)?;
459 let rows_affected = conn.execute(
460 &format!(
461 "UPDATE transactions SET status = ?1 \
462 WHERE status = ?2 \
463 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
464 ),
465 params![canceled_json, queued_json],
466 )?;
467 Ok(rows_affected as u64)
468 }
469
470 pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
479 let conn = self.connection()?;
480 let canceled_json = serialize_field(&JobState::Canceled)?;
481 let queued_json = serialize_field(&JobState::Queued)?;
482 let rows_affected = conn.execute(
483 "UPDATE transactions SET status = ?1 \
484 WHERE transaction_id = ?2 AND status = ?3",
485 params![canceled_json, transaction_id, queued_json],
486 )?;
487 Ok(rows_affected > 0)
488 }
489
490 pub fn list_transactions(
499 &self,
500 limit: u32,
501 status_filter: Option<&str>,
502 action_filter: Option<&str>,
503 since_hours: Option<u32>,
504 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
505 let conn = self.connection()?;
506 let (filter_sql, param_values) =
507 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
508 let sql = format!(
509 "SELECT transaction_id, request_id, request_hash, action_name, \
510 risk_level, status, approval_id, summary, warnings_json \
511 FROM transactions WHERE 1=1{filter_sql}"
512 );
513 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
514 param_values.iter().map(|b| b.as_ref()).collect();
515
516 let mut stmt = conn.prepare(&sql)?;
517 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
518
519 let mut results = Vec::new();
520 for row in rows {
521 results.push(row??);
522 }
523 Ok(results)
524 }
525
526 pub fn list_history(
531 &self,
532 limit: u32,
533 status_filter: Option<&str>,
534 action_filter: Option<&str>,
535 since_hours: Option<u32>,
536 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
537 let conn = self.connection()?;
538 let (filter_sql, param_values) =
539 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
540 let sql = format!(
541 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
542 FROM transactions WHERE 1=1{filter_sql}"
543 );
544 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
545 param_values.iter().map(|b| b.as_ref()).collect();
546
547 let mut stmt = conn.prepare(&sql)?;
548 let rows = stmt.query_map(params_ref.as_slice(), |row| {
549 Ok((|| {
550 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
551 transaction_id: row.get(0)?,
552 action_name: row.get(1)?,
553 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
554 status: deserialize_field(&row.get::<_, String>(3)?)?,
555 summary: row.get(4)?,
556 created_at: row.get(5)?,
557 })
558 })())
559 })?;
560
561 let mut results = Vec::new();
562 for row in rows {
563 results.push(row??);
564 }
565 Ok(results)
566 }
567
568 fn build_history_filter(
573 limit: u32,
574 status_filter: Option<&str>,
575 action_filter: Option<&str>,
576 since_hours: Option<u32>,
577 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
578 let mut sql = String::new();
579 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
580
581 if let Some(status) = status_filter {
582 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
586 let status_json = serialize_field(&validated)?;
587 sql.push_str(" AND status = ?");
588 param_values.push(Box::new(status_json));
589 }
590
591 if let Some(action) = action_filter {
592 sql.push_str(" AND action_name = ?");
593 param_values.push(Box::new(action.to_string()));
594 }
595
596 if let Some(hours) = since_hours {
597 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
598 param_values.push(Box::new(hours));
599 }
600
601 sql.push_str(" ORDER BY seq DESC LIMIT ?");
602 param_values.push(Box::new(limit.min(100)));
603
604 Ok((sql, param_values))
605 }
606
607 fn connection(&self) -> Result<Connection, TransactionStoreError> {
608 let conn = Connection::open(&self.path)?;
609 conn.pragma_update(None, "journal_mode", "WAL")?;
620 conn.pragma_update(None, "synchronous", "NORMAL")?;
621 conn.pragma_update(None, "foreign_keys", "ON")?;
622 conn.busy_timeout(std::time::Duration::from_secs(5))?;
623 Ok(conn)
624 }
625
626 fn initialize(&self) -> Result<(), TransactionStoreError> {
627 let mut conn = self.connection()?;
628 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
629 tx.execute_batch(
630 "CREATE TABLE IF NOT EXISTS schema_migrations (\
631 version INTEGER PRIMARY KEY,\
632 name TEXT NOT NULL,\
633 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
634 );",
635 )?;
636 let current: i64 = tx.query_row(
637 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
638 [],
639 |row| row.get(0),
640 )?;
641 if current > 1 {
642 return Err(TransactionStoreError::DatabaseInvariant(format!(
643 "sqlite schema version {current} is newer than this binary supports (1)"
644 )));
645 }
646 tx.execute_batch(
662 r#"
663 CREATE TABLE IF NOT EXISTS transactions (
664 transaction_id TEXT PRIMARY KEY,
665 request_id TEXT NOT NULL,
666 request_hash TEXT NOT NULL,
667 action_name TEXT NOT NULL,
668 risk_level TEXT NOT NULL,
669 status TEXT NOT NULL,
670 approval_id TEXT,
671 summary TEXT NOT NULL,
672 warnings_json TEXT NOT NULL,
673 created_at TEXT NOT NULL DEFAULT (datetime('now')),
674 seq INTEGER NOT NULL UNIQUE,
675 key_id TEXT NOT NULL,
676 chain_hash TEXT NOT NULL,
677 prev_chain_hash TEXT NOT NULL DEFAULT ''
678 );
679
680 CREATE TABLE IF NOT EXISTS transaction_previews (
681 transaction_id TEXT PRIMARY KEY,
682 preview_json TEXT NOT NULL
683 );
684
685 CREATE TABLE IF NOT EXISTS transaction_approvals (
686 transaction_id TEXT PRIMARY KEY,
687 receipt_digest TEXT NOT NULL,
688 approved_at TEXT NOT NULL DEFAULT (datetime('now')),
689 consumed_at TEXT
690 );
691
692 CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
693 "#,
694 )?;
695 tx.execute(
696 "INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (1, ?1)",
697 params!["initial_audit_schema"],
698 )?;
699 tx.commit()?;
700 Ok(())
701 }
702
703 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
705 let conn = self.connection()?;
706 let mut stmt = conn.prepare(
707 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
708 action_name, risk_level, summary, approval_id, warnings_json, \
709 created_at, prev_chain_hash, chain_hash \
710 FROM transactions ORDER BY seq ASC",
711 )?;
712 let rows = stmt.query_map([], |row| {
713 Ok(ChainRow {
714 seq: row.get::<_, i64>(0)? as u64,
715 key_id: row.get(1)?,
716 transaction_id: row.get(2)?,
717 request_id: row.get(3)?,
718 request_hash: row.get(4)?,
719 action_name: row.get(5)?,
720 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
721 rusqlite::Error::FromSqlConversionFailure(
722 6,
723 rusqlite::types::Type::Text,
724 Box::new(e),
725 )
726 })?,
727 summary: row.get(7)?,
728 approval_id: row.get(8)?,
729 warnings_json: row.get(9)?,
730 created_at: row.get(10)?,
731 prev_chain_hash: row.get(11)?,
732 chain_hash: row.get(12)?,
733 })
734 })?;
735 let mut out = Vec::new();
736 for row in rows {
737 out.push(row?);
738 }
739 Ok(out)
740 }
741
742 pub fn verify_audit_chain(
744 &self,
745 key: &AuditKey,
746 ) -> Result<VerifyOutcome, TransactionStoreError> {
747 let rows = self.fetch_chain_rows()?;
748 Ok(audit_chain::verify_chain(key, &rows))
749 }
750
751 pub fn verify_audit_chain_with_pubkey(
754 &self,
755 verifying_key_hex: &str,
756 ) -> Result<VerifyOutcome, TransactionStoreError> {
757 let rows = self.fetch_chain_rows()?;
758 Ok(audit_chain::verify_chain_with_pubkey(
759 verifying_key_hex,
760 &rows,
761 ))
762 }
763
764 pub fn fetch_chain_row(
767 &self,
768 transaction_id: &str,
769 ) -> Result<Option<ChainRow>, TransactionStoreError> {
770 let conn = self.connection()?;
771 let mut stmt = conn.prepare(
772 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
773 action_name, risk_level, summary, approval_id, warnings_json, \
774 created_at, prev_chain_hash, chain_hash \
775 FROM transactions WHERE transaction_id = ?1",
776 )?;
777 let mut rows = stmt.query(params![transaction_id])?;
778 if let Some(row) = rows.next()? {
779 Ok(Some(ChainRow {
780 seq: row.get::<_, i64>(0)? as u64,
781 key_id: row.get(1)?,
782 transaction_id: row.get(2)?,
783 request_id: row.get(3)?,
784 request_hash: row.get(4)?,
785 action_name: row.get(5)?,
786 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
787 rusqlite::Error::FromSqlConversionFailure(
788 6,
789 rusqlite::types::Type::Text,
790 Box::new(e),
791 )
792 })?,
793 summary: row.get(7)?,
794 approval_id: row.get(8)?,
795 warnings_json: row.get(9)?,
796 created_at: row.get(10)?,
797 prev_chain_hash: row.get(11)?,
798 chain_hash: row.get(12)?,
799 }))
800 } else {
801 Ok(None)
802 }
803 }
804
805 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
809 let prev: Option<(i64, String)> = conn
810 .query_row(
811 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
812 [],
813 |row| Ok((row.get(0)?, row.get(1)?)),
814 )
815 .map(Some)
816 .or_else(|e| match e {
817 rusqlite::Error::QueryReturnedNoRows => Ok(None),
818 other => Err(other),
819 })?;
820 Ok(match prev {
821 Some((seq, hash)) => ((seq as u64) + 1, hash),
822 None => (1, String::new()),
823 })
824 }
825
826 fn insert_transaction(
827 conn: &Connection,
828 key: &AuditKey,
829 transaction_id: &str,
830 transaction: NewTransaction,
831 ) -> Result<InsertedTransaction, TransactionStoreError> {
832 let request_id = transaction.request_id;
833 let request_hash = transaction.request_hash;
834 let action_name = transaction.action_name;
835 let risk_level = transaction.risk_level;
836 let status = JobState::Queued;
838 let approval_id = Some(key.approval_commitment(transaction_id, &request_hash));
845 let summary = transaction.summary;
846 let warnings = transaction.warnings;
847 let warnings_json = serde_json::to_string(&warnings)?;
848
849 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
852
853 let created_at: String =
857 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
858 row.get(0)
859 })?;
860
861 let key_id = CURRENT_KEY_ID.to_string();
862 let chain_hash = key.chain_hash(
863 &ChainContent {
864 seq,
865 key_id: &key_id,
866 transaction_id,
867 request_id: &request_id,
868 request_hash: &request_hash,
869 action_name: &action_name,
870 risk_level,
871 summary: &summary,
872 approval_id: approval_id.as_deref(),
873 warnings_json: &warnings_json,
874 created_at: &created_at,
875 },
876 &prev_chain_hash,
877 );
878
879 let record = TransactionRecord {
880 transaction_id: transaction_id.to_string(),
881 request_id: request_id.clone(),
882 request_hash: request_hash.clone(),
883 action_name: action_name.clone(),
884 risk_level,
885 status,
886 approval_id: approval_id.clone(),
887 summary: summary.clone(),
888 warnings: warnings.clone(),
889 };
890
891 conn.execute(
892 "INSERT INTO transactions (
893 transaction_id,
894 request_id,
895 request_hash,
896 action_name,
897 risk_level,
898 status,
899 approval_id,
900 summary,
901 warnings_json,
902 created_at,
903 seq,
904 key_id,
905 chain_hash,
906 prev_chain_hash
907 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
908 params![
909 transaction_id,
910 request_id,
911 request_hash,
912 action_name,
913 serialize_field(&risk_level)?,
914 serialize_field(&status)?,
915 approval_id,
916 summary,
917 warnings_json,
918 created_at,
919 seq as i64,
920 key_id,
921 chain_hash,
922 prev_chain_hash,
923 ],
924 )?;
925
926 Ok(InsertedTransaction {
927 record,
928 seq,
929 chain_hash,
930 })
931 }
932
933 fn insert_preview(
934 conn: &Connection,
935 transaction_id: &str,
936 preview: &PreviewEnvelope,
937 ) -> Result<(), TransactionStoreError> {
938 conn.execute(
939 "INSERT INTO transaction_previews (transaction_id, preview_json)
940 VALUES (?1, ?2)",
941 params![transaction_id, serde_json::to_string(preview)?],
942 )?;
943 Ok(())
944 }
945}
946
947fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
954 use std::os::unix::fs::DirBuilderExt;
955 if dir.exists() {
956 return Ok(());
957 }
958 std::fs::DirBuilder::new()
959 .recursive(true)
960 .mode(0o700)
961 .create(dir)
962}
963
964fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
965 Ok(TransactionRecord {
966 transaction_id: row.get(0)?,
967 request_id: row.get(1)?,
968 request_hash: row.get(2)?,
969 action_name: row.get(3)?,
970 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
971 status: deserialize_field(&row.get::<_, String>(5)?)?,
972 approval_id: row.get(6)?,
973 summary: row.get(7)?,
974 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
975 })
976}
977
978fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
979 serde_json::to_string(value)
980}
981
982fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
983 serde_json::from_str(value)
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989 use std::os::unix::fs::PermissionsExt;
990 use tempfile::tempdir;
991
992 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
996 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
997 TransactionStore::open_with_key(path, key).unwrap()
998 }
999
1000 #[test]
1003 fn record_writes_audit_chain_columns() {
1004 let dir = tempdir().unwrap();
1005 let store = test_store(dir.path().join("tx.db"));
1006 let _record = store.record(queued_transaction()).unwrap();
1007
1008 let conn = store.connection().unwrap();
1009 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
1010 .query_row(
1011 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
1012 [],
1013 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1014 )
1015 .unwrap();
1016 assert_eq!(seq, 1, "first row gets seq=1");
1017 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
1018 assert_eq!(prev, "", "first row has empty prev_chain_hash");
1019 assert_eq!(
1020 chain_hash.len(),
1021 audit_chain::HASH_HEX_LEN,
1022 "chain_hash is a hex-encoded Ed25519 signature"
1023 );
1024 }
1025
1026 #[test]
1027 fn sequential_records_produce_chained_hashes() {
1028 let dir = tempdir().unwrap();
1029 let store = test_store(dir.path().join("tx.db"));
1030 store.record(queued_transaction()).unwrap();
1031 store.record(queued_transaction()).unwrap();
1032 store.record(queued_transaction()).unwrap();
1033
1034 let rows = store.fetch_chain_rows().unwrap();
1035 assert_eq!(rows.len(), 3);
1036 assert_eq!(rows[0].seq, 1);
1037 assert_eq!(rows[1].seq, 2);
1038 assert_eq!(rows[2].seq, 3);
1039 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1041 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
1042 }
1043
1044 #[test]
1056 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
1057 const WORKERS: usize = 8;
1058 const RECORDS_PER_WORKER: usize = 10;
1059 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
1060
1061 let dir = tempdir().unwrap();
1062 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1063
1064 let mut handles = Vec::with_capacity(WORKERS);
1065 for w in 0..WORKERS {
1066 let store = std::sync::Arc::clone(&store);
1067 handles.push(std::thread::spawn(move || {
1068 for r in 0..RECORDS_PER_WORKER {
1069 let tx = NewTransaction {
1070 request_id: format!("worker-{w}-record-{r}"),
1071 request_hash: format!("hash-{w}-{r}"),
1072 action_name: "GetSystemState".to_string(),
1073 risk_level: RiskLevel::Low,
1074 summary: format!("worker {w} record {r}"),
1075 warnings: vec![],
1076 };
1077 store
1078 .record(tx)
1079 .expect("record must succeed under contention");
1080 }
1081 }));
1082 }
1083 for h in handles {
1084 h.join().expect("worker thread did not panic");
1085 }
1086
1087 let key = AuditKey::from_bytes(vec![0x42; 32]);
1089 let outcome = store.verify_audit_chain(&key).unwrap();
1090 match outcome {
1091 VerifyOutcome::Intact { rows_checked } => {
1092 assert_eq!(
1093 rows_checked, TOTAL as u64,
1094 "expected {TOTAL} rows checked, got {rows_checked}"
1095 );
1096 }
1097 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
1098 }
1099
1100 let conn = store.connection().unwrap();
1102 let mut stmt = conn
1103 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
1104 .unwrap();
1105 let seqs: Vec<i64> = stmt
1106 .query_map([], |row| row.get::<_, i64>(0))
1107 .unwrap()
1108 .map(|r| r.unwrap())
1109 .collect();
1110 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
1111 for (i, s) in seqs.iter().enumerate() {
1112 assert_eq!(
1113 *s,
1114 (i as i64) + 1,
1115 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
1116 );
1117 }
1118 }
1119
1120 #[test]
1121 fn verify_audit_chain_intact_after_inserts() {
1122 let dir = tempdir().unwrap();
1123 let store = test_store(dir.path().join("tx.db"));
1124 for _ in 0..3 {
1125 store.record(queued_transaction()).unwrap();
1126 }
1127 let key = AuditKey::from_bytes(vec![0x42; 32]);
1128 let outcome = store.verify_audit_chain(&key).unwrap();
1129 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1130 }
1131
1132 #[test]
1133 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
1134 let dir = tempdir().unwrap();
1135 let store = test_store(dir.path().join("tx.db"));
1136 for _ in 0..3 {
1137 store.record(queued_transaction()).unwrap();
1138 }
1139 let key = AuditKey::from_bytes(vec![0x42; 32]);
1141 let outcome = store
1142 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
1143 .unwrap();
1144 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1145 }
1146
1147 #[test]
1148 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
1149 let dir = tempdir().unwrap();
1150 let store = test_store(dir.path().join("tx.db"));
1151 store.record(queued_transaction()).unwrap();
1152 let other = AuditKey::from_bytes(vec![0x99; 32]);
1154 let outcome = store
1155 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
1156 .unwrap();
1157 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1158 }
1159
1160 #[test]
1161 fn verify_detects_tampered_summary() {
1162 let dir = tempdir().unwrap();
1163 let store = test_store(dir.path().join("tx.db"));
1164 let tx = store.record(queued_transaction()).unwrap();
1165
1166 let conn = store.connection().unwrap();
1169 conn.execute(
1170 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
1171 params!["EVIL CHANGE", tx.transaction_id],
1172 )
1173 .unwrap();
1174
1175 let key = AuditKey::from_bytes(vec![0x42; 32]);
1176 let outcome = store.verify_audit_chain(&key).unwrap();
1177 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1178 }
1179
1180 #[test]
1181 fn status_update_does_not_break_chain() {
1182 let dir = tempdir().unwrap();
1184 let store = test_store(dir.path().join("tx.db"));
1185 let tx = store.record(queued_transaction()).unwrap();
1186 store
1187 .update_status(&tx.transaction_id, JobState::Running)
1188 .unwrap();
1189 store
1190 .update_status(&tx.transaction_id, JobState::Succeeded)
1191 .unwrap();
1192
1193 let key = AuditKey::from_bytes(vec![0x42; 32]);
1194 let outcome = store.verify_audit_chain(&key).unwrap();
1195 assert!(
1196 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
1197 "status mutation must not break the chain (status not in chain content): {outcome:?}"
1198 );
1199 }
1200
1201 #[test]
1202 fn open_read_only_rejects_record() {
1203 let dir = tempdir().unwrap();
1204 let key_path = dir.path().join("audit-key");
1205 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
1206 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1207
1208 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
1209 let result = store.record(queued_transaction());
1210 assert!(matches!(
1211 result,
1212 Err(TransactionStoreError::AuditChainMissing(_))
1213 ));
1214 }
1215
1216 #[test]
1217 fn ensure_private_dir_creates_with_0700_mode() {
1218 let root = tempdir().unwrap();
1219 let target = root.path().join("a/b/c");
1220 ensure_private_dir(&target).unwrap();
1221 assert!(target.is_dir());
1222 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1223 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
1224 }
1225
1226 #[test]
1227 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
1228 let root = tempdir().unwrap();
1229 let target = root.path().join("preexisting");
1230 std::fs::create_dir(&target).unwrap();
1231 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1232 ensure_private_dir(&target).unwrap();
1233 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1235 assert_eq!(mode, 0o755);
1236 }
1237
1238 #[test]
1239 fn open_creates_parent_with_private_mode() {
1240 let root = tempdir().unwrap();
1241 let db_path = root.path().join("nested/dirs/daemon.sqlite");
1242 let _store = test_store(&db_path);
1243 let parent = db_path.parent().unwrap();
1244 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
1245 assert_eq!(mode, 0o700);
1246 }
1247
1248 fn queued_transaction() -> NewTransaction {
1249 NewTransaction {
1250 request_id: "req-1".to_string(),
1251 request_hash: "hash-abc".to_string(),
1252 action_name: "UpdateSystem".to_string(),
1253 risk_level: RiskLevel::High,
1254 summary: "Upgrade the system".to_string(),
1255 warnings: vec![],
1256 }
1257 }
1258
1259 #[test]
1260 fn update_status_transitions_queued_to_running() {
1261 let dir = tempdir().unwrap();
1262 let store = test_store(dir.path().join("tx.db"));
1263 let tx = store.record(queued_transaction()).unwrap();
1264
1265 store
1266 .update_status(&tx.transaction_id, JobState::Running)
1267 .unwrap();
1268
1269 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1270 assert_eq!(updated.status, JobState::Running);
1271 }
1272
1273 #[test]
1274 fn update_status_transitions_running_to_succeeded() {
1275 let dir = tempdir().unwrap();
1276 let store = test_store(dir.path().join("tx.db"));
1277 let tx = store.record(queued_transaction()).unwrap();
1278
1279 store
1280 .update_status(&tx.transaction_id, JobState::Running)
1281 .unwrap();
1282 store
1283 .update_status(&tx.transaction_id, JobState::Succeeded)
1284 .unwrap();
1285
1286 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1287 assert_eq!(updated.status, JobState::Succeeded);
1288 }
1289
1290 #[test]
1291 fn update_status_for_unknown_id_returns_not_found() {
1292 let dir = tempdir().unwrap();
1293 let store = test_store(dir.path().join("tx.db"));
1294
1295 let result = store.update_status("does-not-exist", JobState::Running);
1296 assert!(
1297 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
1298 "expected NotFound, got: {result:?}"
1299 );
1300 }
1301
1302 #[test]
1303 fn update_status_leaves_other_fields_intact() {
1304 let dir = tempdir().unwrap();
1305 let store = test_store(dir.path().join("tx.db"));
1306 let tx = store.record(queued_transaction()).unwrap();
1307
1308 store
1309 .update_status(&tx.transaction_id, JobState::Running)
1310 .unwrap();
1311 store
1312 .update_status(&tx.transaction_id, JobState::Failed)
1313 .unwrap();
1314
1315 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1316 assert_eq!(updated.action_name, "UpdateSystem");
1317 assert_eq!(updated.risk_level, RiskLevel::High);
1318 assert_eq!(updated.status, JobState::Failed);
1319 }
1320
1321 #[test]
1330 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
1331 let dir = tempdir().unwrap();
1332 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1333 let tx = store.record(queued_transaction()).unwrap();
1334 store
1335 .update_status(&tx.transaction_id, JobState::Running)
1336 .unwrap();
1337
1338 let store_a = std::sync::Arc::clone(&store);
1339 let id_a = tx.transaction_id.clone();
1340 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
1341
1342 let store_b = std::sync::Arc::clone(&store);
1343 let id_b = tx.transaction_id.clone();
1344 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
1345
1346 let succeed_result = succeed.join().unwrap();
1347 let fail_result = fail.join().unwrap();
1348
1349 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
1351 .into_iter()
1352 .filter(|ok| *ok)
1353 .count();
1354 assert_eq!(
1355 oks, 1,
1356 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
1357 fail={fail_result:?}"
1358 );
1359
1360 let loser = if succeed_result.is_ok() {
1376 &fail_result
1377 } else {
1378 &succeed_result
1379 };
1380 match loser {
1381 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
1382 assert_eq!(id, &tx.transaction_id);
1383 }
1384 Err(TransactionStoreError::InvalidTransition { from, to }) => {
1385 let winner = if succeed_result.is_ok() {
1387 JobState::Succeeded
1388 } else {
1389 JobState::Failed
1390 };
1391 assert_eq!(
1392 *from, winner,
1393 "the loser must have observed the winner's committed status"
1394 );
1395 assert_ne!(*to, winner, "the loser must be the other transition");
1396 }
1397 other => panic!(
1398 "the losing transition must be refused with either \
1399 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
1400 ),
1401 }
1402
1403 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
1406 if succeed_result.is_ok() {
1407 assert_eq!(final_status, JobState::Succeeded);
1408 } else {
1409 assert_eq!(final_status, JobState::Failed);
1410 }
1411 }
1412
1413 #[test]
1414 fn revoke_unconsumed_approval_removes_an_unused_receipt() {
1415 let dir = tempdir().unwrap();
1418 let store = test_store(dir.path().join("tx.db"));
1419 let tx = store.record(queued_transaction()).unwrap();
1420 let receipt = store
1421 .approve_transaction(&tx.transaction_id)
1422 .unwrap()
1423 .expect("approved");
1424 let digest = audit_chain::approval_receipt_digest(&receipt);
1425
1426 assert!(
1427 store
1428 .revoke_unconsumed_approval(&tx.transaction_id)
1429 .unwrap(),
1430 "an unconsumed approval must be revocable"
1431 );
1432 assert!(
1433 !store
1434 .claim_approved_for_execution(&tx.transaction_id, &digest)
1435 .unwrap(),
1436 "a revoked receipt must no longer be claimable"
1437 );
1438 }
1439
1440 #[test]
1441 fn revoke_cannot_retract_an_approval_that_was_already_used() {
1442 let dir = tempdir().unwrap();
1446 let store = test_store(dir.path().join("tx.db"));
1447 let tx = store.record(queued_transaction()).unwrap();
1448 let receipt = store
1449 .approve_transaction(&tx.transaction_id)
1450 .unwrap()
1451 .expect("approved");
1452 let digest = audit_chain::approval_receipt_digest(&receipt);
1453 assert!(store
1454 .claim_approved_for_execution(&tx.transaction_id, &digest)
1455 .unwrap());
1456
1457 assert!(
1458 !store
1459 .revoke_unconsumed_approval(&tx.transaction_id)
1460 .unwrap(),
1461 "a consumed approval must not be revocable"
1462 );
1463 assert_eq!(
1464 store.get(&tx.transaction_id).unwrap().unwrap().status,
1465 JobState::Running,
1466 "a failed revocation must not disturb the running transaction"
1467 );
1468 }
1469
1470 #[test]
1471 fn approve_refuses_a_transaction_that_is_no_longer_queued() {
1472 let dir = tempdir().unwrap();
1476 let store = test_store(dir.path().join("tx.db"));
1477 let tx = store.record(queued_transaction()).unwrap();
1478 let receipt = store
1479 .approve_transaction(&tx.transaction_id)
1480 .unwrap()
1481 .expect("approved");
1482 let digest = audit_chain::approval_receipt_digest(&receipt);
1483 assert!(store
1484 .claim_approved_for_execution(&tx.transaction_id, &digest)
1485 .unwrap());
1486
1487 assert!(
1489 store
1490 .approve_transaction(&tx.transaction_id)
1491 .unwrap()
1492 .is_none(),
1493 "a Running transaction must not be approvable"
1494 );
1495
1496 store
1498 .update_status(&tx.transaction_id, JobState::Succeeded)
1499 .unwrap();
1500 assert!(
1501 store
1502 .approve_transaction(&tx.transaction_id)
1503 .unwrap()
1504 .is_none(),
1505 "a completed transaction must not be approvable"
1506 );
1507 }
1508
1509 #[test]
1510 fn only_one_of_two_concurrent_claims_can_execute() {
1511 let dir = tempdir().unwrap();
1516 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1517 let tx = store.record(queued_transaction()).unwrap();
1518 let receipt = store
1519 .approve_transaction(&tx.transaction_id)
1520 .unwrap()
1521 .expect("approved");
1522 let digest = audit_chain::approval_receipt_digest(&receipt);
1523
1524 let mut handles = Vec::new();
1525 for _ in 0..2 {
1526 let store = std::sync::Arc::clone(&store);
1527 let id = tx.transaction_id.clone();
1528 let digest = digest.clone();
1529 handles.push(std::thread::spawn(move || {
1530 store.claim_approved_for_execution(&id, &digest)
1531 }));
1532 }
1533 let claims: Vec<bool> = handles
1534 .into_iter()
1535 .map(|h| h.join().unwrap().unwrap_or(false))
1536 .collect();
1537
1538 assert_eq!(
1539 claims.iter().filter(|c| **c).count(),
1540 1,
1541 "exactly one claim may win: {claims:?}"
1542 );
1543 assert_eq!(
1544 store.get(&tx.transaction_id).unwrap().unwrap().status,
1545 JobState::Running
1546 );
1547 }
1548
1549 #[test]
1550 fn cancel_queued_cancels_a_queued_transaction_once() {
1551 let dir = tempdir().unwrap();
1552 let store = test_store(dir.path().join("tx.db"));
1553 let tx = store.record(queued_transaction()).unwrap();
1554
1555 assert!(
1556 store.cancel_queued(&tx.transaction_id).unwrap(),
1557 "a queued transaction is cancelable"
1558 );
1559 assert_eq!(
1560 store.get(&tx.transaction_id).unwrap().unwrap().status,
1561 JobState::Canceled
1562 );
1563 assert!(
1564 !store.cancel_queued(&tx.transaction_id).unwrap(),
1565 "an already-canceled transaction is not cancelable again"
1566 );
1567 assert!(
1568 !store.cancel_queued("no-such-transaction").unwrap(),
1569 "a missing transaction is not cancelable"
1570 );
1571 }
1572
1573 #[test]
1574 fn cancel_queued_refuses_a_running_transaction() {
1575 let dir = tempdir().unwrap();
1578 let store = test_store(dir.path().join("tx.db"));
1579 let tx = store.record(queued_transaction()).unwrap();
1580 let receipt = store
1581 .approve_transaction(&tx.transaction_id)
1582 .unwrap()
1583 .expect("approved");
1584 let digest = audit_chain::approval_receipt_digest(&receipt);
1585 assert!(store
1586 .claim_approved_for_execution(&tx.transaction_id, &digest)
1587 .unwrap());
1588
1589 assert!(
1590 !store.cancel_queued(&tx.transaction_id).unwrap(),
1591 "a running transaction must not be cancelable"
1592 );
1593 assert_eq!(
1594 store.get(&tx.transaction_id).unwrap().unwrap().status,
1595 JobState::Running,
1596 "cancel must not disturb a running transaction"
1597 );
1598 }
1599
1600 #[test]
1601 fn approved_receipt_is_required_and_consumed_once() {
1602 let dir = tempdir().unwrap();
1603 let store = test_store(dir.path().join("tx.db"));
1604 let tx = store.record(queued_transaction()).unwrap();
1605
1606 assert!(
1607 !store
1608 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
1609 .unwrap(),
1610 "an unapproved preview must not execute"
1611 );
1612 let receipt = store
1613 .approve_transaction(&tx.transaction_id)
1614 .unwrap()
1615 .expect("first approval must succeed");
1616 let digest = audit_chain::approval_receipt_digest(&receipt);
1617 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
1618 assert!(
1619 store
1620 .approve_transaction(&tx.transaction_id)
1621 .unwrap()
1622 .is_none(),
1623 "approval is immutable once issued"
1624 );
1625 assert!(
1626 !store
1627 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
1628 .unwrap(),
1629 "a forged receipt must not execute"
1630 );
1631 assert!(
1632 store
1633 .claim_approved_for_execution(&tx.transaction_id, &digest)
1634 .unwrap(),
1635 "the exact approved receipt must execute"
1636 );
1637 assert!(
1638 !store
1639 .claim_approved_for_execution(&tx.transaction_id, &digest)
1640 .unwrap(),
1641 "the receipt must be one-time"
1642 );
1643 }
1644
1645 #[test]
1646 fn approval_commitment_is_covered_by_the_signed_chain() {
1647 let dir = tempdir().unwrap();
1648 let store = test_store(dir.path().join("tx.db"));
1649 let tx = store.record(queued_transaction()).unwrap();
1650 assert!(tx.approval_id.is_some());
1651
1652 store
1653 .connection()
1654 .unwrap()
1655 .execute(
1656 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
1657 params![tx.transaction_id],
1658 )
1659 .unwrap();
1660
1661 let key = AuditKey::from_bytes(vec![0x42; 32]);
1662 assert!(matches!(
1663 store.verify_audit_chain(&key).unwrap(),
1664 VerifyOutcome::Broken { .. }
1665 ));
1666 }
1667
1668 #[test]
1669 fn approve_rejects_a_forged_commitment_at_runtime() {
1670 let dir = tempdir().unwrap();
1674 let store = test_store(dir.path().join("tx.db"));
1675 let tx = store.record(queued_transaction()).unwrap();
1676
1677 store
1678 .connection()
1679 .unwrap()
1680 .execute(
1681 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
1682 params![tx.transaction_id],
1683 )
1684 .unwrap();
1685
1686 let err = store
1687 .approve_transaction(&tx.transaction_id)
1688 .expect_err("a forged commitment must be rejected, not approved");
1689 assert!(
1690 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
1691 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
1692 );
1693 }
1694
1695 #[test]
1696 fn stale_iso_timestamp_cannot_be_approved() {
1697 let dir = tempdir().unwrap();
1698 let store = test_store(dir.path().join("tx.db"));
1699 let tx = store.record(queued_transaction()).unwrap();
1700 let conn = store.connection().unwrap();
1701 conn.execute(
1702 "UPDATE transactions \
1703 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
1704 WHERE transaction_id = ?1",
1705 params![tx.transaction_id],
1706 )
1707 .unwrap();
1708
1709 assert!(
1710 store
1711 .approve_transaction(&tx.transaction_id)
1712 .unwrap()
1713 .is_none(),
1714 "a production-format timestamp outside the TTL must not be approved"
1715 );
1716 }
1717
1718 #[test]
1719 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
1720 let dir = tempdir().unwrap();
1725 let store = test_store(dir.path().join("tx.db"));
1726 let tx = store.record(queued_transaction()).unwrap();
1727 let receipt = store
1728 .approve_transaction(&tx.transaction_id)
1729 .unwrap()
1730 .expect("a fresh approval succeeds");
1731 let digest = audit_chain::approval_receipt_digest(&receipt);
1732
1733 let conn = store.connection().unwrap();
1735 conn.execute(
1736 "UPDATE transactions \
1737 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
1738 WHERE transaction_id = ?1",
1739 params![tx.transaction_id],
1740 )
1741 .unwrap();
1742
1743 assert!(
1744 !store
1745 .claim_approved_for_execution(&tx.transaction_id, &digest)
1746 .unwrap(),
1747 "an approval aged past the TTL must not be claimable at execute time"
1748 );
1749 assert_eq!(
1750 store.get(&tx.transaction_id).unwrap().unwrap().status,
1751 JobState::Queued,
1752 "a TTL-expired claim must leave the transaction Queued, never Running"
1753 );
1754 }
1755
1756 #[test]
1757 fn cleanup_stale_queued_cancels_old_records() {
1758 let dir = tempdir().unwrap();
1759 let store = test_store(dir.path().join("tx.db"));
1760
1761 let fresh = store.record(queued_transaction()).unwrap();
1763 let stale = store.record(queued_transaction()).unwrap();
1764
1765 let conn = store.connection().unwrap();
1767 conn.execute(
1768 "UPDATE transactions \
1769 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
1770 WHERE transaction_id = ?1",
1771 params![stale.transaction_id],
1772 )
1773 .unwrap();
1774
1775 let canceled = store.cleanup_stale_queued().unwrap();
1776 assert_eq!(canceled, 1, "only the stale record should be canceled");
1777
1778 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
1780 assert_eq!(stale_record.status, JobState::Canceled);
1781
1782 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
1784 assert_eq!(fresh_record.status, JobState::Queued);
1785 }
1786
1787 #[test]
1790 fn update_status_rejects_queued_to_succeeded() {
1791 let dir = tempdir().unwrap();
1792 let store = test_store(dir.path().join("tx.db"));
1793 let tx = store.record(queued_transaction()).unwrap();
1794
1795 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
1796 assert!(
1797 matches!(
1798 result,
1799 Err(TransactionStoreError::InvalidTransition {
1800 from: JobState::Queued,
1801 to: JobState::Succeeded,
1802 })
1803 ),
1804 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
1805 );
1806 }
1807
1808 #[test]
1809 fn update_status_rejects_succeeded_to_running() {
1810 let dir = tempdir().unwrap();
1811 let store = test_store(dir.path().join("tx.db"));
1812 let tx = store.record(queued_transaction()).unwrap();
1813
1814 store
1815 .update_status(&tx.transaction_id, JobState::Running)
1816 .unwrap();
1817 store
1818 .update_status(&tx.transaction_id, JobState::Succeeded)
1819 .unwrap();
1820
1821 let result = store.update_status(&tx.transaction_id, JobState::Running);
1822 assert!(
1823 matches!(
1824 result,
1825 Err(TransactionStoreError::InvalidTransition {
1826 from: JobState::Succeeded,
1827 to: JobState::Running,
1828 })
1829 ),
1830 "Succeeded -> Running must be rejected (terminal state): {result:?}"
1831 );
1832 }
1833
1834 #[test]
1835 fn update_status_accepts_running_to_failed() {
1836 let dir = tempdir().unwrap();
1837 let store = test_store(dir.path().join("tx.db"));
1838 let tx = store.record(queued_transaction()).unwrap();
1839
1840 store
1841 .update_status(&tx.transaction_id, JobState::Running)
1842 .unwrap();
1843 store
1844 .update_status(&tx.transaction_id, JobState::Failed)
1845 .unwrap();
1846
1847 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1848 assert_eq!(updated.status, JobState::Failed);
1849 }
1850
1851 #[test]
1852 fn update_status_accepts_running_to_rolled_back() {
1853 let dir = tempdir().unwrap();
1854 let store = test_store(dir.path().join("tx.db"));
1855 let tx = store.record(queued_transaction()).unwrap();
1856
1857 store
1858 .update_status(&tx.transaction_id, JobState::Running)
1859 .unwrap();
1860 store
1861 .update_status(&tx.transaction_id, JobState::RolledBack)
1862 .unwrap();
1863
1864 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1865 assert_eq!(updated.status, JobState::RolledBack);
1866 }
1867
1868 #[test]
1871 fn list_transactions_returns_empty_for_fresh_store() {
1872 let dir = tempdir().unwrap();
1873 let store = test_store(dir.path().join("tx.db"));
1874 let results = store.list_transactions(10, None, None, None).unwrap();
1875 assert!(results.is_empty());
1876 }
1877
1878 #[test]
1879 fn list_transactions_returns_all_records_ordered_by_newest_first() {
1880 let dir = tempdir().unwrap();
1881 let store = test_store(dir.path().join("tx.db"));
1882 store.record(queued_transaction()).unwrap();
1883
1884 let mut second = queued_transaction();
1885 second.action_name = "GetDiskUsage".to_string();
1886 second.risk_level = RiskLevel::Low;
1887 store.record(second).unwrap();
1888
1889 let results = store.list_transactions(10, None, None, None).unwrap();
1890 assert_eq!(results.len(), 2);
1891 assert_eq!(results[0].action_name, "GetDiskUsage");
1893 assert_eq!(results[1].action_name, "UpdateSystem");
1894 }
1895
1896 #[test]
1897 fn list_history_populates_created_at_and_risk_level() {
1898 let dir = tempdir().unwrap();
1899 let store = test_store(dir.path().join("tx.db"));
1900 store.record(queued_transaction()).unwrap();
1901
1902 let entries = store.list_history(10, None, None, None).unwrap();
1903 assert_eq!(entries.len(), 1);
1904 let entry = &entries[0];
1905 assert_eq!(entry.action_name, "UpdateSystem");
1906 assert_eq!(entry.risk_level, RiskLevel::High);
1907 assert_eq!(entry.status, JobState::Queued);
1908 assert!(
1909 !entry.created_at.is_empty(),
1910 "created_at must be populated from the stored row, not left blank"
1911 );
1912 }
1913
1914 #[test]
1915 fn list_history_applies_the_same_filters_as_list_transactions() {
1916 let dir = tempdir().unwrap();
1917 let store = test_store(dir.path().join("tx.db"));
1918 store.record(queued_transaction()).unwrap();
1919 let mut low = queued_transaction();
1920 low.action_name = "GetDiskUsage".to_string();
1921 low.risk_level = RiskLevel::Low;
1922 store.record(low).unwrap();
1923
1924 let only = store
1925 .list_history(10, None, Some("GetDiskUsage"), None)
1926 .unwrap();
1927 assert_eq!(only.len(), 1);
1928 assert_eq!(only[0].action_name, "GetDiskUsage");
1929 }
1930
1931 #[test]
1932 fn list_transactions_respects_limit() {
1933 let dir = tempdir().unwrap();
1934 let store = test_store(dir.path().join("tx.db"));
1935 for _ in 0..5 {
1936 store.record(queued_transaction()).unwrap();
1937 }
1938 let results = store.list_transactions(3, None, None, None).unwrap();
1939 assert_eq!(results.len(), 3);
1940 }
1941
1942 #[test]
1943 fn list_transactions_filters_by_status() {
1944 let dir = tempdir().unwrap();
1945 let store = test_store(dir.path().join("tx.db"));
1946 let tx = store.record(queued_transaction()).unwrap();
1947 store
1948 .update_status(&tx.transaction_id, JobState::Running)
1949 .unwrap();
1950 store
1951 .update_status(&tx.transaction_id, JobState::Succeeded)
1952 .unwrap();
1953
1954 store.record(queued_transaction()).unwrap();
1956
1957 let succeeded = store
1958 .list_transactions(10, Some("succeeded"), None, None)
1959 .unwrap();
1960 assert_eq!(succeeded.len(), 1);
1961 assert_eq!(succeeded[0].status, JobState::Succeeded);
1962
1963 let queued = store
1964 .list_transactions(10, Some("queued"), None, None)
1965 .unwrap();
1966 assert_eq!(queued.len(), 1);
1967 assert_eq!(queued[0].status, JobState::Queued);
1968 }
1969
1970 #[test]
1971 fn list_transactions_filters_by_action_name() {
1972 let dir = tempdir().unwrap();
1973 let store = test_store(dir.path().join("tx.db"));
1974 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
1977 disk.action_name = "GetDiskUsage".to_string();
1978 store.record(disk).unwrap();
1979
1980 let results = store
1981 .list_transactions(10, None, Some("GetDiskUsage"), None)
1982 .unwrap();
1983 assert_eq!(results.len(), 1);
1984 assert_eq!(results[0].action_name, "GetDiskUsage");
1985 }
1986
1987 #[test]
1988 fn list_transactions_filters_by_since_hours() {
1989 let dir = tempdir().unwrap();
1990 let store = test_store(dir.path().join("tx.db"));
1991
1992 let old = store.record(queued_transaction()).unwrap();
1994 let conn = store.connection().unwrap();
1995 conn.execute(
1996 "UPDATE transactions \
1997 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
1998 WHERE transaction_id = ?1",
1999 params![old.transaction_id],
2000 )
2001 .unwrap();
2002
2003 store.record(queued_transaction()).unwrap();
2005
2006 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
2008 assert_eq!(results.len(), 1);
2009
2010 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
2012 assert_eq!(results.len(), 2);
2013 }
2014
2015 #[test]
2016 fn list_transactions_rejects_invalid_status_filter() {
2017 let dir = tempdir().unwrap();
2018 let store = test_store(dir.path().join("tx.db"));
2019 store.record(queued_transaction()).unwrap();
2020 let result = store.list_transactions(10, Some("bogus"), None, None);
2021 assert!(result.is_err(), "invalid status filter should return error");
2022 }
2023
2024 #[test]
2032 fn record_emits_one_watermark_per_entry() {
2033 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2034 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2035
2036 let dir = tempdir().unwrap();
2037 let store = test_store(dir.path().join("tx.db"));
2038 store.record(queued_transaction()).unwrap();
2039
2040 let calls = crate::audit_watermark::take_watermarks(&sink);
2041 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
2042 }
2043
2044 #[test]
2046 fn record_previewed_emits_one_watermark() {
2047 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2048 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2049
2050 let dir = tempdir().unwrap();
2051 let store = test_store(dir.path().join("tx.db"));
2052 let preview = PreviewEnvelope {
2053 summary: "Upgrade the system".to_string(),
2054 risk_level: RiskLevel::High,
2055 current_state: serde_json::Value::Null,
2056 proposed_change: serde_json::Value::Null,
2057 expected_side_effects: vec![],
2058 reboot_required: false,
2059 rollback_available: false,
2060 warnings: vec![],
2061 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
2062 };
2063 store
2064 .record_previewed(queued_transaction(), preview)
2065 .unwrap();
2066
2067 let calls = crate::audit_watermark::take_watermarks(&sink);
2068 assert_eq!(
2069 calls.len(),
2070 1,
2071 "expected exactly 1 watermark per record_previewed()"
2072 );
2073 }
2074
2075 #[test]
2077 fn watermark_seq_and_hash_match_chain_row() {
2078 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2079 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2080
2081 let dir = tempdir().unwrap();
2082 let store = test_store(dir.path().join("tx.db"));
2083 store.record(queued_transaction()).unwrap();
2084
2085 let rows = store.fetch_chain_rows().unwrap();
2086 assert_eq!(rows.len(), 1);
2087 let row = &rows[0];
2088
2089 let calls = crate::audit_watermark::take_watermarks(&sink);
2090 assert_eq!(calls.len(), 1);
2091 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
2092 assert_eq!(
2093 calls[0].chain_hash_hex, row.chain_hash,
2094 "watermark chain_hash_hex must match stored chain_hash"
2095 );
2096 }
2097
2098 #[test]
2100 fn multiple_records_produce_one_watermark_each() {
2101 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2102 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2103
2104 let dir = tempdir().unwrap();
2105 let store = test_store(dir.path().join("tx.db"));
2106 for _ in 0..3 {
2107 store.record(queued_transaction()).unwrap();
2108 }
2109
2110 let calls = crate::audit_watermark::take_watermarks(&sink);
2111 assert_eq!(calls.len(), 3, "one watermark per record call");
2112 assert_eq!(calls[0].seq, 1);
2113 assert_eq!(calls[1].seq, 2);
2114 assert_eq!(calls[2].seq, 3);
2115 }
2116
2117 #[test]
2131 fn no_watermark_emitted_before_any_record() {
2132 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2133 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
2134
2135 let dir = tempdir().unwrap();
2136 let _store = test_store(dir.path().join("tx.db"));
2137
2138 let calls = crate::audit_watermark::take_watermarks(&sink);
2140 assert!(
2141 calls.is_empty(),
2142 "no watermark must be emitted without a record() call"
2143 );
2144 }
2145}