Skip to main content

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}