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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
//! Integration tests for `tsafe kv-push` (ADR-030 write contract).
//!
//! Uses the same local-test-seam pattern as `test_pull.rs`:
//! `TSAFE_AKV_TEST_LOCAL_URL` + `TSAFE_AKV_TEST_TOKEN` in debug builds
//! bypass real Azure auth so tests run against a mockito server.
use assert_cmd::Command;
use mockito::Server;
use predicates::prelude::*;
use tempfile::tempdir;
fn tsafe() -> Command {
Command::cargo_bin("tsafe").unwrap()
}
fn init_vault(dir: &std::path::Path) {
tsafe()
.args(["--profile", "default", "init"])
.env("TSAFE_VAULT_DIR", dir)
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success();
}
fn set_secret(dir: &std::path::Path, key: &str, value: &str) {
tsafe()
.args(["--profile", "default", "set", key, value])
.env("TSAFE_VAULT_DIR", dir)
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success();
}
/// Build a `kv-push` command that points at a local mockito server.
fn kv_push_local_test_cmd(server_url: &str) -> Command {
let mut cmd = tsafe();
cmd.arg("--profile")
.arg("default")
.env("TSAFE_AKV_TEST_LOCAL_URL", server_url)
.env("TSAFE_AKV_TEST_TOKEN", "tok")
.env_remove("TSAFE_AKV_URL")
.env_remove("AZURE_TENANT_ID")
.env_remove("AZURE_CLIENT_ID")
.env_remove("AZURE_CLIENT_SECRET");
cmd
}
/// Register a mock empty list endpoint so `pull_secrets` (used for remote fetch) returns nothing.
fn mock_empty_remote(server: &mut Server) {
server
.mock("GET", mockito::Matcher::Regex(r"^/secrets\?".to_string()))
.match_header("Authorization", "Bearer tok")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
}
/// Register an existing secret in the remote (for the list + get path used in pull_secrets).
fn mock_remote_with_secret(server: &mut Server, name: &str, value: &str) {
// List endpoint returns one secret.
server
.mock("GET", mockito::Matcher::Regex(r"^/secrets\?".to_string()))
.match_header("Authorization", "Bearer tok")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(
r#"{{"value":[{{"id":"https://vault/secrets/{name}","attributes":{{"enabled":true}}}}]}}"#
))
.create();
// Get endpoint returns the secret value.
server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"^/secrets/{name}\?").to_string()),
)
.match_header("Authorization", "Bearer tok")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(r#"{{"value":"{value}"}}"#))
.create();
}
/// Register a PUT endpoint that accepts a secret and returns success.
/// Also mocks the preceding GET (which `push_secret` uses to check the current value)
/// to return 404 — simulating a secret absent from the remote vault.
fn mock_put_secret(server: &mut Server, name: &str) -> mockito::Mock {
// GET returns 404 so push_secret detects the secret as absent (create path).
server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"^/secrets/{name}\?").to_string()),
)
.match_header("Authorization", "Bearer tok")
.with_status(404)
.with_header("Content-Type", "application/json")
.with_body(r#"{"error":{"code":"SecretNotFound"}}"#)
.create();
// PUT creates the secret.
server
.mock(
"PUT",
mockito::Matcher::Regex(format!(r"^/secrets/{name}\?").to_string()),
)
.match_header("Authorization", "Bearer tok")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(format!(
r#"{{"id":"https://vault/secrets/{name}/v1","value":"secret"}}"#
))
.create()
}
// ── Test 1: dry-run shows diff without writing ────────────────────────────────
#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_dry_run_shows_diff_without_writing() {
let dir = tempdir().unwrap();
init_vault(dir.path());
set_secret(dir.path(), "MY_SECRET", "hello");
let mut server = Server::new();
mock_empty_remote(&mut server);
// Deliberately do NOT register a PUT mock — if any write is made the test
// will fail with "unexpected call" or "no mock matched" from mockito.
let no_put_mock = server.mock("PUT", mockito::Matcher::Any).expect(0).create();
kv_push_local_test_cmd(&server.url())
.args(["kv-push", "--dry-run"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stdout(predicate::str::contains("create").or(predicate::str::contains("update")))
.stdout(predicate::str::contains("Dry-run complete"));
no_put_mock.assert(); // 0 PUT calls made
}
// ── Test 2: abort without --yes in non-interactive ───────────────────────────
#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_aborts_without_yes_in_non_interactive() {
let dir = tempdir().unwrap();
init_vault(dir.path());
set_secret(dir.path(), "SOME_KEY", "value123");
let mut server = Server::new();
mock_empty_remote(&mut server);
// No PUT should be made.
let no_put_mock = server.mock("PUT", mockito::Matcher::Any).expect(0).create();
// stdin is not a TTY in test processes; without --yes this must fail.
kv_push_local_test_cmd(&server.url())
.args(["kv-push"]) // no --yes
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.failure()
.stderr(predicate::str::contains("--yes"));
no_put_mock.assert();
}
// ── Test 3: creates new secrets when remote is empty ─────────────────────────
#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_creates_new_secrets() {
let dir = tempdir().unwrap();
init_vault(dir.path());
set_secret(dir.path(), "DB_PASSWORD", "s3cr3t");
let mut server = Server::new();
mock_empty_remote(&mut server);
let put_mock = mock_put_secret(&mut server, "db-password");
kv_push_local_test_cmd(&server.url())
.args(["kv-push", "--yes"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stdout(predicate::str::contains("created"));
put_mock.assert(); // exactly 1 PUT to /secrets/db-password
}
// ── Test 4: collision detection aborts before any write ──────────────────────
#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_collision_detection_aborts_before_any_write() {
// MY_SECRET and MY-SECRET (if local vault allowed hyphen in key) would both
// normalise to "my-secret". We test with two keys that both normalise the same way.
// Use a namespace that has two keys mapping to the same provider name:
// e.g. MY_KEY and MY__KEY → "my-key" and "my--key" are different.
// The actual collision case: store a key whose normalized form matches another.
// Since local vault key names are typically UPPER_SNAKE, let's test the scenario
// via a namespace: ns/MY_SECRET and ns/MY-SECRET — but vault keys can't easily
// have hyphens. Instead, test with "API_KEY" and "API-KEY" stored both as
// normal vault keys if vault allows it.
//
// The simplest reproducible collision in our normalization:
// "MY_DB_PASS" → "my-db-pass"
// There is no collision with a single unique key; we need two that map the same.
//
// Our normalization: replace '_' with '-', lowercase.
// So "MY__SECRET" → "my--secret" (still distinct from "my-secret").
// A real collision requires two local keys where after replace+lowercase they match.
// Example: "MYKEY" → "mykey", "mykey" → "mykey" — but vault key names are case-sensitive.
// Actually, two separate keys stored as "MY_KEY" and "MY_KEY" is impossible (same key).
//
// The collision can only happen if the *normalized* result is identical.
// Given normalization = replace('_', '-').to_lowercase():
// "MY_KEY" → "my-key"
// The only way to collide is via case: local vault stores "MY_KEY" and "my_key".
// Both normalize to "my-key".
let dir = tempdir().unwrap();
init_vault(dir.path());
// Store two keys that normalize to the same provider name.
// "MY_KEY" and "my_key" both become "my-key" after our reverse-normalization.
set_secret(dir.path(), "MY_KEY", "value-a");
set_secret(dir.path(), "my_key", "value-b");
let mut server = Server::new();
mock_empty_remote(&mut server);
let no_put_mock = server.mock("PUT", mockito::Matcher::Any).expect(0).create();
kv_push_local_test_cmd(&server.url())
.args(["kv-push", "--yes"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.failure()
.stderr(predicate::str::contains("collision").or(predicate::str::contains("my-key")));
no_put_mock.assert(); // no writes made before collision detection
}
// ── Test 5: unchanged secrets produce no writes ───────────────────────────────
#[cfg(feature = "akv-pull")]
#[test]
fn kv_push_unchanged_secrets_produce_no_write_calls() {
let dir = tempdir().unwrap();
init_vault(dir.path());
set_secret(dir.path(), "API_TOKEN", "identical-value");
let mut server = Server::new();
// Remote has the same secret with the same value (normalized key "api-token").
mock_remote_with_secret(&mut server, "api-token", "identical-value");
let no_put_mock = server.mock("PUT", mockito::Matcher::Any).expect(0).create();
kv_push_local_test_cmd(&server.url())
.args(["kv-push", "--yes"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "test-pw")
.assert()
.success()
.stdout(predicate::str::contains("unchanged").or(predicate::str::contains("up to date")));
no_put_mock.assert(); // no write calls for identical value
}