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 approval_id: Option<String>,
52 pub summary: String,
72 pub warnings: Vec<String>,
73}
74
75#[derive(Clone, Debug)]
76pub struct TransactionStore {
77 path: PathBuf,
78 audit_key: Option<Arc<AuditKey>>,
81}
82
83#[derive(Clone, Debug, PartialEq)]
84pub struct RecordedPreviewedTransaction {
85 pub transaction: TransactionRecord,
86 pub preview: PreviewEnvelope,
87}
88
89struct InsertedTransaction {
90 record: TransactionRecord,
91 seq: u64,
92 chain_hash: String,
93}
94
95#[derive(Debug, thiserror::Error)]
96pub enum TransactionStoreError {
97 #[error("io error: {0}")]
98 Io(#[from] std::io::Error),
99
100 #[error("sqlite error: {0}")]
101 Sqlite(#[from] rusqlite::Error),
102
103 #[error("json error: {0}")]
104 Json(#[from] serde_json::Error),
105
106 #[error("database invariant violation: {0}")]
107 DatabaseInvariant(String),
108
109 #[error("transaction not found: {0}")]
110 NotFound(String),
111
112 #[error("invalid transition from {from:?} to {to:?}")]
113 InvalidTransition { from: JobState, to: JobState },
114
115 #[error("audit chain misconfiguration: {0}")]
116 AuditChainMissing(&'static str),
117}
118
119impl TransactionStore {
120 pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
124 let path = path.as_ref().to_path_buf();
125 if let Some(parent) = path.parent() {
126 ensure_private_dir(parent)?;
127 }
128
129 let store = Self {
130 path,
131 audit_key: None,
132 };
133 store.initialize()?;
134 Ok(store)
135 }
136
137 pub fn open(path: impl AsRef<Path>) -> Result<Self, TransactionStoreError> {
144 let db_path = path.as_ref();
145 let key_path = std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
146 .map(PathBuf::from)
147 .unwrap_or_else(|_| {
148 db_path
149 .parent()
150 .unwrap_or_else(|| Path::new("."))
151 .join("audit-key")
152 });
153 let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
154 TransactionStoreError::Io(std::io::Error::new(
155 std::io::ErrorKind::InvalidData,
156 format!("audit key load failed: {e}"),
157 ))
158 })?;
159 Self::open_with_key(path, Arc::new(key))
160 }
161
162 pub fn open_with_key(
165 path: impl AsRef<Path>,
166 audit_key: Arc<AuditKey>,
167 ) -> Result<Self, TransactionStoreError> {
168 let path = path.as_ref().to_path_buf();
169 if let Some(parent) = path.parent() {
170 ensure_private_dir(parent)?;
171 }
172
173 let store = Self {
174 path,
175 audit_key: Some(audit_key),
176 };
177 store.initialize()?;
178 Ok(store)
179 }
180
181 pub fn record(
182 &self,
183 transaction: NewTransaction,
184 ) -> Result<TransactionRecord, TransactionStoreError> {
185 let key = self
186 .audit_key
187 .as_ref()
188 .ok_or(TransactionStoreError::AuditChainMissing(
189 "this TransactionStore was opened read-only; cannot record",
190 ))?;
191 let mut conn = self.connection()?;
192 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
197 let transaction_id = Uuid::new_v4().to_string();
198 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
199 tx.commit()?;
200 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
201 Ok(inserted.record)
202 }
203
204 pub fn record_previewed(
205 &self,
206 transaction: NewTransaction,
207 preview: PreviewEnvelope,
208 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
209 let key = self
210 .audit_key
211 .as_ref()
212 .ok_or(TransactionStoreError::AuditChainMissing(
213 "this TransactionStore was opened read-only; cannot record",
214 ))?;
215 let mut conn = self.connection()?;
216 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
221 let transaction_id = Uuid::new_v4().to_string();
222 let inserted = Self::insert_transaction(&tx, key, &transaction_id, transaction)?;
223 Self::insert_preview(&tx, &inserted.record.transaction_id, &preview)?;
224 tx.commit()?;
225 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
226
227 Ok(RecordedPreviewedTransaction {
228 transaction: inserted.record,
229 preview,
230 })
231 }
232
233 pub fn get(
234 &self,
235 transaction_id: &str,
236 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
237 let conn = self.connection()?;
238 let mut stmt = conn.prepare(
239 "SELECT
240 transaction_id,
241 request_id,
242 request_hash,
243 action_name,
244 risk_level,
245 status,
246 approval_id,
247 summary,
248 warnings_json
249 FROM transactions
250 WHERE transaction_id = ?1",
251 )?;
252 let mut rows = stmt.query(params![transaction_id])?;
253 if let Some(row) = rows.next()? {
254 Ok(Some(row_to_record(row)?))
255 } else {
256 Ok(None)
257 }
258 }
259
260 pub fn get_preview(
261 &self,
262 transaction_id: &str,
263 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
264 let conn = self.connection()?;
265 let mut stmt = conn.prepare(
266 "SELECT preview_json
267 FROM transaction_previews
268 WHERE transaction_id = ?1",
269 )?;
270 let mut rows = stmt.query(params![transaction_id])?;
271 if let Some(row) = rows.next()? {
272 let preview_json: String = row.get(0)?;
273 Ok(Some(serde_json::from_str(&preview_json)?))
274 } else {
275 Ok(None)
276 }
277 }
278
279 pub fn update_status(
280 &self,
281 transaction_id: &str,
282 new_status: JobState,
283 ) -> Result<(), TransactionStoreError> {
284 let conn = self.connection()?;
285
286 let current_status: String = conn
288 .query_row(
289 "SELECT status FROM transactions WHERE transaction_id = ?1",
290 params![transaction_id],
291 |row| row.get(0),
292 )
293 .map_err(|e| match e {
294 rusqlite::Error::QueryReturnedNoRows => {
295 TransactionStoreError::NotFound(transaction_id.to_string())
296 }
297 other => TransactionStoreError::Sqlite(other),
298 })?;
299
300 let current: JobState = deserialize_field(¤t_status)?;
301 if !crate::jobs::allowed_transition(¤t, &new_status) {
302 return Err(TransactionStoreError::InvalidTransition {
303 from: current,
304 to: new_status,
305 });
306 }
307
308 conn.execute(
309 "UPDATE transactions SET status = ?1 WHERE transaction_id = ?2",
310 params![serialize_field(&new_status)?, transaction_id],
311 )?;
312 Ok(())
313 }
314
315 pub fn approve_transaction(
317 &self,
318 transaction_id: &str,
319 ) -> Result<Option<String>, TransactionStoreError> {
320 let key = self
321 .audit_key
322 .as_ref()
323 .ok_or(TransactionStoreError::AuditChainMissing(
324 "this TransactionStore was opened read-only; cannot approve",
325 ))?;
326 let Some(record) = self.get(transaction_id)? else {
327 return Ok(None);
328 };
329 let receipt = key.approval_receipt(transaction_id, &record.request_hash);
330 let receipt_digest = audit_chain::approval_receipt_digest(&receipt);
331 let Some(committed_digest) = record.approval_id.as_deref() else {
332 return Err(TransactionStoreError::DatabaseInvariant(format!(
333 "transaction {transaction_id} has no signed approval commitment"
334 )));
335 };
336 if !bool::from(receipt_digest.as_bytes().ct_eq(committed_digest.as_bytes())) {
337 return Err(TransactionStoreError::DatabaseInvariant(format!(
338 "transaction {transaction_id} approval commitment does not match its signed preview"
339 )));
340 }
341
342 let conn = self.connection()?;
343 let queued_json = serialize_field(&JobState::Queued)?;
344 let rows_affected = conn.execute(
345 &format!(
346 "INSERT INTO transaction_approvals (transaction_id, receipt_digest) \
347 SELECT transaction_id, ?1 FROM transactions \
348 WHERE transaction_id = ?2 \
349 AND status = ?3 \
350 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
351 AND NOT EXISTS ( \
352 SELECT 1 FROM transaction_approvals WHERE transaction_id = ?2 \
353 )"
354 ),
355 params![receipt_digest, transaction_id, queued_json],
356 )?;
357 Ok((rows_affected > 0).then_some(receipt))
358 }
359
360 pub fn revoke_unconsumed_approval(
363 &self,
364 transaction_id: &str,
365 ) -> Result<bool, TransactionStoreError> {
366 let conn = self.connection()?;
367 let rows_affected = conn.execute(
368 "DELETE FROM transaction_approvals \
369 WHERE transaction_id = ?1 AND consumed_at IS NULL",
370 params![transaction_id],
371 )?;
372 Ok(rows_affected > 0)
373 }
374
375 pub fn claim_approved_for_execution(
377 &self,
378 transaction_id: &str,
379 receipt_digest: &str,
380 ) -> Result<bool, TransactionStoreError> {
381 let mut conn = self.connection()?;
382 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
383 let queued_json = serialize_field(&JobState::Queued)?;
384 let running_json = serialize_field(&JobState::Running)?;
385 let rows_affected = tx.execute(
386 &format!(
387 "UPDATE transactions SET status = ?1 \
388 WHERE transaction_id = ?2 \
389 AND status = ?3 \
390 AND julianday(created_at) > julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes') \
391 AND EXISTS ( \
392 SELECT 1 FROM transaction_approvals \
393 WHERE transaction_id = ?2 \
394 AND receipt_digest = ?4 \
395 AND consumed_at IS NULL \
396 )"
397 ),
398 params![running_json, transaction_id, queued_json, receipt_digest],
399 )?;
400 if rows_affected > 0 {
401 tx.execute(
402 "UPDATE transaction_approvals \
403 SET consumed_at = datetime('now') \
404 WHERE transaction_id = ?1 AND consumed_at IS NULL",
405 params![transaction_id],
406 )?;
407 }
408 tx.commit()?;
409 Ok(rows_affected > 0)
410 }
411
412 pub fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
427 let conn = self.connection()?;
428 let canceled_json = serialize_field(&JobState::Canceled)?;
429 let queued_json = serialize_field(&JobState::Queued)?;
430 let rows_affected = conn.execute(
431 &format!(
432 "UPDATE transactions SET status = ?1 \
433 WHERE status = ?2 \
434 AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')"
435 ),
436 params![canceled_json, queued_json],
437 )?;
438 Ok(rows_affected as u64)
439 }
440
441 pub fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
450 let conn = self.connection()?;
451 let canceled_json = serialize_field(&JobState::Canceled)?;
452 let queued_json = serialize_field(&JobState::Queued)?;
453 let rows_affected = conn.execute(
454 "UPDATE transactions SET status = ?1 \
455 WHERE transaction_id = ?2 AND status = ?3",
456 params![canceled_json, transaction_id, queued_json],
457 )?;
458 Ok(rows_affected > 0)
459 }
460
461 pub fn list_transactions(
470 &self,
471 limit: u32,
472 status_filter: Option<&str>,
473 action_filter: Option<&str>,
474 since_hours: Option<u32>,
475 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
476 let conn = self.connection()?;
477 let (filter_sql, param_values) =
478 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
479 let sql = format!(
480 "SELECT transaction_id, request_id, request_hash, action_name, \
481 risk_level, status, approval_id, summary, warnings_json \
482 FROM transactions WHERE 1=1{filter_sql}"
483 );
484 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
485 param_values.iter().map(|b| b.as_ref()).collect();
486
487 let mut stmt = conn.prepare(&sql)?;
488 let rows = stmt.query_map(params_ref.as_slice(), |row| Ok(row_to_record(row)))?;
489
490 let mut results = Vec::new();
491 for row in rows {
492 results.push(row??);
493 }
494 Ok(results)
495 }
496
497 pub fn list_history(
502 &self,
503 limit: u32,
504 status_filter: Option<&str>,
505 action_filter: Option<&str>,
506 since_hours: Option<u32>,
507 ) -> Result<Vec<JobHistoryEntry>, TransactionStoreError> {
508 let conn = self.connection()?;
509 let (filter_sql, param_values) =
510 Self::build_history_filter(limit, status_filter, action_filter, since_hours)?;
511 let sql = format!(
512 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
513 FROM transactions WHERE 1=1{filter_sql}"
514 );
515 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
516 param_values.iter().map(|b| b.as_ref()).collect();
517
518 let mut stmt = conn.prepare(&sql)?;
519 let rows = stmt.query_map(params_ref.as_slice(), |row| {
520 Ok((|| {
521 Ok::<JobHistoryEntry, TransactionStoreError>(JobHistoryEntry {
522 transaction_id: row.get(0)?,
523 action_name: row.get(1)?,
524 risk_level: deserialize_field(&row.get::<_, String>(2)?)?,
525 status: deserialize_field(&row.get::<_, String>(3)?)?,
526 summary: row.get(4)?,
527 created_at: row.get(5)?,
528 })
529 })())
530 })?;
531
532 let mut results = Vec::new();
533 for row in rows {
534 results.push(row??);
535 }
536 Ok(results)
537 }
538
539 fn build_history_filter(
544 limit: u32,
545 status_filter: Option<&str>,
546 action_filter: Option<&str>,
547 since_hours: Option<u32>,
548 ) -> Result<(String, Vec<Box<dyn rusqlite::types::ToSql>>), TransactionStoreError> {
549 let mut sql = String::new();
550 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
551
552 if let Some(status) = status_filter {
553 let validated: JobState = deserialize_field(&format!("\"{status}\""))?;
557 let status_json = serialize_field(&validated)?;
558 sql.push_str(" AND status = ?");
559 param_values.push(Box::new(status_json));
560 }
561
562 if let Some(action) = action_filter {
563 sql.push_str(" AND action_name = ?");
564 param_values.push(Box::new(action.to_string()));
565 }
566
567 if let Some(hours) = since_hours {
568 sql.push_str(" AND julianday(created_at) > julianday('now', '-' || ? || ' hours')");
569 param_values.push(Box::new(hours));
570 }
571
572 sql.push_str(" ORDER BY seq DESC LIMIT ?");
573 param_values.push(Box::new(limit.min(100)));
574
575 Ok((sql, param_values))
576 }
577
578 fn connection(&self) -> Result<Connection, TransactionStoreError> {
579 let conn = Connection::open(&self.path)?;
580 conn.pragma_update(None, "journal_mode", "WAL")?;
591 conn.pragma_update(None, "synchronous", "NORMAL")?;
592 conn.pragma_update(None, "foreign_keys", "ON")?;
593 conn.busy_timeout(std::time::Duration::from_secs(5))?;
594 Ok(conn)
595 }
596
597 fn initialize(&self) -> Result<(), TransactionStoreError> {
598 let mut conn = self.connection()?;
599 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
600 tx.execute_batch(
601 "CREATE TABLE IF NOT EXISTS schema_migrations (\
602 version INTEGER PRIMARY KEY,\
603 name TEXT NOT NULL,\
604 applied_at TEXT NOT NULL DEFAULT (datetime('now'))\
605 );",
606 )?;
607 let current: i64 = tx.query_row(
608 "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
609 [],
610 |row| row.get(0),
611 )?;
612 if current > 1 {
613 return Err(TransactionStoreError::DatabaseInvariant(format!(
614 "sqlite schema version {current} is newer than this binary supports (1)"
615 )));
616 }
617 tx.execute_batch(
631 r#"
632 CREATE TABLE IF NOT EXISTS transactions (
633 transaction_id TEXT PRIMARY KEY,
634 request_id TEXT NOT NULL,
635 request_hash TEXT NOT NULL,
636 action_name TEXT NOT NULL,
637 risk_level TEXT NOT NULL,
638 status TEXT NOT NULL,
639 approval_id TEXT,
640 summary TEXT NOT NULL,
641 warnings_json TEXT NOT NULL,
642 created_at TEXT NOT NULL DEFAULT (datetime('now')),
643 seq INTEGER NOT NULL UNIQUE,
644 key_id TEXT NOT NULL,
645 chain_hash TEXT NOT NULL,
646 prev_chain_hash TEXT NOT NULL DEFAULT ''
647 );
648
649 CREATE TABLE IF NOT EXISTS transaction_previews (
650 transaction_id TEXT PRIMARY KEY,
651 preview_json TEXT NOT NULL
652 );
653
654 CREATE TABLE IF NOT EXISTS transaction_approvals (
655 transaction_id TEXT PRIMARY KEY,
656 receipt_digest TEXT NOT NULL,
657 approved_at TEXT NOT NULL DEFAULT (datetime('now')),
658 consumed_at TEXT
659 );
660
661 CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
662 "#,
663 )?;
664 tx.execute(
665 "INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (1, ?1)",
666 params!["initial_audit_schema"],
667 )?;
668 tx.commit()?;
669 Ok(())
670 }
671
672 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
674 let conn = self.connection()?;
675 let mut stmt = conn.prepare(
676 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
677 action_name, risk_level, summary, approval_id, warnings_json, \
678 created_at, prev_chain_hash, chain_hash \
679 FROM transactions ORDER BY seq ASC",
680 )?;
681 let rows = stmt.query_map([], |row| {
682 Ok(ChainRow {
683 seq: row.get::<_, i64>(0)? as u64,
684 key_id: row.get(1)?,
685 transaction_id: row.get(2)?,
686 request_id: row.get(3)?,
687 request_hash: row.get(4)?,
688 action_name: row.get(5)?,
689 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
690 rusqlite::Error::FromSqlConversionFailure(
691 6,
692 rusqlite::types::Type::Text,
693 Box::new(e),
694 )
695 })?,
696 summary: row.get(7)?,
697 approval_id: row.get(8)?,
698 warnings_json: row.get(9)?,
699 created_at: row.get(10)?,
700 prev_chain_hash: row.get(11)?,
701 chain_hash: row.get(12)?,
702 })
703 })?;
704 let mut out = Vec::new();
705 for row in rows {
706 out.push(row?);
707 }
708 Ok(out)
709 }
710
711 pub fn verify_audit_chain(
713 &self,
714 key: &AuditKey,
715 ) -> Result<VerifyOutcome, TransactionStoreError> {
716 let rows = self.fetch_chain_rows()?;
717 Ok(audit_chain::verify_chain(key, &rows))
718 }
719
720 pub fn verify_audit_chain_with_pubkey(
723 &self,
724 verifying_key_hex: &str,
725 ) -> Result<VerifyOutcome, TransactionStoreError> {
726 let rows = self.fetch_chain_rows()?;
727 Ok(audit_chain::verify_chain_with_pubkey(
728 verifying_key_hex,
729 &rows,
730 ))
731 }
732
733 pub fn fetch_chain_row(
736 &self,
737 transaction_id: &str,
738 ) -> Result<Option<ChainRow>, TransactionStoreError> {
739 let conn = self.connection()?;
740 let mut stmt = conn.prepare(
741 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
742 action_name, risk_level, summary, approval_id, warnings_json, \
743 created_at, prev_chain_hash, chain_hash \
744 FROM transactions WHERE transaction_id = ?1",
745 )?;
746 let mut rows = stmt.query(params![transaction_id])?;
747 if let Some(row) = rows.next()? {
748 Ok(Some(ChainRow {
749 seq: row.get::<_, i64>(0)? as u64,
750 key_id: row.get(1)?,
751 transaction_id: row.get(2)?,
752 request_id: row.get(3)?,
753 request_hash: row.get(4)?,
754 action_name: row.get(5)?,
755 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
756 rusqlite::Error::FromSqlConversionFailure(
757 6,
758 rusqlite::types::Type::Text,
759 Box::new(e),
760 )
761 })?,
762 summary: row.get(7)?,
763 approval_id: row.get(8)?,
764 warnings_json: row.get(9)?,
765 created_at: row.get(10)?,
766 prev_chain_hash: row.get(11)?,
767 chain_hash: row.get(12)?,
768 }))
769 } else {
770 Ok(None)
771 }
772 }
773
774 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
778 let prev: Option<(i64, String)> = conn
779 .query_row(
780 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
781 [],
782 |row| Ok((row.get(0)?, row.get(1)?)),
783 )
784 .map(Some)
785 .or_else(|e| match e {
786 rusqlite::Error::QueryReturnedNoRows => Ok(None),
787 other => Err(other),
788 })?;
789 Ok(match prev {
790 Some((seq, hash)) => ((seq as u64) + 1, hash),
791 None => (1, String::new()),
792 })
793 }
794
795 fn insert_transaction(
796 conn: &Connection,
797 key: &AuditKey,
798 transaction_id: &str,
799 transaction: NewTransaction,
800 ) -> Result<InsertedTransaction, TransactionStoreError> {
801 let request_id = transaction.request_id;
802 let request_hash = transaction.request_hash;
803 let action_name = transaction.action_name;
804 let risk_level = transaction.risk_level;
805 let status = JobState::Queued;
807 let approval_id = transaction
808 .approval_id
809 .or_else(|| Some(key.approval_commitment(transaction_id, &request_hash)));
810 let summary = transaction.summary;
811 let warnings = transaction.warnings;
812 let warnings_json = serde_json::to_string(&warnings)?;
813
814 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
817
818 let created_at: String =
822 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
823 row.get(0)
824 })?;
825
826 let key_id = CURRENT_KEY_ID.to_string();
827 let chain_hash = key.chain_hash(
828 &ChainContent {
829 seq,
830 key_id: &key_id,
831 transaction_id,
832 request_id: &request_id,
833 request_hash: &request_hash,
834 action_name: &action_name,
835 risk_level,
836 summary: &summary,
837 approval_id: approval_id.as_deref(),
838 warnings_json: &warnings_json,
839 created_at: &created_at,
840 },
841 &prev_chain_hash,
842 );
843
844 let record = TransactionRecord {
845 transaction_id: transaction_id.to_string(),
846 request_id: request_id.clone(),
847 request_hash: request_hash.clone(),
848 action_name: action_name.clone(),
849 risk_level,
850 status,
851 approval_id: approval_id.clone(),
852 summary: summary.clone(),
853 warnings: warnings.clone(),
854 };
855
856 conn.execute(
857 "INSERT INTO transactions (
858 transaction_id,
859 request_id,
860 request_hash,
861 action_name,
862 risk_level,
863 status,
864 approval_id,
865 summary,
866 warnings_json,
867 created_at,
868 seq,
869 key_id,
870 chain_hash,
871 prev_chain_hash
872 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
873 params![
874 transaction_id,
875 request_id,
876 request_hash,
877 action_name,
878 serialize_field(&risk_level)?,
879 serialize_field(&status)?,
880 approval_id,
881 summary,
882 warnings_json,
883 created_at,
884 seq as i64,
885 key_id,
886 chain_hash,
887 prev_chain_hash,
888 ],
889 )?;
890
891 Ok(InsertedTransaction {
892 record,
893 seq,
894 chain_hash,
895 })
896 }
897
898 fn insert_preview(
899 conn: &Connection,
900 transaction_id: &str,
901 preview: &PreviewEnvelope,
902 ) -> Result<(), TransactionStoreError> {
903 conn.execute(
904 "INSERT INTO transaction_previews (transaction_id, preview_json)
905 VALUES (?1, ?2)",
906 params![transaction_id, serde_json::to_string(preview)?],
907 )?;
908 Ok(())
909 }
910}
911
912fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
919 use std::os::unix::fs::DirBuilderExt;
920 if dir.exists() {
921 return Ok(());
922 }
923 std::fs::DirBuilder::new()
924 .recursive(true)
925 .mode(0o700)
926 .create(dir)
927}
928
929fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
930 Ok(TransactionRecord {
931 transaction_id: row.get(0)?,
932 request_id: row.get(1)?,
933 request_hash: row.get(2)?,
934 action_name: row.get(3)?,
935 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
936 status: deserialize_field(&row.get::<_, String>(5)?)?,
937 approval_id: row.get(6)?,
938 summary: row.get(7)?,
939 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
940 })
941}
942
943fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
944 serde_json::to_string(value)
945}
946
947fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
948 serde_json::from_str(value)
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use std::os::unix::fs::PermissionsExt;
955 use tempfile::tempdir;
956
957 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
961 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
962 TransactionStore::open_with_key(path, key).unwrap()
963 }
964
965 #[test]
968 fn record_writes_audit_chain_columns() {
969 let dir = tempdir().unwrap();
970 let store = test_store(dir.path().join("tx.db"));
971 let _record = store.record(queued_transaction()).unwrap();
972
973 let conn = store.connection().unwrap();
974 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
975 .query_row(
976 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
977 [],
978 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
979 )
980 .unwrap();
981 assert_eq!(seq, 1, "first row gets seq=1");
982 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
983 assert_eq!(prev, "", "first row has empty prev_chain_hash");
984 assert_eq!(
985 chain_hash.len(),
986 audit_chain::HASH_HEX_LEN,
987 "chain_hash is a hex-encoded Ed25519 signature"
988 );
989 }
990
991 #[test]
992 fn sequential_records_produce_chained_hashes() {
993 let dir = tempdir().unwrap();
994 let store = test_store(dir.path().join("tx.db"));
995 store.record(queued_transaction()).unwrap();
996 store.record(queued_transaction()).unwrap();
997 store.record(queued_transaction()).unwrap();
998
999 let rows = store.fetch_chain_rows().unwrap();
1000 assert_eq!(rows.len(), 3);
1001 assert_eq!(rows[0].seq, 1);
1002 assert_eq!(rows[1].seq, 2);
1003 assert_eq!(rows[2].seq, 3);
1004 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1006 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
1007 }
1008
1009 #[test]
1021 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
1022 const WORKERS: usize = 8;
1023 const RECORDS_PER_WORKER: usize = 10;
1024 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
1025
1026 let dir = tempdir().unwrap();
1027 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1028
1029 let mut handles = Vec::with_capacity(WORKERS);
1030 for w in 0..WORKERS {
1031 let store = std::sync::Arc::clone(&store);
1032 handles.push(std::thread::spawn(move || {
1033 for r in 0..RECORDS_PER_WORKER {
1034 let tx = NewTransaction {
1035 request_id: format!("worker-{w}-record-{r}"),
1036 request_hash: format!("hash-{w}-{r}"),
1037 action_name: "GetSystemState".to_string(),
1038 risk_level: RiskLevel::Low,
1039 approval_id: None,
1040 summary: format!("worker {w} record {r}"),
1041 warnings: vec![],
1042 };
1043 store
1044 .record(tx)
1045 .expect("record must succeed under contention");
1046 }
1047 }));
1048 }
1049 for h in handles {
1050 h.join().expect("worker thread did not panic");
1051 }
1052
1053 let key = AuditKey::from_bytes(vec![0x42; 32]);
1055 let outcome = store.verify_audit_chain(&key).unwrap();
1056 match outcome {
1057 VerifyOutcome::Intact { rows_checked } => {
1058 assert_eq!(
1059 rows_checked, TOTAL as u64,
1060 "expected {TOTAL} rows checked, got {rows_checked}"
1061 );
1062 }
1063 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
1064 }
1065
1066 let conn = store.connection().unwrap();
1068 let mut stmt = conn
1069 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
1070 .unwrap();
1071 let seqs: Vec<i64> = stmt
1072 .query_map([], |row| row.get::<_, i64>(0))
1073 .unwrap()
1074 .map(|r| r.unwrap())
1075 .collect();
1076 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
1077 for (i, s) in seqs.iter().enumerate() {
1078 assert_eq!(
1079 *s,
1080 (i as i64) + 1,
1081 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
1082 );
1083 }
1084 }
1085
1086 #[test]
1087 fn verify_audit_chain_intact_after_inserts() {
1088 let dir = tempdir().unwrap();
1089 let store = test_store(dir.path().join("tx.db"));
1090 for _ in 0..3 {
1091 store.record(queued_transaction()).unwrap();
1092 }
1093 let key = AuditKey::from_bytes(vec![0x42; 32]);
1094 let outcome = store.verify_audit_chain(&key).unwrap();
1095 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1096 }
1097
1098 #[test]
1099 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
1100 let dir = tempdir().unwrap();
1101 let store = test_store(dir.path().join("tx.db"));
1102 for _ in 0..3 {
1103 store.record(queued_transaction()).unwrap();
1104 }
1105 let key = AuditKey::from_bytes(vec![0x42; 32]);
1107 let outcome = store
1108 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
1109 .unwrap();
1110 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1111 }
1112
1113 #[test]
1114 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
1115 let dir = tempdir().unwrap();
1116 let store = test_store(dir.path().join("tx.db"));
1117 store.record(queued_transaction()).unwrap();
1118 let other = AuditKey::from_bytes(vec![0x99; 32]);
1120 let outcome = store
1121 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
1122 .unwrap();
1123 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1124 }
1125
1126 #[test]
1127 fn verify_detects_tampered_summary() {
1128 let dir = tempdir().unwrap();
1129 let store = test_store(dir.path().join("tx.db"));
1130 let tx = store.record(queued_transaction()).unwrap();
1131
1132 let conn = store.connection().unwrap();
1135 conn.execute(
1136 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
1137 params!["EVIL CHANGE", tx.transaction_id],
1138 )
1139 .unwrap();
1140
1141 let key = AuditKey::from_bytes(vec![0x42; 32]);
1142 let outcome = store.verify_audit_chain(&key).unwrap();
1143 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1144 }
1145
1146 #[test]
1147 fn status_update_does_not_break_chain() {
1148 let dir = tempdir().unwrap();
1150 let store = test_store(dir.path().join("tx.db"));
1151 let tx = store.record(queued_transaction()).unwrap();
1152 store
1153 .update_status(&tx.transaction_id, JobState::Running)
1154 .unwrap();
1155 store
1156 .update_status(&tx.transaction_id, JobState::Succeeded)
1157 .unwrap();
1158
1159 let key = AuditKey::from_bytes(vec![0x42; 32]);
1160 let outcome = store.verify_audit_chain(&key).unwrap();
1161 assert!(
1162 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
1163 "status mutation must not break the chain (status not in chain content): {outcome:?}"
1164 );
1165 }
1166
1167 #[test]
1168 fn open_read_only_rejects_record() {
1169 let dir = tempdir().unwrap();
1170 let key_path = dir.path().join("audit-key");
1171 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
1172 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1173
1174 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
1175 let result = store.record(queued_transaction());
1176 assert!(matches!(
1177 result,
1178 Err(TransactionStoreError::AuditChainMissing(_))
1179 ));
1180 }
1181
1182 #[test]
1183 fn ensure_private_dir_creates_with_0700_mode() {
1184 let root = tempdir().unwrap();
1185 let target = root.path().join("a/b/c");
1186 ensure_private_dir(&target).unwrap();
1187 assert!(target.is_dir());
1188 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1189 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
1190 }
1191
1192 #[test]
1193 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
1194 let root = tempdir().unwrap();
1195 let target = root.path().join("preexisting");
1196 std::fs::create_dir(&target).unwrap();
1197 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1198 ensure_private_dir(&target).unwrap();
1199 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1201 assert_eq!(mode, 0o755);
1202 }
1203
1204 #[test]
1205 fn open_creates_parent_with_private_mode() {
1206 let root = tempdir().unwrap();
1207 let db_path = root.path().join("nested/dirs/daemon.sqlite");
1208 let _store = test_store(&db_path);
1209 let parent = db_path.parent().unwrap();
1210 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
1211 assert_eq!(mode, 0o700);
1212 }
1213
1214 fn queued_transaction() -> NewTransaction {
1215 NewTransaction {
1216 request_id: "req-1".to_string(),
1217 request_hash: "hash-abc".to_string(),
1218 action_name: "UpdateSystem".to_string(),
1219 risk_level: RiskLevel::High,
1220 approval_id: None,
1221 summary: "Upgrade the system".to_string(),
1222 warnings: vec![],
1223 }
1224 }
1225
1226 #[test]
1227 fn update_status_transitions_queued_to_running() {
1228 let dir = tempdir().unwrap();
1229 let store = test_store(dir.path().join("tx.db"));
1230 let tx = store.record(queued_transaction()).unwrap();
1231
1232 store
1233 .update_status(&tx.transaction_id, JobState::Running)
1234 .unwrap();
1235
1236 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1237 assert_eq!(updated.status, JobState::Running);
1238 }
1239
1240 #[test]
1241 fn update_status_transitions_running_to_succeeded() {
1242 let dir = tempdir().unwrap();
1243 let store = test_store(dir.path().join("tx.db"));
1244 let tx = store.record(queued_transaction()).unwrap();
1245
1246 store
1247 .update_status(&tx.transaction_id, JobState::Running)
1248 .unwrap();
1249 store
1250 .update_status(&tx.transaction_id, JobState::Succeeded)
1251 .unwrap();
1252
1253 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1254 assert_eq!(updated.status, JobState::Succeeded);
1255 }
1256
1257 #[test]
1258 fn update_status_for_unknown_id_returns_not_found() {
1259 let dir = tempdir().unwrap();
1260 let store = test_store(dir.path().join("tx.db"));
1261
1262 let result = store.update_status("does-not-exist", JobState::Running);
1263 assert!(
1264 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
1265 "expected NotFound, got: {result:?}"
1266 );
1267 }
1268
1269 #[test]
1270 fn update_status_leaves_other_fields_intact() {
1271 let dir = tempdir().unwrap();
1272 let store = test_store(dir.path().join("tx.db"));
1273 let tx = store.record(queued_transaction()).unwrap();
1274
1275 store
1276 .update_status(&tx.transaction_id, JobState::Running)
1277 .unwrap();
1278 store
1279 .update_status(&tx.transaction_id, JobState::Failed)
1280 .unwrap();
1281
1282 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1283 assert_eq!(updated.action_name, "UpdateSystem");
1284 assert_eq!(updated.risk_level, RiskLevel::High);
1285 assert_eq!(updated.status, JobState::Failed);
1286 }
1287
1288 #[test]
1289 fn cancel_queued_cancels_a_queued_transaction_once() {
1290 let dir = tempdir().unwrap();
1291 let store = test_store(dir.path().join("tx.db"));
1292 let tx = store.record(queued_transaction()).unwrap();
1293
1294 assert!(
1295 store.cancel_queued(&tx.transaction_id).unwrap(),
1296 "a queued transaction is cancelable"
1297 );
1298 assert_eq!(
1299 store.get(&tx.transaction_id).unwrap().unwrap().status,
1300 JobState::Canceled
1301 );
1302 assert!(
1303 !store.cancel_queued(&tx.transaction_id).unwrap(),
1304 "an already-canceled transaction is not cancelable again"
1305 );
1306 assert!(
1307 !store.cancel_queued("no-such-transaction").unwrap(),
1308 "a missing transaction is not cancelable"
1309 );
1310 }
1311
1312 #[test]
1313 fn cancel_queued_refuses_a_running_transaction() {
1314 let dir = tempdir().unwrap();
1317 let store = test_store(dir.path().join("tx.db"));
1318 let tx = store.record(queued_transaction()).unwrap();
1319 let receipt = store
1320 .approve_transaction(&tx.transaction_id)
1321 .unwrap()
1322 .expect("approved");
1323 let digest = audit_chain::approval_receipt_digest(&receipt);
1324 assert!(store
1325 .claim_approved_for_execution(&tx.transaction_id, &digest)
1326 .unwrap());
1327
1328 assert!(
1329 !store.cancel_queued(&tx.transaction_id).unwrap(),
1330 "a running transaction must not be cancelable"
1331 );
1332 assert_eq!(
1333 store.get(&tx.transaction_id).unwrap().unwrap().status,
1334 JobState::Running,
1335 "cancel must not disturb a running transaction"
1336 );
1337 }
1338
1339 #[test]
1340 fn approved_receipt_is_required_and_consumed_once() {
1341 let dir = tempdir().unwrap();
1342 let store = test_store(dir.path().join("tx.db"));
1343 let tx = store.record(queued_transaction()).unwrap();
1344
1345 assert!(
1346 !store
1347 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
1348 .unwrap(),
1349 "an unapproved preview must not execute"
1350 );
1351 let receipt = store
1352 .approve_transaction(&tx.transaction_id)
1353 .unwrap()
1354 .expect("first approval must succeed");
1355 let digest = audit_chain::approval_receipt_digest(&receipt);
1356 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
1357 assert!(
1358 store
1359 .approve_transaction(&tx.transaction_id)
1360 .unwrap()
1361 .is_none(),
1362 "approval is immutable once issued"
1363 );
1364 assert!(
1365 !store
1366 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
1367 .unwrap(),
1368 "a forged receipt must not execute"
1369 );
1370 assert!(
1371 store
1372 .claim_approved_for_execution(&tx.transaction_id, &digest)
1373 .unwrap(),
1374 "the exact approved receipt must execute"
1375 );
1376 assert!(
1377 !store
1378 .claim_approved_for_execution(&tx.transaction_id, &digest)
1379 .unwrap(),
1380 "the receipt must be one-time"
1381 );
1382 }
1383
1384 #[test]
1385 fn approval_commitment_is_covered_by_the_signed_chain() {
1386 let dir = tempdir().unwrap();
1387 let store = test_store(dir.path().join("tx.db"));
1388 let tx = store.record(queued_transaction()).unwrap();
1389 assert!(tx.approval_id.is_some());
1390
1391 store
1392 .connection()
1393 .unwrap()
1394 .execute(
1395 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
1396 params![tx.transaction_id],
1397 )
1398 .unwrap();
1399
1400 let key = AuditKey::from_bytes(vec![0x42; 32]);
1401 assert!(matches!(
1402 store.verify_audit_chain(&key).unwrap(),
1403 VerifyOutcome::Broken { .. }
1404 ));
1405 }
1406
1407 #[test]
1408 fn approve_rejects_a_forged_commitment_at_runtime() {
1409 let dir = tempdir().unwrap();
1413 let store = test_store(dir.path().join("tx.db"));
1414 let tx = store.record(queued_transaction()).unwrap();
1415
1416 store
1417 .connection()
1418 .unwrap()
1419 .execute(
1420 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
1421 params![tx.transaction_id],
1422 )
1423 .unwrap();
1424
1425 let err = store
1426 .approve_transaction(&tx.transaction_id)
1427 .expect_err("a forged commitment must be rejected, not approved");
1428 assert!(
1429 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
1430 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
1431 );
1432 }
1433
1434 #[test]
1435 fn stale_iso_timestamp_cannot_be_approved() {
1436 let dir = tempdir().unwrap();
1437 let store = test_store(dir.path().join("tx.db"));
1438 let tx = store.record(queued_transaction()).unwrap();
1439 let conn = store.connection().unwrap();
1440 conn.execute(
1441 "UPDATE transactions \
1442 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
1443 WHERE transaction_id = ?1",
1444 params![tx.transaction_id],
1445 )
1446 .unwrap();
1447
1448 assert!(
1449 store
1450 .approve_transaction(&tx.transaction_id)
1451 .unwrap()
1452 .is_none(),
1453 "a production-format timestamp outside the TTL must not be approved"
1454 );
1455 }
1456
1457 #[test]
1458 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
1459 let dir = tempdir().unwrap();
1464 let store = test_store(dir.path().join("tx.db"));
1465 let tx = store.record(queued_transaction()).unwrap();
1466 let receipt = store
1467 .approve_transaction(&tx.transaction_id)
1468 .unwrap()
1469 .expect("a fresh approval succeeds");
1470 let digest = audit_chain::approval_receipt_digest(&receipt);
1471
1472 let conn = store.connection().unwrap();
1474 conn.execute(
1475 "UPDATE transactions \
1476 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
1477 WHERE transaction_id = ?1",
1478 params![tx.transaction_id],
1479 )
1480 .unwrap();
1481
1482 assert!(
1483 !store
1484 .claim_approved_for_execution(&tx.transaction_id, &digest)
1485 .unwrap(),
1486 "an approval aged past the TTL must not be claimable at execute time"
1487 );
1488 assert_eq!(
1489 store.get(&tx.transaction_id).unwrap().unwrap().status,
1490 JobState::Queued,
1491 "a TTL-expired claim must leave the transaction Queued, never Running"
1492 );
1493 }
1494
1495 #[test]
1496 fn cleanup_stale_queued_cancels_old_records() {
1497 let dir = tempdir().unwrap();
1498 let store = test_store(dir.path().join("tx.db"));
1499
1500 let fresh = store.record(queued_transaction()).unwrap();
1502 let stale = store.record(queued_transaction()).unwrap();
1503
1504 let conn = store.connection().unwrap();
1506 conn.execute(
1507 "UPDATE transactions \
1508 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
1509 WHERE transaction_id = ?1",
1510 params![stale.transaction_id],
1511 )
1512 .unwrap();
1513
1514 let canceled = store.cleanup_stale_queued().unwrap();
1515 assert_eq!(canceled, 1, "only the stale record should be canceled");
1516
1517 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
1519 assert_eq!(stale_record.status, JobState::Canceled);
1520
1521 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
1523 assert_eq!(fresh_record.status, JobState::Queued);
1524 }
1525
1526 #[test]
1529 fn update_status_rejects_queued_to_succeeded() {
1530 let dir = tempdir().unwrap();
1531 let store = test_store(dir.path().join("tx.db"));
1532 let tx = store.record(queued_transaction()).unwrap();
1533
1534 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
1535 assert!(
1536 matches!(
1537 result,
1538 Err(TransactionStoreError::InvalidTransition {
1539 from: JobState::Queued,
1540 to: JobState::Succeeded,
1541 })
1542 ),
1543 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
1544 );
1545 }
1546
1547 #[test]
1548 fn update_status_rejects_succeeded_to_running() {
1549 let dir = tempdir().unwrap();
1550 let store = test_store(dir.path().join("tx.db"));
1551 let tx = store.record(queued_transaction()).unwrap();
1552
1553 store
1554 .update_status(&tx.transaction_id, JobState::Running)
1555 .unwrap();
1556 store
1557 .update_status(&tx.transaction_id, JobState::Succeeded)
1558 .unwrap();
1559
1560 let result = store.update_status(&tx.transaction_id, JobState::Running);
1561 assert!(
1562 matches!(
1563 result,
1564 Err(TransactionStoreError::InvalidTransition {
1565 from: JobState::Succeeded,
1566 to: JobState::Running,
1567 })
1568 ),
1569 "Succeeded -> Running must be rejected (terminal state): {result:?}"
1570 );
1571 }
1572
1573 #[test]
1574 fn update_status_accepts_running_to_failed() {
1575 let dir = tempdir().unwrap();
1576 let store = test_store(dir.path().join("tx.db"));
1577 let tx = store.record(queued_transaction()).unwrap();
1578
1579 store
1580 .update_status(&tx.transaction_id, JobState::Running)
1581 .unwrap();
1582 store
1583 .update_status(&tx.transaction_id, JobState::Failed)
1584 .unwrap();
1585
1586 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1587 assert_eq!(updated.status, JobState::Failed);
1588 }
1589
1590 #[test]
1591 fn update_status_accepts_running_to_rolled_back() {
1592 let dir = tempdir().unwrap();
1593 let store = test_store(dir.path().join("tx.db"));
1594 let tx = store.record(queued_transaction()).unwrap();
1595
1596 store
1597 .update_status(&tx.transaction_id, JobState::Running)
1598 .unwrap();
1599 store
1600 .update_status(&tx.transaction_id, JobState::RolledBack)
1601 .unwrap();
1602
1603 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1604 assert_eq!(updated.status, JobState::RolledBack);
1605 }
1606
1607 #[test]
1610 fn list_transactions_returns_empty_for_fresh_store() {
1611 let dir = tempdir().unwrap();
1612 let store = test_store(dir.path().join("tx.db"));
1613 let results = store.list_transactions(10, None, None, None).unwrap();
1614 assert!(results.is_empty());
1615 }
1616
1617 #[test]
1618 fn list_transactions_returns_all_records_ordered_by_newest_first() {
1619 let dir = tempdir().unwrap();
1620 let store = test_store(dir.path().join("tx.db"));
1621 store.record(queued_transaction()).unwrap();
1622
1623 let mut second = queued_transaction();
1624 second.action_name = "GetDiskUsage".to_string();
1625 second.risk_level = RiskLevel::Low;
1626 store.record(second).unwrap();
1627
1628 let results = store.list_transactions(10, None, None, None).unwrap();
1629 assert_eq!(results.len(), 2);
1630 assert_eq!(results[0].action_name, "GetDiskUsage");
1632 assert_eq!(results[1].action_name, "UpdateSystem");
1633 }
1634
1635 #[test]
1636 fn list_history_populates_created_at_and_risk_level() {
1637 let dir = tempdir().unwrap();
1638 let store = test_store(dir.path().join("tx.db"));
1639 store.record(queued_transaction()).unwrap();
1640
1641 let entries = store.list_history(10, None, None, None).unwrap();
1642 assert_eq!(entries.len(), 1);
1643 let entry = &entries[0];
1644 assert_eq!(entry.action_name, "UpdateSystem");
1645 assert_eq!(entry.risk_level, RiskLevel::High);
1646 assert_eq!(entry.status, JobState::Queued);
1647 assert!(
1648 !entry.created_at.is_empty(),
1649 "created_at must be populated from the stored row, not left blank"
1650 );
1651 }
1652
1653 #[test]
1654 fn list_history_applies_the_same_filters_as_list_transactions() {
1655 let dir = tempdir().unwrap();
1656 let store = test_store(dir.path().join("tx.db"));
1657 store.record(queued_transaction()).unwrap();
1658 let mut low = queued_transaction();
1659 low.action_name = "GetDiskUsage".to_string();
1660 low.risk_level = RiskLevel::Low;
1661 store.record(low).unwrap();
1662
1663 let only = store
1664 .list_history(10, None, Some("GetDiskUsage"), None)
1665 .unwrap();
1666 assert_eq!(only.len(), 1);
1667 assert_eq!(only[0].action_name, "GetDiskUsage");
1668 }
1669
1670 #[test]
1671 fn list_transactions_respects_limit() {
1672 let dir = tempdir().unwrap();
1673 let store = test_store(dir.path().join("tx.db"));
1674 for _ in 0..5 {
1675 store.record(queued_transaction()).unwrap();
1676 }
1677 let results = store.list_transactions(3, None, None, None).unwrap();
1678 assert_eq!(results.len(), 3);
1679 }
1680
1681 #[test]
1682 fn list_transactions_filters_by_status() {
1683 let dir = tempdir().unwrap();
1684 let store = test_store(dir.path().join("tx.db"));
1685 let tx = store.record(queued_transaction()).unwrap();
1686 store
1687 .update_status(&tx.transaction_id, JobState::Running)
1688 .unwrap();
1689 store
1690 .update_status(&tx.transaction_id, JobState::Succeeded)
1691 .unwrap();
1692
1693 store.record(queued_transaction()).unwrap();
1695
1696 let succeeded = store
1697 .list_transactions(10, Some("succeeded"), None, None)
1698 .unwrap();
1699 assert_eq!(succeeded.len(), 1);
1700 assert_eq!(succeeded[0].status, JobState::Succeeded);
1701
1702 let queued = store
1703 .list_transactions(10, Some("queued"), None, None)
1704 .unwrap();
1705 assert_eq!(queued.len(), 1);
1706 assert_eq!(queued[0].status, JobState::Queued);
1707 }
1708
1709 #[test]
1710 fn list_transactions_filters_by_action_name() {
1711 let dir = tempdir().unwrap();
1712 let store = test_store(dir.path().join("tx.db"));
1713 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
1716 disk.action_name = "GetDiskUsage".to_string();
1717 store.record(disk).unwrap();
1718
1719 let results = store
1720 .list_transactions(10, None, Some("GetDiskUsage"), None)
1721 .unwrap();
1722 assert_eq!(results.len(), 1);
1723 assert_eq!(results[0].action_name, "GetDiskUsage");
1724 }
1725
1726 #[test]
1727 fn list_transactions_filters_by_since_hours() {
1728 let dir = tempdir().unwrap();
1729 let store = test_store(dir.path().join("tx.db"));
1730
1731 let old = store.record(queued_transaction()).unwrap();
1733 let conn = store.connection().unwrap();
1734 conn.execute(
1735 "UPDATE transactions \
1736 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
1737 WHERE transaction_id = ?1",
1738 params![old.transaction_id],
1739 )
1740 .unwrap();
1741
1742 store.record(queued_transaction()).unwrap();
1744
1745 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
1747 assert_eq!(results.len(), 1);
1748
1749 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
1751 assert_eq!(results.len(), 2);
1752 }
1753
1754 #[test]
1755 fn list_transactions_rejects_invalid_status_filter() {
1756 let dir = tempdir().unwrap();
1757 let store = test_store(dir.path().join("tx.db"));
1758 store.record(queued_transaction()).unwrap();
1759 let result = store.list_transactions(10, Some("bogus"), None, None);
1760 assert!(result.is_err(), "invalid status filter should return error");
1761 }
1762
1763 #[test]
1771 fn record_emits_one_watermark_per_entry() {
1772 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1773 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1774
1775 let dir = tempdir().unwrap();
1776 let store = test_store(dir.path().join("tx.db"));
1777 store.record(queued_transaction()).unwrap();
1778
1779 let calls = crate::audit_watermark::take_watermarks(&sink);
1780 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
1781 }
1782
1783 #[test]
1785 fn record_previewed_emits_one_watermark() {
1786 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1787 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1788
1789 let dir = tempdir().unwrap();
1790 let store = test_store(dir.path().join("tx.db"));
1791 let preview = PreviewEnvelope {
1792 summary: "Upgrade the system".to_string(),
1793 risk_level: RiskLevel::High,
1794 current_state: serde_json::Value::Null,
1795 proposed_change: serde_json::Value::Null,
1796 expected_side_effects: vec![],
1797 reboot_required: false,
1798 rollback_available: false,
1799 warnings: vec![],
1800 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
1801 };
1802 store
1803 .record_previewed(queued_transaction(), preview)
1804 .unwrap();
1805
1806 let calls = crate::audit_watermark::take_watermarks(&sink);
1807 assert_eq!(
1808 calls.len(),
1809 1,
1810 "expected exactly 1 watermark per record_previewed()"
1811 );
1812 }
1813
1814 #[test]
1816 fn watermark_seq_and_hash_match_chain_row() {
1817 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1818 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1819
1820 let dir = tempdir().unwrap();
1821 let store = test_store(dir.path().join("tx.db"));
1822 store.record(queued_transaction()).unwrap();
1823
1824 let rows = store.fetch_chain_rows().unwrap();
1825 assert_eq!(rows.len(), 1);
1826 let row = &rows[0];
1827
1828 let calls = crate::audit_watermark::take_watermarks(&sink);
1829 assert_eq!(calls.len(), 1);
1830 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
1831 assert_eq!(
1832 calls[0].chain_hash_hex, row.chain_hash,
1833 "watermark chain_hash_hex must match stored chain_hash"
1834 );
1835 }
1836
1837 #[test]
1839 fn multiple_records_produce_one_watermark_each() {
1840 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1841 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1842
1843 let dir = tempdir().unwrap();
1844 let store = test_store(dir.path().join("tx.db"));
1845 for _ in 0..3 {
1846 store.record(queued_transaction()).unwrap();
1847 }
1848
1849 let calls = crate::audit_watermark::take_watermarks(&sink);
1850 assert_eq!(calls.len(), 3, "one watermark per record call");
1851 assert_eq!(calls[0].seq, 1);
1852 assert_eq!(calls[1].seq, 2);
1853 assert_eq!(calls[2].seq, 3);
1854 }
1855
1856 #[test]
1870 fn no_watermark_emitted_before_any_record() {
1871 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1872 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1873
1874 let dir = tempdir().unwrap();
1875 let _store = test_store(dir.path().join("tx.db"));
1876
1877 let calls = crate::audit_watermark::take_watermarks(&sink);
1879 assert!(
1880 calls.is_empty(),
1881 "no watermark must be emitted without a record() call"
1882 );
1883 }
1884}