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(
663 r#"
664 CREATE TABLE IF NOT EXISTS transactions (
665 transaction_id TEXT PRIMARY KEY,
666 request_id TEXT NOT NULL,
667 request_hash TEXT NOT NULL,
668 action_name TEXT NOT NULL,
669 risk_level TEXT NOT NULL,
670 status TEXT NOT NULL,
671 approval_id TEXT,
672 summary TEXT NOT NULL,
673 warnings_json TEXT NOT NULL,
674 created_at TEXT NOT NULL DEFAULT (datetime('now')),
675 seq INTEGER NOT NULL UNIQUE,
676 key_id TEXT NOT NULL,
677 chain_hash TEXT NOT NULL,
678 prev_chain_hash TEXT NOT NULL DEFAULT ''
679 );
680
681 CREATE TABLE IF NOT EXISTS transaction_previews (
682 transaction_id TEXT PRIMARY KEY,
683 preview_json TEXT NOT NULL
684 );
685
686 CREATE TABLE IF NOT EXISTS transaction_approvals (
687 transaction_id TEXT PRIMARY KEY,
688 receipt_digest TEXT NOT NULL,
689 approved_at TEXT NOT NULL DEFAULT (datetime('now')),
690 consumed_at TEXT
691 );
692
693 CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq);
694 "#,
695 )?;
696 tx.execute(
697 "INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (1, ?1)",
698 params!["initial_audit_schema"],
699 )?;
700 tx.commit()?;
701 Ok(())
702 }
703
704 pub fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
706 let conn = self.connection()?;
707 let mut stmt = conn.prepare(
708 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
709 action_name, risk_level, summary, approval_id, warnings_json, \
710 created_at, prev_chain_hash, chain_hash \
711 FROM transactions ORDER BY seq ASC",
712 )?;
713 let rows = stmt.query_map([], |row| {
714 Ok(ChainRow {
715 seq: row.get::<_, i64>(0)? as u64,
716 key_id: row.get(1)?,
717 transaction_id: row.get(2)?,
718 request_id: row.get(3)?,
719 request_hash: row.get(4)?,
720 action_name: row.get(5)?,
721 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
722 rusqlite::Error::FromSqlConversionFailure(
723 6,
724 rusqlite::types::Type::Text,
725 Box::new(e),
726 )
727 })?,
728 summary: row.get(7)?,
729 approval_id: row.get(8)?,
730 warnings_json: row.get(9)?,
731 created_at: row.get(10)?,
732 prev_chain_hash: row.get(11)?,
733 chain_hash: row.get(12)?,
734 })
735 })?;
736 let mut out = Vec::new();
737 for row in rows {
738 out.push(row?);
739 }
740 Ok(out)
741 }
742
743 pub fn verify_audit_chain(
745 &self,
746 key: &AuditKey,
747 ) -> Result<VerifyOutcome, TransactionStoreError> {
748 let rows = self.fetch_chain_rows()?;
749 Ok(audit_chain::verify_chain(key, &rows))
750 }
751
752 pub fn verify_audit_chain_with_pubkey(
755 &self,
756 verifying_key_hex: &str,
757 ) -> Result<VerifyOutcome, TransactionStoreError> {
758 let rows = self.fetch_chain_rows()?;
759 Ok(audit_chain::verify_chain_with_pubkey(
760 verifying_key_hex,
761 &rows,
762 ))
763 }
764
765 pub fn fetch_chain_row(
768 &self,
769 transaction_id: &str,
770 ) -> Result<Option<ChainRow>, TransactionStoreError> {
771 let conn = self.connection()?;
772 let mut stmt = conn.prepare(
773 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
774 action_name, risk_level, summary, approval_id, warnings_json, \
775 created_at, prev_chain_hash, chain_hash \
776 FROM transactions WHERE transaction_id = ?1",
777 )?;
778 let mut rows = stmt.query(params![transaction_id])?;
779 if let Some(row) = rows.next()? {
780 Ok(Some(ChainRow {
781 seq: row.get::<_, i64>(0)? as u64,
782 key_id: row.get(1)?,
783 transaction_id: row.get(2)?,
784 request_id: row.get(3)?,
785 request_hash: row.get(4)?,
786 action_name: row.get(5)?,
787 risk_level: deserialize_field(&row.get::<_, String>(6)?).map_err(|e| {
788 rusqlite::Error::FromSqlConversionFailure(
789 6,
790 rusqlite::types::Type::Text,
791 Box::new(e),
792 )
793 })?,
794 summary: row.get(7)?,
795 approval_id: row.get(8)?,
796 warnings_json: row.get(9)?,
797 created_at: row.get(10)?,
798 prev_chain_hash: row.get(11)?,
799 chain_hash: row.get(12)?,
800 }))
801 } else {
802 Ok(None)
803 }
804 }
805
806 fn next_seq_and_prev_hash(conn: &Connection) -> Result<(u64, String), TransactionStoreError> {
810 let prev: Option<(i64, String)> = conn
811 .query_row(
812 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1",
813 [],
814 |row| Ok((row.get(0)?, row.get(1)?)),
815 )
816 .map(Some)
817 .or_else(|e| match e {
818 rusqlite::Error::QueryReturnedNoRows => Ok(None),
819 other => Err(other),
820 })?;
821 Ok(match prev {
822 Some((seq, hash)) => ((seq as u64) + 1, hash),
823 None => (1, String::new()),
824 })
825 }
826
827 fn insert_transaction(
828 conn: &Connection,
829 key: &AuditKey,
830 transaction_id: &str,
831 transaction: NewTransaction,
832 ) -> Result<InsertedTransaction, TransactionStoreError> {
833 let request_id = transaction.request_id;
834 let request_hash = transaction.request_hash;
835 let action_name = transaction.action_name;
836 let risk_level = transaction.risk_level;
837 let status = JobState::Queued;
839 let approval_id = transaction
840 .approval_id
841 .or_else(|| Some(key.approval_commitment(transaction_id, &request_hash)));
842 let summary = transaction.summary;
843 let warnings = transaction.warnings;
844 let warnings_json = serde_json::to_string(&warnings)?;
845
846 let (seq, prev_chain_hash) = Self::next_seq_and_prev_hash(conn)?;
849
850 let created_at: String =
854 conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", [], |row| {
855 row.get(0)
856 })?;
857
858 let key_id = CURRENT_KEY_ID.to_string();
859 let chain_hash = key.chain_hash(
860 &ChainContent {
861 seq,
862 key_id: &key_id,
863 transaction_id,
864 request_id: &request_id,
865 request_hash: &request_hash,
866 action_name: &action_name,
867 risk_level,
868 summary: &summary,
869 approval_id: approval_id.as_deref(),
870 warnings_json: &warnings_json,
871 created_at: &created_at,
872 },
873 &prev_chain_hash,
874 );
875
876 let record = TransactionRecord {
877 transaction_id: transaction_id.to_string(),
878 request_id: request_id.clone(),
879 request_hash: request_hash.clone(),
880 action_name: action_name.clone(),
881 risk_level,
882 status,
883 approval_id: approval_id.clone(),
884 summary: summary.clone(),
885 warnings: warnings.clone(),
886 };
887
888 conn.execute(
889 "INSERT INTO transactions (
890 transaction_id,
891 request_id,
892 request_hash,
893 action_name,
894 risk_level,
895 status,
896 approval_id,
897 summary,
898 warnings_json,
899 created_at,
900 seq,
901 key_id,
902 chain_hash,
903 prev_chain_hash
904 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
905 params![
906 transaction_id,
907 request_id,
908 request_hash,
909 action_name,
910 serialize_field(&risk_level)?,
911 serialize_field(&status)?,
912 approval_id,
913 summary,
914 warnings_json,
915 created_at,
916 seq as i64,
917 key_id,
918 chain_hash,
919 prev_chain_hash,
920 ],
921 )?;
922
923 Ok(InsertedTransaction {
924 record,
925 seq,
926 chain_hash,
927 })
928 }
929
930 fn insert_preview(
931 conn: &Connection,
932 transaction_id: &str,
933 preview: &PreviewEnvelope,
934 ) -> Result<(), TransactionStoreError> {
935 conn.execute(
936 "INSERT INTO transaction_previews (transaction_id, preview_json)
937 VALUES (?1, ?2)",
938 params![transaction_id, serde_json::to_string(preview)?],
939 )?;
940 Ok(())
941 }
942}
943
944fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
951 use std::os::unix::fs::DirBuilderExt;
952 if dir.exists() {
953 return Ok(());
954 }
955 std::fs::DirBuilder::new()
956 .recursive(true)
957 .mode(0o700)
958 .create(dir)
959}
960
961fn row_to_record(row: &rusqlite::Row) -> Result<TransactionRecord, TransactionStoreError> {
962 Ok(TransactionRecord {
963 transaction_id: row.get(0)?,
964 request_id: row.get(1)?,
965 request_hash: row.get(2)?,
966 action_name: row.get(3)?,
967 risk_level: deserialize_field(&row.get::<_, String>(4)?)?,
968 status: deserialize_field(&row.get::<_, String>(5)?)?,
969 approval_id: row.get(6)?,
970 summary: row.get(7)?,
971 warnings: serde_json::from_str(&row.get::<_, String>(8)?)?,
972 })
973}
974
975fn serialize_field<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
976 serde_json::to_string(value)
977}
978
979fn deserialize_field<T: DeserializeOwned>(value: &str) -> Result<T, serde_json::Error> {
980 serde_json::from_str(value)
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986 use std::os::unix::fs::PermissionsExt;
987 use tempfile::tempdir;
988
989 fn test_store(path: impl AsRef<Path>) -> TransactionStore {
993 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
994 TransactionStore::open_with_key(path, key).unwrap()
995 }
996
997 #[test]
1000 fn record_writes_audit_chain_columns() {
1001 let dir = tempdir().unwrap();
1002 let store = test_store(dir.path().join("tx.db"));
1003 let _record = store.record(queued_transaction()).unwrap();
1004
1005 let conn = store.connection().unwrap();
1006 let (seq, key_id, chain_hash, prev): (i64, String, String, String) = conn
1007 .query_row(
1008 "SELECT seq, key_id, chain_hash, prev_chain_hash FROM transactions",
1009 [],
1010 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1011 )
1012 .unwrap();
1013 assert_eq!(seq, 1, "first row gets seq=1");
1014 assert_eq!(key_id, audit_chain::CURRENT_KEY_ID);
1015 assert_eq!(prev, "", "first row has empty prev_chain_hash");
1016 assert_eq!(
1017 chain_hash.len(),
1018 audit_chain::HASH_HEX_LEN,
1019 "chain_hash is a hex-encoded Ed25519 signature"
1020 );
1021 }
1022
1023 #[test]
1024 fn sequential_records_produce_chained_hashes() {
1025 let dir = tempdir().unwrap();
1026 let store = test_store(dir.path().join("tx.db"));
1027 store.record(queued_transaction()).unwrap();
1028 store.record(queued_transaction()).unwrap();
1029 store.record(queued_transaction()).unwrap();
1030
1031 let rows = store.fetch_chain_rows().unwrap();
1032 assert_eq!(rows.len(), 3);
1033 assert_eq!(rows[0].seq, 1);
1034 assert_eq!(rows[1].seq, 2);
1035 assert_eq!(rows[2].seq, 3);
1036 assert_eq!(rows[1].prev_chain_hash, rows[0].chain_hash);
1038 assert_eq!(rows[2].prev_chain_hash, rows[1].chain_hash);
1039 }
1040
1041 #[test]
1053 fn concurrent_record_keeps_chain_intact_and_seqs_contiguous() {
1054 const WORKERS: usize = 8;
1055 const RECORDS_PER_WORKER: usize = 10;
1056 const TOTAL: usize = WORKERS * RECORDS_PER_WORKER;
1057
1058 let dir = tempdir().unwrap();
1059 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1060
1061 let mut handles = Vec::with_capacity(WORKERS);
1062 for w in 0..WORKERS {
1063 let store = std::sync::Arc::clone(&store);
1064 handles.push(std::thread::spawn(move || {
1065 for r in 0..RECORDS_PER_WORKER {
1066 let tx = NewTransaction {
1067 request_id: format!("worker-{w}-record-{r}"),
1068 request_hash: format!("hash-{w}-{r}"),
1069 action_name: "GetSystemState".to_string(),
1070 risk_level: RiskLevel::Low,
1071 approval_id: None,
1072 summary: format!("worker {w} record {r}"),
1073 warnings: vec![],
1074 };
1075 store
1076 .record(tx)
1077 .expect("record must succeed under contention");
1078 }
1079 }));
1080 }
1081 for h in handles {
1082 h.join().expect("worker thread did not panic");
1083 }
1084
1085 let key = AuditKey::from_bytes(vec![0x42; 32]);
1087 let outcome = store.verify_audit_chain(&key).unwrap();
1088 match outcome {
1089 VerifyOutcome::Intact { rows_checked } => {
1090 assert_eq!(
1091 rows_checked, TOTAL as u64,
1092 "expected {TOTAL} rows checked, got {rows_checked}"
1093 );
1094 }
1095 other => panic!("chain must be Intact under concurrent writes; got {other:?}"),
1096 }
1097
1098 let conn = store.connection().unwrap();
1100 let mut stmt = conn
1101 .prepare("SELECT seq FROM transactions ORDER BY seq ASC")
1102 .unwrap();
1103 let seqs: Vec<i64> = stmt
1104 .query_map([], |row| row.get::<_, i64>(0))
1105 .unwrap()
1106 .map(|r| r.unwrap())
1107 .collect();
1108 assert_eq!(seqs.len(), TOTAL, "row count mismatch");
1109 for (i, s) in seqs.iter().enumerate() {
1110 assert_eq!(
1111 *s,
1112 (i as i64) + 1,
1113 "seq column must be contiguous 1..={TOTAL}; saw {s} at position {i}"
1114 );
1115 }
1116 }
1117
1118 #[test]
1119 fn verify_audit_chain_intact_after_inserts() {
1120 let dir = tempdir().unwrap();
1121 let store = test_store(dir.path().join("tx.db"));
1122 for _ in 0..3 {
1123 store.record(queued_transaction()).unwrap();
1124 }
1125 let key = AuditKey::from_bytes(vec![0x42; 32]);
1126 let outcome = store.verify_audit_chain(&key).unwrap();
1127 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1128 }
1129
1130 #[test]
1131 fn verify_audit_chain_with_pubkey_intact_after_inserts() {
1132 let dir = tempdir().unwrap();
1133 let store = test_store(dir.path().join("tx.db"));
1134 for _ in 0..3 {
1135 store.record(queued_transaction()).unwrap();
1136 }
1137 let key = AuditKey::from_bytes(vec![0x42; 32]);
1139 let outcome = store
1140 .verify_audit_chain_with_pubkey(&key.verifying_key_hex())
1141 .unwrap();
1142 assert!(matches!(outcome, VerifyOutcome::Intact { rows_checked: 3 }));
1143 }
1144
1145 #[test]
1146 fn verify_audit_chain_with_wrong_pubkey_is_broken() {
1147 let dir = tempdir().unwrap();
1148 let store = test_store(dir.path().join("tx.db"));
1149 store.record(queued_transaction()).unwrap();
1150 let other = AuditKey::from_bytes(vec![0x99; 32]);
1152 let outcome = store
1153 .verify_audit_chain_with_pubkey(&other.verifying_key_hex())
1154 .unwrap();
1155 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1156 }
1157
1158 #[test]
1159 fn verify_detects_tampered_summary() {
1160 let dir = tempdir().unwrap();
1161 let store = test_store(dir.path().join("tx.db"));
1162 let tx = store.record(queued_transaction()).unwrap();
1163
1164 let conn = store.connection().unwrap();
1167 conn.execute(
1168 "UPDATE transactions SET summary = ?1 WHERE transaction_id = ?2",
1169 params!["EVIL CHANGE", tx.transaction_id],
1170 )
1171 .unwrap();
1172
1173 let key = AuditKey::from_bytes(vec![0x42; 32]);
1174 let outcome = store.verify_audit_chain(&key).unwrap();
1175 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1176 }
1177
1178 #[test]
1179 fn status_update_does_not_break_chain() {
1180 let dir = tempdir().unwrap();
1182 let store = test_store(dir.path().join("tx.db"));
1183 let tx = store.record(queued_transaction()).unwrap();
1184 store
1185 .update_status(&tx.transaction_id, JobState::Running)
1186 .unwrap();
1187 store
1188 .update_status(&tx.transaction_id, JobState::Succeeded)
1189 .unwrap();
1190
1191 let key = AuditKey::from_bytes(vec![0x42; 32]);
1192 let outcome = store.verify_audit_chain(&key).unwrap();
1193 assert!(
1194 matches!(outcome, VerifyOutcome::Intact { rows_checked: 1 }),
1195 "status mutation must not break the chain (status not in chain content): {outcome:?}"
1196 );
1197 }
1198
1199 #[test]
1200 fn open_read_only_rejects_record() {
1201 let dir = tempdir().unwrap();
1202 let key_path = dir.path().join("audit-key");
1203 std::fs::write(&key_path, vec![0x42; 32]).unwrap();
1204 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1205
1206 let store = TransactionStore::open_read_only(dir.path().join("tx.db")).unwrap();
1207 let result = store.record(queued_transaction());
1208 assert!(matches!(
1209 result,
1210 Err(TransactionStoreError::AuditChainMissing(_))
1211 ));
1212 }
1213
1214 #[test]
1215 fn ensure_private_dir_creates_with_0700_mode() {
1216 let root = tempdir().unwrap();
1217 let target = root.path().join("a/b/c");
1218 ensure_private_dir(&target).unwrap();
1219 assert!(target.is_dir());
1220 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1221 assert_eq!(mode, 0o700, "leaf dir must be 0o700, got {mode:o}");
1222 }
1223
1224 #[test]
1225 fn ensure_private_dir_is_idempotent_and_does_not_widen_existing_mode() {
1226 let root = tempdir().unwrap();
1227 let target = root.path().join("preexisting");
1228 std::fs::create_dir(&target).unwrap();
1229 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1230 ensure_private_dir(&target).unwrap();
1231 let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1233 assert_eq!(mode, 0o755);
1234 }
1235
1236 #[test]
1237 fn open_creates_parent_with_private_mode() {
1238 let root = tempdir().unwrap();
1239 let db_path = root.path().join("nested/dirs/daemon.sqlite");
1240 let _store = test_store(&db_path);
1241 let parent = db_path.parent().unwrap();
1242 let mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
1243 assert_eq!(mode, 0o700);
1244 }
1245
1246 fn queued_transaction() -> NewTransaction {
1247 NewTransaction {
1248 request_id: "req-1".to_string(),
1249 request_hash: "hash-abc".to_string(),
1250 action_name: "UpdateSystem".to_string(),
1251 risk_level: RiskLevel::High,
1252 approval_id: None,
1253 summary: "Upgrade the system".to_string(),
1254 warnings: vec![],
1255 }
1256 }
1257
1258 #[test]
1259 fn update_status_transitions_queued_to_running() {
1260 let dir = tempdir().unwrap();
1261 let store = test_store(dir.path().join("tx.db"));
1262 let tx = store.record(queued_transaction()).unwrap();
1263
1264 store
1265 .update_status(&tx.transaction_id, JobState::Running)
1266 .unwrap();
1267
1268 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1269 assert_eq!(updated.status, JobState::Running);
1270 }
1271
1272 #[test]
1273 fn update_status_transitions_running_to_succeeded() {
1274 let dir = tempdir().unwrap();
1275 let store = test_store(dir.path().join("tx.db"));
1276 let tx = store.record(queued_transaction()).unwrap();
1277
1278 store
1279 .update_status(&tx.transaction_id, JobState::Running)
1280 .unwrap();
1281 store
1282 .update_status(&tx.transaction_id, JobState::Succeeded)
1283 .unwrap();
1284
1285 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1286 assert_eq!(updated.status, JobState::Succeeded);
1287 }
1288
1289 #[test]
1290 fn update_status_for_unknown_id_returns_not_found() {
1291 let dir = tempdir().unwrap();
1292 let store = test_store(dir.path().join("tx.db"));
1293
1294 let result = store.update_status("does-not-exist", JobState::Running);
1295 assert!(
1296 matches!(result, Err(TransactionStoreError::NotFound(ref id)) if id == "does-not-exist"),
1297 "expected NotFound, got: {result:?}"
1298 );
1299 }
1300
1301 #[test]
1302 fn update_status_leaves_other_fields_intact() {
1303 let dir = tempdir().unwrap();
1304 let store = test_store(dir.path().join("tx.db"));
1305 let tx = store.record(queued_transaction()).unwrap();
1306
1307 store
1308 .update_status(&tx.transaction_id, JobState::Running)
1309 .unwrap();
1310 store
1311 .update_status(&tx.transaction_id, JobState::Failed)
1312 .unwrap();
1313
1314 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1315 assert_eq!(updated.action_name, "UpdateSystem");
1316 assert_eq!(updated.risk_level, RiskLevel::High);
1317 assert_eq!(updated.status, JobState::Failed);
1318 }
1319
1320 #[test]
1329 fn update_status_is_atomic_under_concurrent_conflicting_transitions() {
1330 let dir = tempdir().unwrap();
1331 let store = std::sync::Arc::new(test_store(dir.path().join("tx.db")));
1332 let tx = store.record(queued_transaction()).unwrap();
1333 store
1334 .update_status(&tx.transaction_id, JobState::Running)
1335 .unwrap();
1336
1337 let store_a = std::sync::Arc::clone(&store);
1338 let id_a = tx.transaction_id.clone();
1339 let succeed = std::thread::spawn(move || store_a.update_status(&id_a, JobState::Succeeded));
1340
1341 let store_b = std::sync::Arc::clone(&store);
1342 let id_b = tx.transaction_id.clone();
1343 let fail = std::thread::spawn(move || store_b.update_status(&id_b, JobState::Failed));
1344
1345 let succeed_result = succeed.join().unwrap();
1346 let fail_result = fail.join().unwrap();
1347
1348 let oks = [succeed_result.is_ok(), fail_result.is_ok()]
1350 .into_iter()
1351 .filter(|ok| *ok)
1352 .count();
1353 assert_eq!(
1354 oks, 1,
1355 "exactly one concurrent transition should succeed: succeed={succeed_result:?} \
1356 fail={fail_result:?}"
1357 );
1358
1359 let loser = if succeed_result.is_ok() {
1375 &fail_result
1376 } else {
1377 &succeed_result
1378 };
1379 match loser {
1380 Err(TransactionStoreError::ConcurrentStatusChange(id)) => {
1381 assert_eq!(id, &tx.transaction_id);
1382 }
1383 Err(TransactionStoreError::InvalidTransition { from, to }) => {
1384 let winner = if succeed_result.is_ok() {
1386 JobState::Succeeded
1387 } else {
1388 JobState::Failed
1389 };
1390 assert_eq!(
1391 *from, winner,
1392 "the loser must have observed the winner's committed status"
1393 );
1394 assert_ne!(*to, winner, "the loser must be the other transition");
1395 }
1396 other => panic!(
1397 "the losing transition must be refused with either \
1398 ConcurrentStatusChange or InvalidTransition, got: {other:?}"
1399 ),
1400 }
1401
1402 let final_status = store.get(&tx.transaction_id).unwrap().unwrap().status;
1405 if succeed_result.is_ok() {
1406 assert_eq!(final_status, JobState::Succeeded);
1407 } else {
1408 assert_eq!(final_status, JobState::Failed);
1409 }
1410 }
1411
1412 #[test]
1413 fn cancel_queued_cancels_a_queued_transaction_once() {
1414 let dir = tempdir().unwrap();
1415 let store = test_store(dir.path().join("tx.db"));
1416 let tx = store.record(queued_transaction()).unwrap();
1417
1418 assert!(
1419 store.cancel_queued(&tx.transaction_id).unwrap(),
1420 "a queued transaction is cancelable"
1421 );
1422 assert_eq!(
1423 store.get(&tx.transaction_id).unwrap().unwrap().status,
1424 JobState::Canceled
1425 );
1426 assert!(
1427 !store.cancel_queued(&tx.transaction_id).unwrap(),
1428 "an already-canceled transaction is not cancelable again"
1429 );
1430 assert!(
1431 !store.cancel_queued("no-such-transaction").unwrap(),
1432 "a missing transaction is not cancelable"
1433 );
1434 }
1435
1436 #[test]
1437 fn cancel_queued_refuses_a_running_transaction() {
1438 let dir = tempdir().unwrap();
1441 let store = test_store(dir.path().join("tx.db"));
1442 let tx = store.record(queued_transaction()).unwrap();
1443 let receipt = store
1444 .approve_transaction(&tx.transaction_id)
1445 .unwrap()
1446 .expect("approved");
1447 let digest = audit_chain::approval_receipt_digest(&receipt);
1448 assert!(store
1449 .claim_approved_for_execution(&tx.transaction_id, &digest)
1450 .unwrap());
1451
1452 assert!(
1453 !store.cancel_queued(&tx.transaction_id).unwrap(),
1454 "a running transaction must not be cancelable"
1455 );
1456 assert_eq!(
1457 store.get(&tx.transaction_id).unwrap().unwrap().status,
1458 JobState::Running,
1459 "cancel must not disturb a running transaction"
1460 );
1461 }
1462
1463 #[test]
1464 fn approved_receipt_is_required_and_consumed_once() {
1465 let dir = tempdir().unwrap();
1466 let store = test_store(dir.path().join("tx.db"));
1467 let tx = store.record(queued_transaction()).unwrap();
1468
1469 assert!(
1470 !store
1471 .claim_approved_for_execution(&tx.transaction_id, "digest-a")
1472 .unwrap(),
1473 "an unapproved preview must not execute"
1474 );
1475 let receipt = store
1476 .approve_transaction(&tx.transaction_id)
1477 .unwrap()
1478 .expect("first approval must succeed");
1479 let digest = audit_chain::approval_receipt_digest(&receipt);
1480 assert_eq!(tx.approval_id.as_deref(), Some(digest.as_str()));
1481 assert!(
1482 store
1483 .approve_transaction(&tx.transaction_id)
1484 .unwrap()
1485 .is_none(),
1486 "approval is immutable once issued"
1487 );
1488 assert!(
1489 !store
1490 .claim_approved_for_execution(&tx.transaction_id, "wrong-digest")
1491 .unwrap(),
1492 "a forged receipt must not execute"
1493 );
1494 assert!(
1495 store
1496 .claim_approved_for_execution(&tx.transaction_id, &digest)
1497 .unwrap(),
1498 "the exact approved receipt must execute"
1499 );
1500 assert!(
1501 !store
1502 .claim_approved_for_execution(&tx.transaction_id, &digest)
1503 .unwrap(),
1504 "the receipt must be one-time"
1505 );
1506 }
1507
1508 #[test]
1509 fn approval_commitment_is_covered_by_the_signed_chain() {
1510 let dir = tempdir().unwrap();
1511 let store = test_store(dir.path().join("tx.db"));
1512 let tx = store.record(queued_transaction()).unwrap();
1513 assert!(tx.approval_id.is_some());
1514
1515 store
1516 .connection()
1517 .unwrap()
1518 .execute(
1519 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
1520 params![tx.transaction_id],
1521 )
1522 .unwrap();
1523
1524 let key = AuditKey::from_bytes(vec![0x42; 32]);
1525 assert!(matches!(
1526 store.verify_audit_chain(&key).unwrap(),
1527 VerifyOutcome::Broken { .. }
1528 ));
1529 }
1530
1531 #[test]
1532 fn approve_rejects_a_forged_commitment_at_runtime() {
1533 let dir = tempdir().unwrap();
1537 let store = test_store(dir.path().join("tx.db"));
1538 let tx = store.record(queued_transaction()).unwrap();
1539
1540 store
1541 .connection()
1542 .unwrap()
1543 .execute(
1544 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
1545 params![tx.transaction_id],
1546 )
1547 .unwrap();
1548
1549 let err = store
1550 .approve_transaction(&tx.transaction_id)
1551 .expect_err("a forged commitment must be rejected, not approved");
1552 assert!(
1553 matches!(err, TransactionStoreError::DatabaseInvariant(_)),
1554 "forged commitment must surface as a DatabaseInvariant, got {err:?}"
1555 );
1556 }
1557
1558 #[test]
1559 fn stale_iso_timestamp_cannot_be_approved() {
1560 let dir = tempdir().unwrap();
1561 let store = test_store(dir.path().join("tx.db"));
1562 let tx = store.record(queued_transaction()).unwrap();
1563 let conn = store.connection().unwrap();
1564 conn.execute(
1565 "UPDATE transactions \
1566 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
1567 WHERE transaction_id = ?1",
1568 params![tx.transaction_id],
1569 )
1570 .unwrap();
1571
1572 assert!(
1573 store
1574 .approve_transaction(&tx.transaction_id)
1575 .unwrap()
1576 .is_none(),
1577 "a production-format timestamp outside the TTL must not be approved"
1578 );
1579 }
1580
1581 #[test]
1582 fn stale_iso_timestamp_cannot_be_claimed_for_execution() {
1583 let dir = tempdir().unwrap();
1588 let store = test_store(dir.path().join("tx.db"));
1589 let tx = store.record(queued_transaction()).unwrap();
1590 let receipt = store
1591 .approve_transaction(&tx.transaction_id)
1592 .unwrap()
1593 .expect("a fresh approval succeeds");
1594 let digest = audit_chain::approval_receipt_digest(&receipt);
1595
1596 let conn = store.connection().unwrap();
1598 conn.execute(
1599 "UPDATE transactions \
1600 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
1601 WHERE transaction_id = ?1",
1602 params![tx.transaction_id],
1603 )
1604 .unwrap();
1605
1606 assert!(
1607 !store
1608 .claim_approved_for_execution(&tx.transaction_id, &digest)
1609 .unwrap(),
1610 "an approval aged past the TTL must not be claimable at execute time"
1611 );
1612 assert_eq!(
1613 store.get(&tx.transaction_id).unwrap().unwrap().status,
1614 JobState::Queued,
1615 "a TTL-expired claim must leave the transaction Queued, never Running"
1616 );
1617 }
1618
1619 #[test]
1620 fn cleanup_stale_queued_cancels_old_records() {
1621 let dir = tempdir().unwrap();
1622 let store = test_store(dir.path().join("tx.db"));
1623
1624 let fresh = store.record(queued_transaction()).unwrap();
1626 let stale = store.record(queued_transaction()).unwrap();
1627
1628 let conn = store.connection().unwrap();
1630 conn.execute(
1631 "UPDATE transactions \
1632 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \
1633 WHERE transaction_id = ?1",
1634 params![stale.transaction_id],
1635 )
1636 .unwrap();
1637
1638 let canceled = store.cleanup_stale_queued().unwrap();
1639 assert_eq!(canceled, 1, "only the stale record should be canceled");
1640
1641 let stale_record = store.get(&stale.transaction_id).unwrap().unwrap();
1643 assert_eq!(stale_record.status, JobState::Canceled);
1644
1645 let fresh_record = store.get(&fresh.transaction_id).unwrap().unwrap();
1647 assert_eq!(fresh_record.status, JobState::Queued);
1648 }
1649
1650 #[test]
1653 fn update_status_rejects_queued_to_succeeded() {
1654 let dir = tempdir().unwrap();
1655 let store = test_store(dir.path().join("tx.db"));
1656 let tx = store.record(queued_transaction()).unwrap();
1657
1658 let result = store.update_status(&tx.transaction_id, JobState::Succeeded);
1659 assert!(
1660 matches!(
1661 result,
1662 Err(TransactionStoreError::InvalidTransition {
1663 from: JobState::Queued,
1664 to: JobState::Succeeded,
1665 })
1666 ),
1667 "Queued -> Succeeded must be rejected (must go through Running first): {result:?}"
1668 );
1669 }
1670
1671 #[test]
1672 fn update_status_rejects_succeeded_to_running() {
1673 let dir = tempdir().unwrap();
1674 let store = test_store(dir.path().join("tx.db"));
1675 let tx = store.record(queued_transaction()).unwrap();
1676
1677 store
1678 .update_status(&tx.transaction_id, JobState::Running)
1679 .unwrap();
1680 store
1681 .update_status(&tx.transaction_id, JobState::Succeeded)
1682 .unwrap();
1683
1684 let result = store.update_status(&tx.transaction_id, JobState::Running);
1685 assert!(
1686 matches!(
1687 result,
1688 Err(TransactionStoreError::InvalidTransition {
1689 from: JobState::Succeeded,
1690 to: JobState::Running,
1691 })
1692 ),
1693 "Succeeded -> Running must be rejected (terminal state): {result:?}"
1694 );
1695 }
1696
1697 #[test]
1698 fn update_status_accepts_running_to_failed() {
1699 let dir = tempdir().unwrap();
1700 let store = test_store(dir.path().join("tx.db"));
1701 let tx = store.record(queued_transaction()).unwrap();
1702
1703 store
1704 .update_status(&tx.transaction_id, JobState::Running)
1705 .unwrap();
1706 store
1707 .update_status(&tx.transaction_id, JobState::Failed)
1708 .unwrap();
1709
1710 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1711 assert_eq!(updated.status, JobState::Failed);
1712 }
1713
1714 #[test]
1715 fn update_status_accepts_running_to_rolled_back() {
1716 let dir = tempdir().unwrap();
1717 let store = test_store(dir.path().join("tx.db"));
1718 let tx = store.record(queued_transaction()).unwrap();
1719
1720 store
1721 .update_status(&tx.transaction_id, JobState::Running)
1722 .unwrap();
1723 store
1724 .update_status(&tx.transaction_id, JobState::RolledBack)
1725 .unwrap();
1726
1727 let updated = store.get(&tx.transaction_id).unwrap().unwrap();
1728 assert_eq!(updated.status, JobState::RolledBack);
1729 }
1730
1731 #[test]
1734 fn list_transactions_returns_empty_for_fresh_store() {
1735 let dir = tempdir().unwrap();
1736 let store = test_store(dir.path().join("tx.db"));
1737 let results = store.list_transactions(10, None, None, None).unwrap();
1738 assert!(results.is_empty());
1739 }
1740
1741 #[test]
1742 fn list_transactions_returns_all_records_ordered_by_newest_first() {
1743 let dir = tempdir().unwrap();
1744 let store = test_store(dir.path().join("tx.db"));
1745 store.record(queued_transaction()).unwrap();
1746
1747 let mut second = queued_transaction();
1748 second.action_name = "GetDiskUsage".to_string();
1749 second.risk_level = RiskLevel::Low;
1750 store.record(second).unwrap();
1751
1752 let results = store.list_transactions(10, None, None, None).unwrap();
1753 assert_eq!(results.len(), 2);
1754 assert_eq!(results[0].action_name, "GetDiskUsage");
1756 assert_eq!(results[1].action_name, "UpdateSystem");
1757 }
1758
1759 #[test]
1760 fn list_history_populates_created_at_and_risk_level() {
1761 let dir = tempdir().unwrap();
1762 let store = test_store(dir.path().join("tx.db"));
1763 store.record(queued_transaction()).unwrap();
1764
1765 let entries = store.list_history(10, None, None, None).unwrap();
1766 assert_eq!(entries.len(), 1);
1767 let entry = &entries[0];
1768 assert_eq!(entry.action_name, "UpdateSystem");
1769 assert_eq!(entry.risk_level, RiskLevel::High);
1770 assert_eq!(entry.status, JobState::Queued);
1771 assert!(
1772 !entry.created_at.is_empty(),
1773 "created_at must be populated from the stored row, not left blank"
1774 );
1775 }
1776
1777 #[test]
1778 fn list_history_applies_the_same_filters_as_list_transactions() {
1779 let dir = tempdir().unwrap();
1780 let store = test_store(dir.path().join("tx.db"));
1781 store.record(queued_transaction()).unwrap();
1782 let mut low = queued_transaction();
1783 low.action_name = "GetDiskUsage".to_string();
1784 low.risk_level = RiskLevel::Low;
1785 store.record(low).unwrap();
1786
1787 let only = store
1788 .list_history(10, None, Some("GetDiskUsage"), None)
1789 .unwrap();
1790 assert_eq!(only.len(), 1);
1791 assert_eq!(only[0].action_name, "GetDiskUsage");
1792 }
1793
1794 #[test]
1795 fn list_transactions_respects_limit() {
1796 let dir = tempdir().unwrap();
1797 let store = test_store(dir.path().join("tx.db"));
1798 for _ in 0..5 {
1799 store.record(queued_transaction()).unwrap();
1800 }
1801 let results = store.list_transactions(3, None, None, None).unwrap();
1802 assert_eq!(results.len(), 3);
1803 }
1804
1805 #[test]
1806 fn list_transactions_filters_by_status() {
1807 let dir = tempdir().unwrap();
1808 let store = test_store(dir.path().join("tx.db"));
1809 let tx = store.record(queued_transaction()).unwrap();
1810 store
1811 .update_status(&tx.transaction_id, JobState::Running)
1812 .unwrap();
1813 store
1814 .update_status(&tx.transaction_id, JobState::Succeeded)
1815 .unwrap();
1816
1817 store.record(queued_transaction()).unwrap();
1819
1820 let succeeded = store
1821 .list_transactions(10, Some("succeeded"), None, None)
1822 .unwrap();
1823 assert_eq!(succeeded.len(), 1);
1824 assert_eq!(succeeded[0].status, JobState::Succeeded);
1825
1826 let queued = store
1827 .list_transactions(10, Some("queued"), None, None)
1828 .unwrap();
1829 assert_eq!(queued.len(), 1);
1830 assert_eq!(queued[0].status, JobState::Queued);
1831 }
1832
1833 #[test]
1834 fn list_transactions_filters_by_action_name() {
1835 let dir = tempdir().unwrap();
1836 let store = test_store(dir.path().join("tx.db"));
1837 store.record(queued_transaction()).unwrap(); let mut disk = queued_transaction();
1840 disk.action_name = "GetDiskUsage".to_string();
1841 store.record(disk).unwrap();
1842
1843 let results = store
1844 .list_transactions(10, None, Some("GetDiskUsage"), None)
1845 .unwrap();
1846 assert_eq!(results.len(), 1);
1847 assert_eq!(results[0].action_name, "GetDiskUsage");
1848 }
1849
1850 #[test]
1851 fn list_transactions_filters_by_since_hours() {
1852 let dir = tempdir().unwrap();
1853 let store = test_store(dir.path().join("tx.db"));
1854
1855 let old = store.record(queued_transaction()).unwrap();
1857 let conn = store.connection().unwrap();
1858 conn.execute(
1859 "UPDATE transactions \
1860 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-48 hours') \
1861 WHERE transaction_id = ?1",
1862 params![old.transaction_id],
1863 )
1864 .unwrap();
1865
1866 store.record(queued_transaction()).unwrap();
1868
1869 let results = store.list_transactions(10, None, None, Some(24)).unwrap();
1871 assert_eq!(results.len(), 1);
1872
1873 let results = store.list_transactions(10, None, None, Some(72)).unwrap();
1875 assert_eq!(results.len(), 2);
1876 }
1877
1878 #[test]
1879 fn list_transactions_rejects_invalid_status_filter() {
1880 let dir = tempdir().unwrap();
1881 let store = test_store(dir.path().join("tx.db"));
1882 store.record(queued_transaction()).unwrap();
1883 let result = store.list_transactions(10, Some("bogus"), None, None);
1884 assert!(result.is_err(), "invalid status filter should return error");
1885 }
1886
1887 #[test]
1895 fn record_emits_one_watermark_per_entry() {
1896 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1897 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1898
1899 let dir = tempdir().unwrap();
1900 let store = test_store(dir.path().join("tx.db"));
1901 store.record(queued_transaction()).unwrap();
1902
1903 let calls = crate::audit_watermark::take_watermarks(&sink);
1904 assert_eq!(calls.len(), 1, "expected exactly 1 watermark per record()");
1905 }
1906
1907 #[test]
1909 fn record_previewed_emits_one_watermark() {
1910 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1911 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1912
1913 let dir = tempdir().unwrap();
1914 let store = test_store(dir.path().join("tx.db"));
1915 let preview = PreviewEnvelope {
1916 summary: "Upgrade the system".to_string(),
1917 risk_level: RiskLevel::High,
1918 current_state: serde_json::Value::Null,
1919 proposed_change: serde_json::Value::Null,
1920 expected_side_effects: vec![],
1921 reboot_required: false,
1922 rollback_available: false,
1923 warnings: vec![],
1924 request_hash: sysknife_types::RequestHash::from("hash-abc".to_string()),
1925 };
1926 store
1927 .record_previewed(queued_transaction(), preview)
1928 .unwrap();
1929
1930 let calls = crate::audit_watermark::take_watermarks(&sink);
1931 assert_eq!(
1932 calls.len(),
1933 1,
1934 "expected exactly 1 watermark per record_previewed()"
1935 );
1936 }
1937
1938 #[test]
1940 fn watermark_seq_and_hash_match_chain_row() {
1941 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1942 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1943
1944 let dir = tempdir().unwrap();
1945 let store = test_store(dir.path().join("tx.db"));
1946 store.record(queued_transaction()).unwrap();
1947
1948 let rows = store.fetch_chain_rows().unwrap();
1949 assert_eq!(rows.len(), 1);
1950 let row = &rows[0];
1951
1952 let calls = crate::audit_watermark::take_watermarks(&sink);
1953 assert_eq!(calls.len(), 1);
1954 assert_eq!(calls[0].seq, row.seq, "watermark seq must match chain row");
1955 assert_eq!(
1956 calls[0].chain_hash_hex, row.chain_hash,
1957 "watermark chain_hash_hex must match stored chain_hash"
1958 );
1959 }
1960
1961 #[test]
1963 fn multiple_records_produce_one_watermark_each() {
1964 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1965 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1966
1967 let dir = tempdir().unwrap();
1968 let store = test_store(dir.path().join("tx.db"));
1969 for _ in 0..3 {
1970 store.record(queued_transaction()).unwrap();
1971 }
1972
1973 let calls = crate::audit_watermark::take_watermarks(&sink);
1974 assert_eq!(calls.len(), 3, "one watermark per record call");
1975 assert_eq!(calls[0].seq, 1);
1976 assert_eq!(calls[1].seq, 2);
1977 assert_eq!(calls[2].seq, 3);
1978 }
1979
1980 #[test]
1994 fn no_watermark_emitted_before_any_record() {
1995 let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1996 crate::audit_watermark::install_test_sink(std::sync::Arc::clone(&sink));
1997
1998 let dir = tempdir().unwrap();
1999 let _store = test_store(dir.path().join("tx.db"));
2000
2001 let calls = crate::audit_watermark::take_watermarks(&sink);
2003 assert!(
2004 calls.is_empty(),
2005 "no watermark must be emitted without a record() call"
2006 );
2007 }
2008}