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