|
1 | 1 | /** |
2 | 2 | * Contract tests for the secret-store abstraction. |
3 | 3 | * |
4 | | - * The keyring-backed default implementation needs platform native bindings |
5 | | - * (libsecret on Linux) that aren't reliably present in CI, so the suite |
6 | | - * exercises the `InMemorySecretStore` here — the same interface the |
7 | | - * production code is tested against via the `/api/servers` integration |
8 | | - * suite (which injects this in-memory impl). Coverage of the keyring |
9 | | - * adapter itself is exercised by hand on macOS/Windows during development. |
| 4 | + * `InMemorySecretStore` is exercised directly. `KeyringSecretStore` is |
| 5 | + * exercised via a `vi.mock` of `@napi-rs/keyring` — the native bindings |
| 6 | + * aren't reliably present in CI (Linux runners ship without libsecret), |
| 7 | + * so the suite stubs the native side and asserts the tolerance contract |
| 8 | + * (`get` returns null on failure, destructive ops no-op, `set` is the |
| 9 | + * one operation that hard-fails with `KeychainUnavailableError`). |
10 | 10 | */ |
11 | | -import { describe, it, expect, beforeEach } from "vitest"; |
| 11 | +import { describe, it, expect, beforeEach, vi } from "vitest"; |
| 12 | + |
| 13 | +// The mock must be hoisted above the `await import` of secret-store |
| 14 | +// inside the `KeyringSecretStore` describe block. Use `vi.hoisted` so |
| 15 | +// references are captured before the import is evaluated. |
| 16 | +const keyringMocks = vi.hoisted(() => { |
| 17 | + const password = new Map<string, string | null>(); |
| 18 | + const reset = () => password.clear(); |
| 19 | + // Behavior hooks each test can flip to simulate keychain unavailability |
| 20 | + // on specific operations. Defaults: real-ish in-memory behavior. |
| 21 | + const failures = { |
| 22 | + getThrows: false, |
| 23 | + setThrows: false, |
| 24 | + deleteThrows: false, |
| 25 | + findThrows: false, |
| 26 | + deleteThrowsNoEntry: false, |
| 27 | + }; |
| 28 | + const credentials = (): Array<{ account: string; password: string }> => { |
| 29 | + const out: Array<{ account: string; password: string }> = []; |
| 30 | + for (const [k, v] of password.entries()) { |
| 31 | + if (v !== null) out.push({ account: k, password: v }); |
| 32 | + } |
| 33 | + return out; |
| 34 | + }; |
| 35 | + class AsyncEntry { |
| 36 | + private readonly key: string; |
| 37 | + constructor(_service: string, username: string) { |
| 38 | + this.key = username; |
| 39 | + } |
| 40 | + async getPassword(): Promise<string | undefined> { |
| 41 | + if (failures.getThrows) throw new Error("keychain get unavailable"); |
| 42 | + const v = password.get(this.key); |
| 43 | + return v === undefined || v === null ? undefined : v; |
| 44 | + } |
| 45 | + async setPassword(value: string): Promise<void> { |
| 46 | + if (failures.setThrows) throw new Error("keychain set unavailable"); |
| 47 | + password.set(this.key, value); |
| 48 | + } |
| 49 | + async deleteCredential(): Promise<boolean> { |
| 50 | + if (failures.deleteThrowsNoEntry) throw new Error("No entry found"); |
| 51 | + if (failures.deleteThrows) throw new Error("keychain delete unavailable"); |
| 52 | + return password.delete(this.key); |
| 53 | + } |
| 54 | + } |
| 55 | + const findCredentialsAsync = async (): Promise< |
| 56 | + Array<{ account: string; password: string }> |
| 57 | + > => { |
| 58 | + if (failures.findThrows) throw new Error("keychain find unavailable"); |
| 59 | + return credentials(); |
| 60 | + }; |
| 61 | + return { AsyncEntry, findCredentialsAsync, failures, password, reset }; |
| 62 | +}); |
| 63 | + |
| 64 | +vi.mock("@napi-rs/keyring", () => ({ |
| 65 | + AsyncEntry: keyringMocks.AsyncEntry, |
| 66 | + findCredentialsAsync: keyringMocks.findCredentialsAsync, |
| 67 | +})); |
| 68 | + |
12 | 69 | import { |
13 | 70 | InMemorySecretStore, |
| 71 | + KeyringSecretStore, |
| 72 | + KeychainUnavailableError, |
14 | 73 | SECRET_FIELD_OAUTH_CLIENT_SECRET, |
15 | 74 | envSecretField, |
| 75 | + parseAccount, |
16 | 76 | type SecretStore, |
17 | 77 | } from "@inspector/core/auth/node/secret-store.js"; |
18 | 78 |
|
@@ -119,3 +179,147 @@ describe("InMemorySecretStore", () => { |
119 | 179 | expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(""); |
120 | 180 | }); |
121 | 181 | }); |
| 182 | + |
| 183 | +describe("parseAccount", () => { |
| 184 | + it("splits `${serverId}:${field}` on the first colon", () => { |
| 185 | + expect(parseAccount("srv:oauth-client-secret")).toEqual({ |
| 186 | + serverId: "srv", |
| 187 | + field: "oauth-client-secret", |
| 188 | + }); |
| 189 | + }); |
| 190 | + |
| 191 | + it("allows the field to contain colons (env:KEY uses one)", () => { |
| 192 | + expect(parseAccount("srv:env:API_KEY")).toEqual({ |
| 193 | + serverId: "srv", |
| 194 | + field: "env:API_KEY", |
| 195 | + }); |
| 196 | + }); |
| 197 | + |
| 198 | + it("returns null when no separator is present", () => { |
| 199 | + expect(parseAccount("noseparator")).toBe(null); |
| 200 | + }); |
| 201 | + |
| 202 | + it("returns null for a leading or trailing colon (empty side)", () => { |
| 203 | + expect(parseAccount(":field")).toBe(null); |
| 204 | + expect(parseAccount("srv:")).toBe(null); |
| 205 | + }); |
| 206 | +}); |
| 207 | + |
| 208 | +describe("KeyringSecretStore (mocked native bindings)", () => { |
| 209 | + let store: KeyringSecretStore; |
| 210 | + |
| 211 | + beforeEach(() => { |
| 212 | + keyringMocks.reset(); |
| 213 | + keyringMocks.failures.getThrows = false; |
| 214 | + keyringMocks.failures.setThrows = false; |
| 215 | + keyringMocks.failures.deleteThrows = false; |
| 216 | + keyringMocks.failures.findThrows = false; |
| 217 | + keyringMocks.failures.deleteThrowsNoEntry = false; |
| 218 | + store = new KeyringSecretStore(); |
| 219 | + }); |
| 220 | + |
| 221 | + it("round-trips a set then get", async () => { |
| 222 | + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); |
| 223 | + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( |
| 224 | + "shh", |
| 225 | + ); |
| 226 | + }); |
| 227 | + |
| 228 | + it("get returns null when getPassword throws (keychain unavailable)", async () => { |
| 229 | + // get is tolerant: there's no value to surface so degrading to "null" |
| 230 | + // matches the absence semantic the caller already handles. |
| 231 | + keyringMocks.failures.getThrows = true; |
| 232 | + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( |
| 233 | + null, |
| 234 | + ); |
| 235 | + }); |
| 236 | + |
| 237 | + it("get returns null when the underlying entry is absent (no value set)", async () => { |
| 238 | + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( |
| 239 | + null, |
| 240 | + ); |
| 241 | + }); |
| 242 | + |
| 243 | + it("set throws KeychainUnavailableError when setPassword throws", async () => { |
| 244 | + // set is the one operation that hard-fails — losing data silently |
| 245 | + // is worse than surfacing a clear error the user can act on. |
| 246 | + keyringMocks.failures.setThrows = true; |
| 247 | + await expect( |
| 248 | + store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v"), |
| 249 | + ).rejects.toBeInstanceOf(KeychainUnavailableError); |
| 250 | + }); |
| 251 | + |
| 252 | + it("delete silently treats a 'no entry' error as success", async () => { |
| 253 | + keyringMocks.failures.deleteThrowsNoEntry = true; |
| 254 | + await expect( |
| 255 | + store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET), |
| 256 | + ).resolves.toBeUndefined(); |
| 257 | + }); |
| 258 | + |
| 259 | + it("delete silently no-ops when the keychain is unavailable", async () => { |
| 260 | + keyringMocks.failures.deleteThrows = true; |
| 261 | + await expect( |
| 262 | + store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET), |
| 263 | + ).resolves.toBeUndefined(); |
| 264 | + }); |
| 265 | + |
| 266 | + it("delete actually removes the value when the keychain is available", async () => { |
| 267 | + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v"); |
| 268 | + await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET); |
| 269 | + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( |
| 270 | + null, |
| 271 | + ); |
| 272 | + }); |
| 273 | + |
| 274 | + it("deleteAllForServer no-ops when findCredentialsAsync throws", async () => { |
| 275 | + // We don't even know what was written, so there's nothing to sweep. |
| 276 | + // Critically, this must not throw — the route's defensive sweep on |
| 277 | + // POST and DELETE depends on it. |
| 278 | + keyringMocks.failures.findThrows = true; |
| 279 | + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); |
| 280 | + }); |
| 281 | + |
| 282 | + it("deleteAllForServer removes every entry under the given id", async () => { |
| 283 | + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); |
| 284 | + await store.set("alpha", envSecretField("K"), "b"); |
| 285 | + await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "untouched"); |
| 286 | + |
| 287 | + await store.deleteAllForServer("alpha"); |
| 288 | + |
| 289 | + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( |
| 290 | + null, |
| 291 | + ); |
| 292 | + expect(await store.get("alpha", envSecretField("K"))).toBe(null); |
| 293 | + expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( |
| 294 | + "untouched", |
| 295 | + ); |
| 296 | + }); |
| 297 | + |
| 298 | + it("deleteAllForServer ignores entries on a different id that share a prefix", async () => { |
| 299 | + // The `parseAccount` check guards against a literal startsWith match |
| 300 | + // wrongly sweeping `alpha-prime:...` when deleting `alpha`. |
| 301 | + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); |
| 302 | + await store.set("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET, "p"); |
| 303 | + |
| 304 | + await store.deleteAllForServer("alpha"); |
| 305 | + |
| 306 | + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( |
| 307 | + null, |
| 308 | + ); |
| 309 | + expect( |
| 310 | + await store.get("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET), |
| 311 | + ).toBe("p"); |
| 312 | + }); |
| 313 | + |
| 314 | + it("KeychainUnavailableError carries the underlying error message", async () => { |
| 315 | + keyringMocks.failures.setThrows = true; |
| 316 | + try { |
| 317 | + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v"); |
| 318 | + throw new Error("expected throw"); |
| 319 | + } catch (err) { |
| 320 | + expect(err).toBeInstanceOf(KeychainUnavailableError); |
| 321 | + expect((err as Error).message).toMatch(/keychain set unavailable/); |
| 322 | + expect((err as Error).message).toMatch(/libsecret/); |
| 323 | + } |
| 324 | + }); |
| 325 | +}); |
0 commit comments