koi_embedded/testkit.rs
1//! Spin a real embedded Koi in a known posture for tests — **no Docker** (ADR-020 §13).
2//!
3//! Shipped as a normal module (not a `cfg(feature)` — the additive-feature trap):
4//! a consumer's integration tests get real-daemon fidelity by depending only on
5//! `koi-embedded`. [`open`] yields an Open node (no identity); [`secured`] yields
6//! an Authenticated node (a CA is created so it holds a real leaf).
7//!
8//! ## The "same code, both postures" acceptance gate (ADR-020 §2)
9//!
10//! The mode-transparency contract is: *one* consumer code path must work whether or
11//! not the node has an identity. The gate is simply to run that path against both:
12//!
13//! ```no_run
14//! # async fn gate() {
15//! use koi_embedded::testkit;
16//! for node in [testkit::open().await, testkit::secured().await] {
17//! let cm = node.certmesh().unwrap();
18//! let env = cm.sign(b"hello").await.unwrap(); // identical in both
19//! assert!(!cm.verify(&env).await.unwrap().is_rejected());
20//! node.shutdown().await;
21//! }
22//! # }
23//! ```
24//!
25//! If the body ever needs `if secure { … } else { … }`, a primitive is missing or
26//! wrong — that is exactly what this gate catches.
27//!
28//! Note: testkit nodes run with mDNS off (no multicast in CI); they exercise the
29//! trust primitives (sign/verify, seal/open, posture, diagnose), not LAN discovery.
30
31use std::path::PathBuf;
32use std::sync::atomic::{AtomicU64, Ordering};
33
34use crate::{Builder, KoiHandle, ServiceMode};
35
36/// A running embedded Koi node for a test, with its data dir cleaned up on
37/// [`shutdown`](TestNode::shutdown). Derefs to [`KoiHandle`], so call any handle
38/// method (`certmesh()`, `mdns()`, …) directly on it.
39pub struct TestNode {
40 handle: KoiHandle,
41 data_dir: PathBuf,
42}
43
44impl std::ops::Deref for TestNode {
45 type Target = KoiHandle;
46 fn deref(&self) -> &KoiHandle {
47 &self.handle
48 }
49}
50
51impl TestNode {
52 /// Shut the node down and remove its (isolated) data dir.
53 pub async fn shutdown(self) {
54 let _ = self.handle.shutdown().await;
55 let _ = std::fs::remove_dir_all(&self.data_dir);
56 }
57}
58
59/// A fresh, isolated, wiped data dir (unique per process + call).
60fn unique_dir(tag: &str) -> PathBuf {
61 static COUNTER: AtomicU64 = AtomicU64::new(0);
62 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
63 let dir = std::env::temp_dir().join(format!("koi-testkit-{tag}-{}-{n}", std::process::id()));
64 let _ = std::fs::remove_dir_all(&dir);
65 dir
66}
67
68/// Build a lean embedded node (certmesh on, everything else off) in its own dir.
69async fn build(tag: &str) -> TestNode {
70 let data_dir = unique_dir(tag);
71 let koi = Builder::new()
72 .data_dir(&data_dir)
73 .service_mode(ServiceMode::EmbeddedOnly)
74 .mdns(false)
75 .dns_enabled(false)
76 .health(false)
77 .certmesh(true)
78 .proxy(false)
79 .build()
80 .expect("testkit: build embedded");
81 let handle = koi.start().await.expect("testkit: start embedded");
82 TestNode { handle, data_dir }
83}
84
85/// An **Open** node — certmesh enabled but no CA, so it holds no identity. `sign`
86/// produces a freshness-stamped passthrough; `posture()` is `Open`.
87pub async fn open() -> TestNode {
88 build("open").await
89}
90
91/// A **secured (Authenticated)** node — a CA is created so the node self-enrolls a
92/// real leaf. `sign` produces an ES256-signed envelope; `posture()` is
93/// `Authenticated`. The CA is created with `auto_unlock: false` (no vault write).
94pub async fn secured() -> TestNode {
95 // testkit is a test harness; keep CA creation off the OS keyring deterministically.
96 std::env::set_var("KOI_NO_CREDENTIAL_STORE", "1");
97 let node = build("secured").await;
98 let core = node
99 .certmesh()
100 .expect("testkit: certmesh enabled")
101 .core()
102 .expect("testkit: embedded certmesh core");
103 core.create(koi_certmesh::protocol::CreateCaRequest {
104 passphrase: "testkit-passphrase".to_string(),
105 entropy_hex: "2a".repeat(32), // 32 bytes
106 operator: None,
107 enrollment_open: false,
108 requires_approval: false,
109 auto_unlock: false,
110 totp_secret_hex: None,
111 })
112 .await
113 .expect("testkit: create CA");
114 node
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[tokio::test]
122 async fn open_node_is_open_and_secured_node_is_authenticated() {
123 let open = open().await;
124 assert!(
125 !open.certmesh().unwrap().posture().unwrap().signed,
126 "open() must yield an Open node"
127 );
128 open.shutdown().await;
129
130 let secured = secured().await;
131 assert!(
132 secured.certmesh().unwrap().posture().unwrap().signed,
133 "secured() must yield an Authenticated node"
134 );
135 secured.shutdown().await;
136 }
137}