1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
//! End-to-end lifecycle regression: fresh vault → store → list →
//! decrypt → audit → revoke → list, with no platform GUI calls.
//!
//! Catches the bug class that bit us during the v0.3.0 hardening
//! pass — every one of the following was found by manually driving
//! the CLI and would have been a unit-test catch:
//!
//! - Audit log rotates on hash-chain mismatch instead of bricking
//! the vault. The dedicated `audit_rotates_on_corruption.rs`
//! regression already covers the rotation primitive in isolation;
//! this file proves the rotation works *during* a real lifecycle
//! call sequence, not just under direct `audit::log_at` calls.
//! - `store_secret_in` returns the new `Vec<Signal>` shape (was
//! `Vec<String>`). The Signal carries `id`, `severity`, `label`,
//! `detail`, `mitigation` instead of a pre-formatted string.
//! - The audit log has exactly the events the lifecycle is supposed
//! to emit, in the right order — no double-logging, no missing
//! `SecretRevoked`.
//! - Revoking removes the .seal file AND records the
//! `SecretRevoked` event in the same chain.
//! - The chain stays valid across multiple writes (regression
//! against accidental schema drift).
use envseal::ops;
use envseal::vault::Vault;
use std::path::PathBuf;
use zeroize::Zeroizing;
/// Pick a vault-safe scratch directory. On Linux/macOS, envseal hard-blocks
/// vault roots under `/tmp` (the parent is world-writable, which is a
/// poisoning vector), so we route through `~/.cache/envseal-core-tests/`
/// the same way `common::vault_tempdir()` does. On Windows, the OS-default
/// tempfile location is already user-private, so we use it directly. The
/// 0o700 perm is set on the leaf so a parallel test can't read this
/// run's vault state.
fn temp_root(name: &str) -> PathBuf {
#[cfg(unix)]
let base = {
let home =
std::env::var_os("HOME").expect("tests require HOME (vault-safe temp directories)");
let b = PathBuf::from(home)
.join(".cache")
.join("envseal-core-tests");
std::fs::create_dir_all(&b).unwrap();
b
};
#[cfg(not(unix))]
let base = std::env::temp_dir();
let dir = base.join(format!(
"envseal-e2e-{}-{}-{}",
name,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
}
dir
}
#[test]
fn full_lifecycle_store_list_audit_revoke() {
let root = temp_root("full_lifecycle");
let pass = Zeroizing::new("integration-test-passphrase".to_string());
// ── 1. Create the vault from a fresh root ─────────────────────
let vault = Vault::open_with_passphrase(&root, &pass).expect("vault create");
assert!(root.join("master.key").exists(), "master.key not written");
// ── 2. Store three secrets with distinct shapes ───────────────
// - one real-looking key (no entropy warnings expected)
// - one obvious placeholder (placeholder Signal expected)
// - one short value (too_short Signal expected)
let signals_real = ops::store_secret_in(
&vault,
"real-key",
b"sk-proj-abc123defghijklmnopqrstuvwxyz1234567890",
false,
)
.expect("store real-key");
let placeholder_signals =
ops::store_secret_in(&vault, "placeholder-key", b"your-api-key-here", false)
.expect("store placeholder-key");
let short_signals =
ops::store_secret_in(&vault, "short-key", b"abc", false).expect("store short-key");
// The Signal vec contract: each element has a stable id under
// `secret.entropy.*`. A real key produces no signals; a
// placeholder produces a placeholder signal; a 3-byte value
// produces too_short.
assert!(
!signals_real
.iter()
.any(|s| s.id.as_str().starts_with("secret.entropy.placeholder.")),
"real key wrongly flagged as placeholder"
);
assert!(
placeholder_signals
.iter()
.any(|s| s.id.as_str().starts_with("secret.entropy.placeholder.")),
"placeholder value missed by entropy check"
);
assert!(
short_signals
.iter()
.any(|s| s.id.as_str() == "secret.entropy.too_short"),
"short value missed by entropy check"
);
// ── 3. List returns all three ─────────────────────────────────
let names = ops::list_secret_names(&root).expect("list");
assert_eq!(names.len(), 3, "expected 3 secrets, got {}", names.len());
assert!(names.iter().any(|n| n == "real-key"));
assert!(names.iter().any(|n| n == "placeholder-key"));
assert!(names.iter().any(|n| n == "short-key"));
// ── 4. Decrypt round-trips ────────────────────────────────────
let plaintext = vault.decrypt("real-key").expect("decrypt real-key");
assert_eq!(
plaintext.as_slice(),
b"sk-proj-abc123defghijklmnopqrstuvwxyz1234567890",
"decrypted plaintext doesn't match what was stored"
);
// ── 5. Revoke one ─────────────────────────────────────────────
ops::revoke_with_policy(&vault, "placeholder-key").expect("revoke placeholder-key");
let names_after = ops::list_secret_names(&root).expect("list after revoke");
assert_eq!(names_after.len(), 2);
assert!(!names_after.iter().any(|n| n == "placeholder-key"));
// Per-vault file is gone.
assert!(!root.join("vault").join("placeholder-key.seal").exists());
// ── 6. Audit chain has the right events in the right order ───
// Event bodies are AES-GCM-sealed since 0.3.13; check via the
// parsed reader (which decrypts under the per-vault audit key)
// rather than substring-grep on the raw log.
let parsed_audit = envseal::audit::read_last_parsed_at(&root, 50);
let stored_count = parsed_audit
.entries
.iter()
.filter(|p| matches!(p.event, envseal::audit::AuditEvent::SecretStored { .. }))
.count();
let revoked_count = parsed_audit
.entries
.iter()
.filter(|p| matches!(p.event, envseal::audit::AuditEvent::SecretRevoked { .. }))
.count();
assert_eq!(
stored_count, 3,
"expected 3 stored events, got {stored_count}"
);
assert_eq!(
revoked_count, 1,
"expected 1 revoked event, got {revoked_count}"
);
// ── 7. Audit chain still verifies as a whole — no schema drift ─
// verify_chain_if_exists is internal; we exercise it indirectly
// by appending one more event and asserting it doesn't rotate
// (rotation creates a corrupted-* sibling).
ops::store_secret_in(&vault, "post-audit-check", b"sk-final-abc1234567", false)
.expect("post-audit store");
let corrupted_files: Vec<_> = std::fs::read_dir(&root)
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("audit.log.corrupted-")
})
.collect();
assert!(
corrupted_files.is_empty(),
"audit chain rotated mid-test — schema drift in the chain hash"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn second_unlock_after_close_round_trips() {
// Ensures the "open → drop → reopen with same passphrase" flow
// every CLI invocation actually does (each `envseal …` is a
// fresh process) doesn't subtly diverge from the in-process
// create flow.
let root = temp_root("reopen_round_trip");
let pass = Zeroizing::new("a-stable-passphrase-9".to_string());
{
let vault = Vault::open_with_passphrase(&root, &pass).expect("create");
let _ = ops::store_secret_in(&vault, "first-key", b"sk-test-aaaaaaaaaa1234", false)
.expect("first store");
}
let vault2 = Vault::open_with_passphrase(&root, &pass).expect("reopen");
let pt = vault2.decrypt("first-key").expect("decrypt after reopen");
assert_eq!(pt.as_slice(), b"sk-test-aaaaaaaaaa1234");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn wrong_passphrase_does_not_modify_vault() {
let root = temp_root("wrong_pass_no_corruption");
let good = Zeroizing::new("correct-pass-abcdefgh".to_string());
let bad = Zeroizing::new("wrong-pass-zzzzzzzz".to_string());
{
let v = Vault::open_with_passphrase(&root, &good).expect("create");
ops::store_secret_in(&v, "kept-key", b"sk-keep-abcdefghij", false).expect("store");
}
// Wrong passphrase fails cleanly.
let err = Vault::open_with_passphrase(&root, &bad).expect_err("wrong passphrase must fail");
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("wrong passphrase") || msg.contains("decryption failed"),
"expected wrong-passphrase error, got: {msg}"
);
// The vault is still openable with the right one and the secret
// is still there — no side-effect from the failed unlock attempt.
let v2 = Vault::open_with_passphrase(&root, &good).expect("reopen with correct");
let pt = v2
.decrypt("kept-key")
.expect("decrypt after wrong-pass attempt");
assert_eq!(pt.as_slice(), b"sk-keep-abcdefghij");
let _ = std::fs::remove_dir_all(&root);
}