@enactprotocol/secrets 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/package.json +30 -0
- package/src/dagger/index.ts +19 -0
- package/src/dagger/secret-object.ts +126 -0
- package/src/dagger/uri-parser.ts +202 -0
- package/src/env/index.ts +54 -0
- package/src/env/manager.ts +251 -0
- package/src/env/parser.ts +240 -0
- package/src/env/reader.ts +105 -0
- package/src/env/writer.ts +118 -0
- package/src/index.ts +120 -0
- package/src/keyring.ts +136 -0
- package/src/resolver.ts +184 -0
- package/src/types.ts +194 -0
- package/tests/dagger/secret-object.test.ts +217 -0
- package/tests/dagger/uri-parser.test.ts +194 -0
- package/tests/env/manager.test.ts +220 -0
- package/tests/env/parser.test.ts +352 -0
- package/tests/env/reader-writer.test.ts +257 -0
- package/tests/fixtures/complex.env +26 -0
- package/tests/fixtures/empty.env +0 -0
- package/tests/fixtures/simple.env +4 -0
- package/tests/keyring.test.ts +250 -0
- package/tests/mocks/keyring.ts +164 -0
- package/tests/resolver.test.ts +287 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { KEYRING_SERVICE } from "../src/types";
|
|
3
|
+
import {
|
|
4
|
+
createMockKeyringFunctions,
|
|
5
|
+
mockKeyring,
|
|
6
|
+
resetMockKeyring,
|
|
7
|
+
seedMockKeyring,
|
|
8
|
+
} from "./mocks/keyring";
|
|
9
|
+
|
|
10
|
+
describe("keyring", () => {
|
|
11
|
+
const testService = "test-enact-cli";
|
|
12
|
+
const { setSecret, getSecret, listSecrets, deleteSecret, secretExists, listAllSecrets } =
|
|
13
|
+
createMockKeyringFunctions(testService);
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
resetMockKeyring();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("KEYRING_SERVICE", () => {
|
|
20
|
+
it("should be defined as enact-cli", () => {
|
|
21
|
+
expect(KEYRING_SERVICE).toBe("enact-cli");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("setSecret", () => {
|
|
26
|
+
it("should store a secret in the keyring", async () => {
|
|
27
|
+
await setSecret("alice/api", "API_KEY", "secret123");
|
|
28
|
+
const stored = await mockKeyring.getPassword(testService, "alice/api:API_KEY");
|
|
29
|
+
expect(stored).toBe("secret123");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should store multiple secrets for different namespaces", async () => {
|
|
33
|
+
await setSecret("alice/api", "KEY1", "value1");
|
|
34
|
+
await setSecret("bob/api", "KEY2", "value2");
|
|
35
|
+
|
|
36
|
+
expect(await mockKeyring.getPassword(testService, "alice/api:KEY1")).toBe("value1");
|
|
37
|
+
expect(await mockKeyring.getPassword(testService, "bob/api:KEY2")).toBe("value2");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should overwrite existing secret", async () => {
|
|
41
|
+
await setSecret("alice/api", "API_KEY", "old-value");
|
|
42
|
+
await setSecret("alice/api", "API_KEY", "new-value");
|
|
43
|
+
|
|
44
|
+
expect(await mockKeyring.getPassword(testService, "alice/api:API_KEY")).toBe("new-value");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should handle empty namespace", async () => {
|
|
48
|
+
await setSecret("", "GLOBAL_KEY", "global-value");
|
|
49
|
+
expect(await mockKeyring.getPassword(testService, ":GLOBAL_KEY")).toBe("global-value");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should handle special characters in key names", async () => {
|
|
53
|
+
await setSecret("namespace", "MY_API_KEY_123", "special-value");
|
|
54
|
+
expect(await mockKeyring.getPassword(testService, "namespace:MY_API_KEY_123")).toBe(
|
|
55
|
+
"special-value"
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("getSecret", () => {
|
|
61
|
+
it("should retrieve a stored secret", async () => {
|
|
62
|
+
await setSecret("alice/api", "API_KEY", "secret123");
|
|
63
|
+
const result = await getSecret("alice/api", "API_KEY");
|
|
64
|
+
expect(result).toBe("secret123");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should return null for non-existent secret", async () => {
|
|
68
|
+
const result = await getSecret("alice/api", "NONEXISTENT");
|
|
69
|
+
expect(result).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should return null for non-existent namespace", async () => {
|
|
73
|
+
const result = await getSecret("nonexistent/namespace", "KEY");
|
|
74
|
+
expect(result).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should differentiate between namespaces", async () => {
|
|
78
|
+
await setSecret("alice/api", "API_KEY", "alice-secret");
|
|
79
|
+
await setSecret("bob/api", "API_KEY", "bob-secret");
|
|
80
|
+
|
|
81
|
+
const aliceResult = await getSecret("alice/api", "API_KEY");
|
|
82
|
+
const bobResult = await getSecret("bob/api", "API_KEY");
|
|
83
|
+
|
|
84
|
+
expect(aliceResult).toBe("alice-secret");
|
|
85
|
+
expect(bobResult).toBe("bob-secret");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("listSecrets", () => {
|
|
90
|
+
it("should return empty array for namespace with no secrets", async () => {
|
|
91
|
+
const result = await listSecrets("empty/namespace");
|
|
92
|
+
expect(result).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should list all secrets in a namespace", async () => {
|
|
96
|
+
await setSecret("alice/api", "KEY1", "value1");
|
|
97
|
+
await setSecret("alice/api", "KEY2", "value2");
|
|
98
|
+
await setSecret("alice/api", "KEY3", "value3");
|
|
99
|
+
|
|
100
|
+
const result = await listSecrets("alice/api");
|
|
101
|
+
expect(result).toHaveLength(3);
|
|
102
|
+
expect(result).toContain("KEY1");
|
|
103
|
+
expect(result).toContain("KEY2");
|
|
104
|
+
expect(result).toContain("KEY3");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should only list secrets for specified namespace", async () => {
|
|
108
|
+
await setSecret("alice/api", "ALICE_KEY", "alice-value");
|
|
109
|
+
await setSecret("bob/api", "BOB_KEY", "bob-value");
|
|
110
|
+
|
|
111
|
+
const aliceSecrets = await listSecrets("alice/api");
|
|
112
|
+
const bobSecrets = await listSecrets("bob/api");
|
|
113
|
+
|
|
114
|
+
expect(aliceSecrets).toEqual(["ALICE_KEY"]);
|
|
115
|
+
expect(bobSecrets).toEqual(["BOB_KEY"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("should handle nested namespaces correctly", async () => {
|
|
119
|
+
await setSecret("alice", "ROOT_KEY", "root-value");
|
|
120
|
+
await setSecret("alice/api", "API_KEY", "api-value");
|
|
121
|
+
await setSecret("alice/api/slack", "SLACK_KEY", "slack-value");
|
|
122
|
+
|
|
123
|
+
const rootSecrets = await listSecrets("alice");
|
|
124
|
+
const apiSecrets = await listSecrets("alice/api");
|
|
125
|
+
const slackSecrets = await listSecrets("alice/api/slack");
|
|
126
|
+
|
|
127
|
+
expect(rootSecrets).toEqual(["ROOT_KEY"]);
|
|
128
|
+
expect(apiSecrets).toEqual(["API_KEY"]);
|
|
129
|
+
expect(slackSecrets).toEqual(["SLACK_KEY"]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("deleteSecret", () => {
|
|
134
|
+
it("should delete an existing secret", async () => {
|
|
135
|
+
await setSecret("alice/api", "API_KEY", "secret123");
|
|
136
|
+
const deleted = await deleteSecret("alice/api", "API_KEY");
|
|
137
|
+
|
|
138
|
+
expect(deleted).toBe(true);
|
|
139
|
+
expect(await getSecret("alice/api", "API_KEY")).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should return false for non-existent secret", async () => {
|
|
143
|
+
const deleted = await deleteSecret("alice/api", "NONEXISTENT");
|
|
144
|
+
expect(deleted).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should only delete specified secret", async () => {
|
|
148
|
+
await setSecret("alice/api", "KEY1", "value1");
|
|
149
|
+
await setSecret("alice/api", "KEY2", "value2");
|
|
150
|
+
|
|
151
|
+
await deleteSecret("alice/api", "KEY1");
|
|
152
|
+
|
|
153
|
+
expect(await getSecret("alice/api", "KEY1")).toBeNull();
|
|
154
|
+
expect(await getSecret("alice/api", "KEY2")).toBe("value2");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should not affect other namespaces", async () => {
|
|
158
|
+
await setSecret("alice/api", "KEY", "alice-value");
|
|
159
|
+
await setSecret("bob/api", "KEY", "bob-value");
|
|
160
|
+
|
|
161
|
+
await deleteSecret("alice/api", "KEY");
|
|
162
|
+
|
|
163
|
+
expect(await getSecret("alice/api", "KEY")).toBeNull();
|
|
164
|
+
expect(await getSecret("bob/api", "KEY")).toBe("bob-value");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("secretExists", () => {
|
|
169
|
+
it("should return true for existing secret", async () => {
|
|
170
|
+
await setSecret("namespace", "KEY", "value");
|
|
171
|
+
const exists = await secretExists("namespace", "KEY");
|
|
172
|
+
expect(exists).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should return false for non-existent secret", async () => {
|
|
176
|
+
const exists = await secretExists("namespace", "NONEXISTENT");
|
|
177
|
+
expect(exists).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("listAllSecrets", () => {
|
|
182
|
+
it("should return empty array when no secrets exist", async () => {
|
|
183
|
+
const result = await listAllSecrets();
|
|
184
|
+
expect(result).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should list all secrets across all namespaces", async () => {
|
|
188
|
+
await setSecret("alice/api", "KEY1", "value1");
|
|
189
|
+
await setSecret("bob/api", "KEY2", "value2");
|
|
190
|
+
await setSecret("charlie", "KEY3", "value3");
|
|
191
|
+
|
|
192
|
+
const result = await listAllSecrets();
|
|
193
|
+
expect(result).toHaveLength(3);
|
|
194
|
+
expect(result).toContainEqual({ namespace: "alice/api", key: "KEY1" });
|
|
195
|
+
expect(result).toContainEqual({ namespace: "bob/api", key: "KEY2" });
|
|
196
|
+
expect(result).toContainEqual({ namespace: "charlie", key: "KEY3" });
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("seedMockKeyring", () => {
|
|
201
|
+
it("should seed the mock keyring with test data", async () => {
|
|
202
|
+
seedMockKeyring(testService, [
|
|
203
|
+
{ namespace: "alice/api", name: "KEY1", value: "value1" },
|
|
204
|
+
{ namespace: "bob/api", name: "KEY2", value: "value2" },
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const result1 = await getSecret("alice/api", "KEY1");
|
|
208
|
+
const result2 = await getSecret("bob/api", "KEY2");
|
|
209
|
+
|
|
210
|
+
expect(result1).toBe("value1");
|
|
211
|
+
expect(result2).toBe("value2");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("edge cases", () => {
|
|
216
|
+
it("should handle secrets with empty values", async () => {
|
|
217
|
+
await setSecret("namespace", "EMPTY_KEY", "");
|
|
218
|
+
const result = await getSecret("namespace", "EMPTY_KEY");
|
|
219
|
+
expect(result).toBe("");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should handle secrets with special characters in values", async () => {
|
|
223
|
+
const specialValue = "pass=word!@#$%^&*()[]{}|;':\",./<>?`~";
|
|
224
|
+
await setSecret("namespace", "SPECIAL_KEY", specialValue);
|
|
225
|
+
const result = await getSecret("namespace", "SPECIAL_KEY");
|
|
226
|
+
expect(result).toBe(specialValue);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("should handle secrets with newlines", async () => {
|
|
230
|
+
const multilineValue = "line1\nline2\nline3";
|
|
231
|
+
await setSecret("namespace", "MULTILINE_KEY", multilineValue);
|
|
232
|
+
const result = await getSecret("namespace", "MULTILINE_KEY");
|
|
233
|
+
expect(result).toBe(multilineValue);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("should handle unicode in values", async () => {
|
|
237
|
+
const unicodeValue = "emoji: 🔐 日本語 中文";
|
|
238
|
+
await setSecret("namespace", "UNICODE_KEY", unicodeValue);
|
|
239
|
+
const result = await getSecret("namespace", "UNICODE_KEY");
|
|
240
|
+
expect(result).toBe(unicodeValue);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("should handle very long namespace paths", async () => {
|
|
244
|
+
const longNamespace = "org/team/project/service/component/instance";
|
|
245
|
+
await setSecret(longNamespace, "KEY", "deep-value");
|
|
246
|
+
const result = await getSecret(longNamespace, "KEY");
|
|
247
|
+
expect(result).toBe("deep-value");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock keyring for testing
|
|
3
|
+
*
|
|
4
|
+
* Provides an in-memory implementation of the keyring interface
|
|
5
|
+
* that can be used in tests without touching the real system keyring.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SecretMetadata } from "../../src/types";
|
|
9
|
+
|
|
10
|
+
interface Credential {
|
|
11
|
+
account: string;
|
|
12
|
+
password: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* In-memory credential store
|
|
17
|
+
*/
|
|
18
|
+
const store = new Map<string, Map<string, string>>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Mock keyring implementation
|
|
22
|
+
*/
|
|
23
|
+
export const mockKeyring = {
|
|
24
|
+
/**
|
|
25
|
+
* Set a password in the mock keyring
|
|
26
|
+
*/
|
|
27
|
+
async setPassword(service: string, account: string, password: string): Promise<void> {
|
|
28
|
+
if (!store.has(service)) {
|
|
29
|
+
store.set(service, new Map());
|
|
30
|
+
}
|
|
31
|
+
store.get(service)?.set(account, password);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get a password from the mock keyring
|
|
36
|
+
*/
|
|
37
|
+
async getPassword(service: string, account: string): Promise<string | null> {
|
|
38
|
+
const serviceStore = store.get(service);
|
|
39
|
+
if (!serviceStore) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return serviceStore.get(account) ?? null;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Delete a password from the mock keyring
|
|
47
|
+
*/
|
|
48
|
+
async deletePassword(service: string, account: string): Promise<boolean> {
|
|
49
|
+
const serviceStore = store.get(service);
|
|
50
|
+
if (!serviceStore) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return serviceStore.delete(account);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find all credentials for a service
|
|
58
|
+
*/
|
|
59
|
+
async findCredentials(service: string): Promise<Credential[]> {
|
|
60
|
+
const serviceStore = store.get(service);
|
|
61
|
+
if (!serviceStore) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const credentials: Credential[] = [];
|
|
66
|
+
for (const [account, password] of serviceStore.entries()) {
|
|
67
|
+
credentials.push({ account, password });
|
|
68
|
+
}
|
|
69
|
+
return credentials;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Clear all credentials (for test cleanup)
|
|
74
|
+
*/
|
|
75
|
+
clear(): void {
|
|
76
|
+
store.clear();
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Clear credentials for a specific service
|
|
81
|
+
*/
|
|
82
|
+
clearService(service: string): void {
|
|
83
|
+
store.delete(service);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get all stored credentials (for debugging)
|
|
88
|
+
*/
|
|
89
|
+
getAll(): Map<string, Map<string, string>> {
|
|
90
|
+
return new Map(store);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create mock keyring functions that use the mock store
|
|
96
|
+
* These have the same signature as the real keyring functions
|
|
97
|
+
*/
|
|
98
|
+
export function createMockKeyringFunctions(service: string) {
|
|
99
|
+
const getSecretFn = async (namespace: string, name: string): Promise<string | null> => {
|
|
100
|
+
const account = `${namespace}:${name}`;
|
|
101
|
+
return mockKeyring.getPassword(service, account);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
async setSecret(namespace: string, name: string, value: string): Promise<void> {
|
|
106
|
+
const account = `${namespace}:${name}`;
|
|
107
|
+
await mockKeyring.setPassword(service, account, value);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
getSecret: getSecretFn,
|
|
111
|
+
|
|
112
|
+
async deleteSecret(namespace: string, name: string): Promise<boolean> {
|
|
113
|
+
const account = `${namespace}:${name}`;
|
|
114
|
+
return mockKeyring.deletePassword(service, account);
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async listSecrets(namespace: string): Promise<string[]> {
|
|
118
|
+
const credentials = await mockKeyring.findCredentials(service);
|
|
119
|
+
const prefix = `${namespace}:`;
|
|
120
|
+
return credentials
|
|
121
|
+
.filter((cred) => cred.account.startsWith(prefix))
|
|
122
|
+
.map((cred) => cred.account.slice(prefix.length));
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async listAllSecrets(): Promise<SecretMetadata[]> {
|
|
126
|
+
const credentials = await mockKeyring.findCredentials(service);
|
|
127
|
+
return credentials.map((cred) => {
|
|
128
|
+
const colonIndex = cred.account.lastIndexOf(":");
|
|
129
|
+
return {
|
|
130
|
+
key: cred.account.slice(colonIndex + 1),
|
|
131
|
+
namespace: cred.account.slice(0, colonIndex),
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async secretExists(namespace: string, name: string): Promise<boolean> {
|
|
137
|
+
const value = await getSecretFn(namespace, name);
|
|
138
|
+
return value !== null;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Reset the mock keyring between tests
|
|
145
|
+
*/
|
|
146
|
+
export function resetMockKeyring(): void {
|
|
147
|
+
mockKeyring.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Seed the mock keyring with test data
|
|
152
|
+
*/
|
|
153
|
+
export function seedMockKeyring(
|
|
154
|
+
service: string,
|
|
155
|
+
secrets: Array<{ namespace: string; name: string; value: string }>
|
|
156
|
+
): void {
|
|
157
|
+
for (const { namespace, name, value } of secrets) {
|
|
158
|
+
const account = `${namespace}:${name}`;
|
|
159
|
+
if (!store.has(service)) {
|
|
160
|
+
store.set(service, new Map());
|
|
161
|
+
}
|
|
162
|
+
store.get(service)?.set(account, value);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { KEYRING_SERVICE } from "../src/types";
|
|
3
|
+
import { mockKeyring, resetMockKeyring, seedMockKeyring } from "./mocks/keyring";
|
|
4
|
+
|
|
5
|
+
// Mock the keyring module before importing resolver
|
|
6
|
+
mock.module("../src/keyring", () => ({
|
|
7
|
+
getSecret: async (namespace: string, name: string) => {
|
|
8
|
+
const account = `${namespace}:${name}`;
|
|
9
|
+
return mockKeyring.getPassword(KEYRING_SERVICE, account);
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Import after mocking
|
|
14
|
+
const {
|
|
15
|
+
getNamespaceChain,
|
|
16
|
+
resolveSecret,
|
|
17
|
+
traceSecretResolution,
|
|
18
|
+
resolveSecrets,
|
|
19
|
+
checkRequiredSecrets,
|
|
20
|
+
} = await import("../src/resolver");
|
|
21
|
+
|
|
22
|
+
describe("resolver", () => {
|
|
23
|
+
const testService = KEYRING_SERVICE;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
resetMockKeyring();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("getNamespaceChain", () => {
|
|
30
|
+
it("should return the full namespace chain for a path", () => {
|
|
31
|
+
const chain = getNamespaceChain("alice/api/slack");
|
|
32
|
+
expect(chain).toEqual(["alice/api/slack", "alice/api", "alice"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should return single element for simple namespace", () => {
|
|
36
|
+
const chain = getNamespaceChain("alice");
|
|
37
|
+
expect(chain).toEqual(["alice"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return empty array for empty string", () => {
|
|
41
|
+
const chain = getNamespaceChain("");
|
|
42
|
+
expect(chain).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should handle deep namespaces", () => {
|
|
46
|
+
const chain = getNamespaceChain("org/team/project/service/component");
|
|
47
|
+
expect(chain).toEqual([
|
|
48
|
+
"org/team/project/service/component",
|
|
49
|
+
"org/team/project/service",
|
|
50
|
+
"org/team/project",
|
|
51
|
+
"org/team",
|
|
52
|
+
"org",
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should handle leading/trailing slashes", () => {
|
|
57
|
+
const chain = getNamespaceChain("/alice/api/");
|
|
58
|
+
expect(chain).toEqual(["alice/api", "alice"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should handle multiple consecutive slashes", () => {
|
|
62
|
+
const chain = getNamespaceChain("alice//api");
|
|
63
|
+
expect(chain).toEqual(["alice/api", "alice"]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("resolveSecret", () => {
|
|
68
|
+
it("should find secret in exact namespace", async () => {
|
|
69
|
+
seedMockKeyring(testService, [
|
|
70
|
+
{ namespace: "alice/api/slack", name: "API_TOKEN", value: "slack-token" },
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const result = await resolveSecret("alice/api/slack", "API_TOKEN");
|
|
74
|
+
|
|
75
|
+
expect(result.found).toBe(true);
|
|
76
|
+
if (result.found) {
|
|
77
|
+
expect(result.namespace).toBe("alice/api/slack");
|
|
78
|
+
expect(result.value).toBe("slack-token");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should walk up namespace chain to find secret", async () => {
|
|
83
|
+
seedMockKeyring(testService, [
|
|
84
|
+
{ namespace: "alice/api", name: "API_TOKEN", value: "api-level-token" },
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
const result = await resolveSecret("alice/api/slack", "API_TOKEN");
|
|
88
|
+
|
|
89
|
+
expect(result.found).toBe(true);
|
|
90
|
+
if (result.found) {
|
|
91
|
+
expect(result.namespace).toBe("alice/api");
|
|
92
|
+
expect(result.value).toBe("api-level-token");
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should find secret at root namespace", async () => {
|
|
97
|
+
seedMockKeyring(testService, [
|
|
98
|
+
{ namespace: "alice", name: "SHARED_KEY", value: "root-level-key" },
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const result = await resolveSecret("alice/api/slack", "SHARED_KEY");
|
|
102
|
+
|
|
103
|
+
expect(result.found).toBe(true);
|
|
104
|
+
if (result.found) {
|
|
105
|
+
expect(result.namespace).toBe("alice");
|
|
106
|
+
expect(result.value).toBe("root-level-key");
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should prefer more specific namespace", async () => {
|
|
111
|
+
seedMockKeyring(testService, [
|
|
112
|
+
{ namespace: "alice", name: "API_TOKEN", value: "root-token" },
|
|
113
|
+
{ namespace: "alice/api", name: "API_TOKEN", value: "api-token" },
|
|
114
|
+
{ namespace: "alice/api/slack", name: "API_TOKEN", value: "slack-token" },
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
const result = await resolveSecret("alice/api/slack", "API_TOKEN");
|
|
118
|
+
|
|
119
|
+
expect(result.found).toBe(true);
|
|
120
|
+
if (result.found) {
|
|
121
|
+
expect(result.namespace).toBe("alice/api/slack");
|
|
122
|
+
expect(result.value).toBe("slack-token");
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should return not found for missing secret", async () => {
|
|
127
|
+
const result = await resolveSecret("alice/api/slack", "NONEXISTENT");
|
|
128
|
+
|
|
129
|
+
expect(result.found).toBe(false);
|
|
130
|
+
if (!result.found) {
|
|
131
|
+
expect(result.searchedNamespaces).toEqual(["alice/api/slack", "alice/api", "alice"]);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should include key in result", async () => {
|
|
136
|
+
seedMockKeyring(testService, [{ namespace: "alice", name: "MY_SECRET", value: "value" }]);
|
|
137
|
+
|
|
138
|
+
const result = await resolveSecret("alice/api", "MY_SECRET");
|
|
139
|
+
|
|
140
|
+
expect(result.key).toBe("MY_SECRET");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("traceSecretResolution", () => {
|
|
145
|
+
it("should trace all checked namespaces", async () => {
|
|
146
|
+
seedMockKeyring(testService, [{ namespace: "alice/api", name: "API_TOKEN", value: "token" }]);
|
|
147
|
+
|
|
148
|
+
const trace = await traceSecretResolution("alice/api/slack", "API_TOKEN");
|
|
149
|
+
|
|
150
|
+
expect(trace.key).toBe("API_TOKEN");
|
|
151
|
+
expect(trace.toolPath).toBe("alice/api/slack");
|
|
152
|
+
expect(trace.entries).toHaveLength(3);
|
|
153
|
+
|
|
154
|
+
// First entry (most specific) - not found
|
|
155
|
+
const entry0 = trace.entries[0];
|
|
156
|
+
const entry1 = trace.entries[1];
|
|
157
|
+
const entry2 = trace.entries[2];
|
|
158
|
+
expect(entry0).toBeDefined();
|
|
159
|
+
expect(entry1).toBeDefined();
|
|
160
|
+
expect(entry2).toBeDefined();
|
|
161
|
+
|
|
162
|
+
expect(entry0?.namespace).toBe("alice/api/slack");
|
|
163
|
+
expect(entry0?.found).toBe(false);
|
|
164
|
+
|
|
165
|
+
// Second entry - found
|
|
166
|
+
expect(entry1?.namespace).toBe("alice/api");
|
|
167
|
+
expect(entry1?.found).toBe(true);
|
|
168
|
+
|
|
169
|
+
// Third entry - not found (but would have been checked)
|
|
170
|
+
expect(entry2?.namespace).toBe("alice");
|
|
171
|
+
expect(entry2?.found).toBe(false);
|
|
172
|
+
|
|
173
|
+
// Result should be the first found
|
|
174
|
+
expect(trace.result.found).toBe(true);
|
|
175
|
+
if (trace.result.found) {
|
|
176
|
+
expect(trace.result.namespace).toBe("alice/api");
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should trace all namespaces when secret not found", async () => {
|
|
181
|
+
const trace = await traceSecretResolution("alice/api", "MISSING");
|
|
182
|
+
|
|
183
|
+
expect(trace.entries).toHaveLength(2);
|
|
184
|
+
expect(trace.entries.every((e) => !e.found)).toBe(true);
|
|
185
|
+
expect(trace.result.found).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("should include account format in entries", async () => {
|
|
189
|
+
seedMockKeyring(testService, [{ namespace: "alice", name: "KEY", value: "value" }]);
|
|
190
|
+
|
|
191
|
+
const trace = await traceSecretResolution("alice/api", "KEY");
|
|
192
|
+
const entry0 = trace.entries[0];
|
|
193
|
+
const entry1 = trace.entries[1];
|
|
194
|
+
|
|
195
|
+
expect(entry0?.account).toBe("alice/api:KEY");
|
|
196
|
+
expect(entry1?.account).toBe("alice:KEY");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("resolveSecrets", () => {
|
|
201
|
+
it("should resolve multiple secrets", async () => {
|
|
202
|
+
seedMockKeyring(testService, [
|
|
203
|
+
{ namespace: "alice", name: "KEY1", value: "value1" },
|
|
204
|
+
{ namespace: "alice/api", name: "KEY2", value: "value2" },
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const results = await resolveSecrets("alice/api", ["KEY1", "KEY2", "KEY3"]);
|
|
208
|
+
|
|
209
|
+
expect(results.size).toBe(3);
|
|
210
|
+
|
|
211
|
+
const key1 = results.get("KEY1");
|
|
212
|
+
expect(key1?.found).toBe(true);
|
|
213
|
+
if (key1?.found) {
|
|
214
|
+
expect(key1.value).toBe("value1");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const key2 = results.get("KEY2");
|
|
218
|
+
expect(key2?.found).toBe(true);
|
|
219
|
+
if (key2?.found) {
|
|
220
|
+
expect(key2.value).toBe("value2");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const key3 = results.get("KEY3");
|
|
224
|
+
expect(key3?.found).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should return empty map for empty secret list", async () => {
|
|
228
|
+
const results = await resolveSecrets("alice/api", []);
|
|
229
|
+
expect(results.size).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("checkRequiredSecrets", () => {
|
|
234
|
+
it("should report all found when all secrets exist", async () => {
|
|
235
|
+
seedMockKeyring(testService, [
|
|
236
|
+
{ namespace: "alice", name: "KEY1", value: "value1" },
|
|
237
|
+
{ namespace: "alice/api", name: "KEY2", value: "value2" },
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const check = await checkRequiredSecrets("alice/api", ["KEY1", "KEY2"]);
|
|
241
|
+
|
|
242
|
+
expect(check.allFound).toBe(true);
|
|
243
|
+
expect(check.found).toHaveLength(2);
|
|
244
|
+
expect(check.missing).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should report missing secrets", async () => {
|
|
248
|
+
seedMockKeyring(testService, [{ namespace: "alice", name: "KEY1", value: "value1" }]);
|
|
249
|
+
|
|
250
|
+
const check = await checkRequiredSecrets("alice/api", ["KEY1", "KEY2", "KEY3"]);
|
|
251
|
+
|
|
252
|
+
expect(check.allFound).toBe(false);
|
|
253
|
+
expect(check.found).toHaveLength(1);
|
|
254
|
+
expect(check.missing).toEqual(["KEY2", "KEY3"]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("should report all missing when no secrets exist", async () => {
|
|
258
|
+
const check = await checkRequiredSecrets("alice/api", ["KEY1", "KEY2"]);
|
|
259
|
+
|
|
260
|
+
expect(check.allFound).toBe(false);
|
|
261
|
+
expect(check.found).toHaveLength(0);
|
|
262
|
+
expect(check.missing).toEqual(["KEY1", "KEY2"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("should return allFound true for empty required list", async () => {
|
|
266
|
+
const check = await checkRequiredSecrets("alice/api", []);
|
|
267
|
+
|
|
268
|
+
expect(check.allFound).toBe(true);
|
|
269
|
+
expect(check.found).toHaveLength(0);
|
|
270
|
+
expect(check.missing).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("should include resolution details in found results", async () => {
|
|
274
|
+
seedMockKeyring(testService, [
|
|
275
|
+
{ namespace: "alice", name: "SHARED_KEY", value: "shared-value" },
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
const check = await checkRequiredSecrets("alice/api/slack", ["SHARED_KEY"]);
|
|
279
|
+
const foundResult = check.found[0];
|
|
280
|
+
expect(foundResult).toBeDefined();
|
|
281
|
+
|
|
282
|
+
expect(foundResult?.namespace).toBe("alice");
|
|
283
|
+
expect(foundResult?.value).toBe("shared-value");
|
|
284
|
+
expect(foundResult?.key).toBe("SHARED_KEY");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|