feat(auth): file-backed and in-memory SecretStore for hosts with no keychain
Closes #1950 Before this, a box with no reachable OS keychain — the published container (no D-Bus session, #1848), Android/Termux (no platform binary, #1905), a minimal Linux install without libsecret — could not persist an OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore` degraded into a store that silently holds nothing: `get` returned null, `delete` no-opped, and `set` hard-failed with a 503. That is the correct failure behavior, but it left the documented, supported way to run the Inspector with no secret persistence whatsoever. Adds the two missing implementations and, more importantly, the policy that picks one and the surfaces that say which was picked. - `FileSecretStore` — one `0600` JSON document at `~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever `MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The whole map is encrypted as a unit rather than value-by-value so the account names — `${serverId}:${field}` — are hidden too; a per-value scheme would leave a readable index of which servers you hold a client secret for. A file that can no longer be decrypted reads as empty (matching the interface's read tolerance) but *refuses to be written*, because replacing a file of still-valid secrets to satisfy an additive request destroys data. - `secret-store-selection.ts` — the policy. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright; otherwise probe the keychain and, if unreachable, fall back loudly. The fallback is `memory` in a container whose secrets directory is not on a mount, and `file` otherwise — so mounting the volume the README already recommends for the catalog flips the same run to durable storage with no configuration, and an unmounted container gets the honest answer rather than a file `--rm` will discard. Cached per process so the banner, `/api/config`, and the store doing the writing cannot disagree. - Automatic, but never silent. The downgrade prints at startup, rides `GET /api/config`, and is named in a permanent 32px footer at the bottom of both dialogs that accept a secret. A startup banner is seen once by whoever started the process; a toast is seen once; a dismissible banner is the thing a user dismisses before doing the work it describes. The plaintext and memory cases render their caveat inline in a warning tone — a warning you must hover to discover is not one. - `SecretStoreUnavailableError` is now the base every store's `set` throws, so the routes translate one condition into a 503 instead of matching `KeychainUnavailableError` by name. Docs: the container section of the root README gains a table of which store you get and how to make secrets survive, plus the `MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules. Tests: the existing interface contract block is extracted to `secretStoreContract.ts` and run against all three implementations, so none can drift on the availability contract. `FileSecretStore` is tested against a real temp filesystem — mode, cross-instance round-trip, that the ciphertext does not contain the plaintext, the read-modify-write race, and every refusal path. Full `npm run ci` passes, per-file gate included. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
43525d9ea2
commit
bc408f5869
4 changed files with 2827 additions and 146 deletions
|
|
@ -0,0 +1,411 @@
|
|||
/**
|
||||
* `FileSecretStore` against a real filesystem (#1950).
|
||||
*
|
||||
* A real temp directory rather than a mocked `fs`: the properties worth
|
||||
* asserting here are properties *of the file* — its mode, that a second
|
||||
* store reading it back sees the same secrets, that the ciphertext does
|
||||
* not contain the plaintext — and a mocked filesystem can be made to
|
||||
* agree with any of those without them being true.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
FileSecretStore,
|
||||
SecretFileKeyMismatchError,
|
||||
tightenSecretFilePermissions,
|
||||
} from "@inspector/core/auth/node/file-secret-store.js";
|
||||
import { SecretStoreUnavailableError } from "@inspector/core/auth/node/secret-store.js";
|
||||
import { SECRET_FIELD_OAUTH_CLIENT_SECRET } from "@inspector/core/auth/secret-fields.js";
|
||||
import { describeSecretStoreContract } from "./secretStoreContract.js";
|
||||
|
||||
let tmpDir: string;
|
||||
const filePath = (): string => path.join(tmpDir, "secrets.json");
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "inspector-secrets-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Both modes must satisfy the same contract — encryption is a property of
|
||||
// the bytes on disk, not of the interface, and a caller must not be able to
|
||||
// tell which one it got.
|
||||
describeSecretStoreContract(
|
||||
"FileSecretStore (plaintext)",
|
||||
() => new FileSecretStore({ filePath: filePath() }),
|
||||
);
|
||||
describeSecretStoreContract(
|
||||
"FileSecretStore (encrypted)",
|
||||
() => new FileSecretStore({ filePath: filePath(), passphrase: "hunter2" }),
|
||||
);
|
||||
|
||||
describe("FileSecretStore persistence", () => {
|
||||
it("a second store instance reads what the first wrote", async () => {
|
||||
const a = new FileSecretStore({ filePath: filePath() });
|
||||
await a.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
const b = new FileSecretStore({ filePath: filePath() });
|
||||
expect(await b.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe("shh");
|
||||
});
|
||||
|
||||
it("round-trips through a real encrypt/decrypt cycle across instances", async () => {
|
||||
const a = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
await a.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
const b = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
expect(await b.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe("shh");
|
||||
});
|
||||
|
||||
it("writes the file 0600", async () => {
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
const stat = await fs.stat(filePath());
|
||||
// Windows does not model POSIX modes; asserting there would fail for a
|
||||
// reason that has nothing to do with this code.
|
||||
if (process.platform !== "win32") {
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it("concurrent sets do not lose each other (the read-modify-write race)", async () => {
|
||||
// The route handlers write a server's fields with `Promise.all`, so this
|
||||
// is the ordinary path. Without the internal queue both calls read the
|
||||
// same empty map and the second write drops the first entry.
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await Promise.all([
|
||||
store.set("alpha", "env:A", "1"),
|
||||
store.set("alpha", "env:B", "2"),
|
||||
store.set("alpha", "env:C", "3"),
|
||||
]);
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
expect(await store.get("alpha", "env:B")).toBe("2");
|
||||
expect(await store.get("alpha", "env:C")).toBe("3");
|
||||
});
|
||||
|
||||
it("keeps the file after the last entry is deleted", async () => {
|
||||
// The file's presence records which encryption mode this install settled
|
||||
// on; unlinking it would let a later write silently switch modes.
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
await store.deleteAllForServer("alpha");
|
||||
await expect(fs.stat(filePath())).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileSecretStore at rest", () => {
|
||||
it("plaintext mode stores the value readably (the state the warning is about)", async () => {
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
expect(store.encrypted).toBe(false);
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "super-secret");
|
||||
const raw = await fs.readFile(filePath(), "utf-8");
|
||||
expect(raw).toContain("super-secret");
|
||||
expect(JSON.parse(raw).encryption).toBe("none");
|
||||
});
|
||||
|
||||
it("encrypted mode leaves neither the value nor the account name on disk", async () => {
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
expect(store.encrypted).toBe(true);
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "super-secret");
|
||||
const raw = await fs.readFile(filePath(), "utf-8");
|
||||
expect(raw).not.toContain("super-secret");
|
||||
// The account names are encrypted too — that's why the map is encrypted
|
||||
// as a unit rather than value-by-value, since the key list would
|
||||
// otherwise index which servers hold which kind of secret.
|
||||
expect(raw).not.toContain("alpha");
|
||||
expect(raw).not.toContain(SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed.encryption).toBe("aes-256-gcm");
|
||||
expect(parsed.kdf.algorithm).toBe("scrypt");
|
||||
});
|
||||
|
||||
it("treats a blank passphrase as no passphrase", async () => {
|
||||
// `MCP_INSPECTOR_SECRET_KEY=` in a compose file means "off", not a
|
||||
// zero-length key — and silently encrypting with "" would be worse than
|
||||
// either reading.
|
||||
expect(
|
||||
new FileSecretStore({ filePath: filePath(), passphrase: " " })
|
||||
.encrypted,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("upgrades an existing plaintext file to encrypted on the next write", async () => {
|
||||
const plain = new FileSecretStore({ filePath: filePath() });
|
||||
await plain.set("alpha", "env:A", "1");
|
||||
const encrypted = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
// Reads the plaintext file it inherited...
|
||||
expect(await encrypted.get("alpha", "env:A")).toBe("1");
|
||||
// ...and the next write carries the old entry into the encrypted form,
|
||||
// so setting the env var does not strand the secrets already stored.
|
||||
await encrypted.set("alpha", "env:B", "2");
|
||||
const raw = await fs.readFile(filePath(), "utf-8");
|
||||
expect(JSON.parse(raw).encryption).toBe("aes-256-gcm");
|
||||
expect(await encrypted.get("alpha", "env:A")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileSecretStore failure handling", () => {
|
||||
const writeEncryptedFixture = async (): Promise<void> => {
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "right-key",
|
||||
});
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
};
|
||||
|
||||
it("get returns null when the passphrase is wrong (read tolerance)", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "wrong-key",
|
||||
});
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("set refuses rather than overwriting a file it cannot decrypt", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "wrong-key",
|
||||
});
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretFileKeyMismatchError,
|
||||
);
|
||||
// The original secrets survive: the refusal is the whole point.
|
||||
const original = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "right-key",
|
||||
});
|
||||
expect(await original.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"shh",
|
||||
);
|
||||
});
|
||||
|
||||
it("set names the missing passphrase when the key was removed entirely", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
|
||||
/MCP_INSPECTOR_SECRET_KEY is not set/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a plaintext file that carries no secrets key as empty", async () => {
|
||||
// A hand-edited (or hand-created) file is the realistic source of this
|
||||
// shape, and it must read as "no secrets yet" rather than throwing: the
|
||||
// document is well-formed and declares its encryption mode, so the only
|
||||
// thing missing is entries. Flattening it to `{}` is also what lets the
|
||||
// next `set` add one instead of refusing.
|
||||
await fs.writeFile(
|
||||
filePath(),
|
||||
JSON.stringify({ version: 1, encryption: "none" }),
|
||||
"utf-8",
|
||||
);
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"shh",
|
||||
);
|
||||
});
|
||||
|
||||
it("delete stays silent on a file it cannot decrypt", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "wrong-key",
|
||||
});
|
||||
await expect(
|
||||
store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("set reports a corrupt file rather than silently replacing it", async () => {
|
||||
await fs.writeFile(filePath(), "{ not json", "utf-8");
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretStoreUnavailableError,
|
||||
);
|
||||
expect(await store.get("alpha", "env:A")).toBe(null);
|
||||
});
|
||||
|
||||
it("set reports a file written in a format this build doesn't know", async () => {
|
||||
// The forward-compat case: a newer Inspector's file, opened by an older
|
||||
// one. Refusing beats truncating it to the entries we happen to parse.
|
||||
await fs.writeFile(
|
||||
filePath(),
|
||||
JSON.stringify({ version: 99, encryption: "chacha20-poly1305" }),
|
||||
"utf-8",
|
||||
);
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
|
||||
/unsupported format/,
|
||||
);
|
||||
});
|
||||
|
||||
it("set reports a truncated ciphertext payload", async () => {
|
||||
await writeEncryptedFixture();
|
||||
const parsed = JSON.parse(await fs.readFile(filePath(), "utf-8"));
|
||||
parsed.data = "only-one-part";
|
||||
await fs.writeFile(filePath(), JSON.stringify(parsed), "utf-8");
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "right-key",
|
||||
});
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretFileKeyMismatchError,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a file whose KDF parameters are unusable", async () => {
|
||||
// scrypt rejects on out-of-grammar cost parameters (`N` must be a power of
|
||||
// two > 1). Those parameters come off disk, so this is a corrupt-file case
|
||||
// reachable without touching the crypto call site — and it must land as a
|
||||
// refusal, not as an exception escaping the derivation.
|
||||
await writeEncryptedFixture();
|
||||
const parsed = JSON.parse(await fs.readFile(filePath(), "utf-8"));
|
||||
parsed.kdf.N = 3;
|
||||
await fs.writeFile(filePath(), JSON.stringify(parsed), "utf-8");
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "right-key",
|
||||
});
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretFileKeyMismatchError,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a read failure that isn't a missing file", async () => {
|
||||
// A directory where the file should be: `readStoreFile` only translates
|
||||
// ENOENT into "no file yet", so anything else must surface as a refusal
|
||||
// rather than being read as an empty store and overwritten.
|
||||
const asDir = path.join(tmpDir, "as-dir");
|
||||
await fs.mkdir(asDir);
|
||||
const store = new FileSecretStore({ filePath: asDir });
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf(
|
||||
SecretStoreUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it("a rejected set leaves the same store usable for later operations", async () => {
|
||||
// The internal queue swallows failures on its chain so one rejection can't
|
||||
// strand every subsequent call on that store — the returned promise still
|
||||
// rejects, which is what the caller sees.
|
||||
await writeEncryptedFixture();
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "wrong-key",
|
||||
});
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow();
|
||||
expect(await store.get("alpha", "env:A")).toBe(null);
|
||||
await expect(store.delete("alpha", "env:A")).resolves.toBeUndefined();
|
||||
await expect(store.set("alpha", "env:B", "2")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("delete is a no-op when the file exists but holds no matching entry", async () => {
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await store.set("alpha", "env:A", "1");
|
||||
await store.delete("beta", "env:A");
|
||||
await store.deleteAllForServer("gamma");
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
});
|
||||
|
||||
it("set reports an unreachable location instead of failing silently", async () => {
|
||||
// A path whose parent is a *file*: the read fails with ENOTDIR rather
|
||||
// than the ENOENT that means "no file yet", so this lands on the read
|
||||
// refusal. It is the class the 503 exists for — fixable outside the
|
||||
// process.
|
||||
const blocker = path.join(tmpDir, "blocker");
|
||||
await fs.writeFile(blocker, "x", "utf-8");
|
||||
const store = new FileSecretStore({
|
||||
filePath: path.join(blocker, "secrets.json"),
|
||||
});
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
|
||||
/Could not read the secrets file/,
|
||||
);
|
||||
});
|
||||
|
||||
// A read that succeeds (or reports "no file yet") followed by a write that
|
||||
// fails — a read-only mount, a full disk, a permissions change since
|
||||
// startup. Reproduced with a non-writable directory, which requires not
|
||||
// being root: `chmod` is advisory to uid 0, so under root the write would
|
||||
// succeed and the assertion would be wrong rather than merely unexercised.
|
||||
// CI runs as an unprivileged user, which is where the coverage gate is
|
||||
// enforced.
|
||||
const asRoot = process.getuid?.() === 0;
|
||||
it.skipIf(asRoot || process.platform === "win32")(
|
||||
"set reports a write failure distinctly from a read failure",
|
||||
async () => {
|
||||
const dir = path.join(tmpDir, "locked");
|
||||
await fs.mkdir(dir);
|
||||
await fs.chmod(dir, 0o555);
|
||||
try {
|
||||
const store = new FileSecretStore({
|
||||
filePath: path.join(dir, "secrets.json"),
|
||||
});
|
||||
// Nothing is there to read, so the failure is unambiguously the write.
|
||||
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
|
||||
/Could not write the secrets file/,
|
||||
);
|
||||
} finally {
|
||||
// Restore write permission or the afterEach `rm -rf` can't clean up.
|
||||
await fs.chmod(dir, 0o755);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("a rejected set does not poison later sets on the same store", async () => {
|
||||
// The internal queue swallows on the chain but not on the returned
|
||||
// promise; without that, one failure would strand every subsequent write.
|
||||
const blocked = path.join(tmpDir, "blocker");
|
||||
await fs.writeFile(blocked, "x", "utf-8");
|
||||
const bad = new FileSecretStore({
|
||||
filePath: path.join(blocked, "secrets.json"),
|
||||
});
|
||||
await expect(bad.set("alpha", "env:A", "1")).rejects.toThrow();
|
||||
|
||||
// A different store on a good path still works, and so does a second
|
||||
// write on the failing one's successor.
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
await store.set("alpha", "env:A", "1");
|
||||
await store.set("alpha", "env:B", "2");
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
expect(await store.get("alpha", "env:B")).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tightenSecretFilePermissions", () => {
|
||||
it("repairs a loosened mode on an existing file", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
await fs.writeFile(filePath(), "{}", "utf-8");
|
||||
await fs.chmod(filePath(), 0o644);
|
||||
await tightenSecretFilePermissions(filePath());
|
||||
expect((await fs.stat(filePath())).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("is a no-op for a file that doesn't exist yet (the first-run case)", async () => {
|
||||
await expect(
|
||||
tightenSecretFilePermissions(path.join(tmpDir, "nope.json")),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
/**
|
||||
* The store-selection policy (#1950).
|
||||
*
|
||||
* The two questions this file exists to pin down are the ones a reviewer
|
||||
* would want proof of, because getting either wrong silently changes where
|
||||
* a user's secret goes:
|
||||
*
|
||||
* 1. an unreachable keychain falls back rather than failing, and says so;
|
||||
* 2. the fallback is `memory` in a container with nothing durable
|
||||
* mounted, and `file` otherwise.
|
||||
*
|
||||
* `resolveSecretStore` caches its answer for the process, so each case
|
||||
* that exercises it goes through `vi.resetModules()` + a fresh dynamic
|
||||
* import — the same technique the keyring-unloadable cases in
|
||||
* `secret-store.test.ts` use, and for the same reason. The keychain probe
|
||||
* is mocked because CI has no libsecret; the container and mount
|
||||
* predicates are driven directly rather than through a real container,
|
||||
* which is what makes them worth exporting.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
chooseFallbackKind,
|
||||
isOnMountPoint,
|
||||
parseSecretStoreEnv,
|
||||
warnAboutSecretStorage,
|
||||
} from "@inspector/core/auth/node/secret-store-selection.js";
|
||||
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"MCP_INSPECTOR_SECRET_STORE",
|
||||
"MCP_INSPECTOR_SECRET_FILE",
|
||||
"MCP_INSPECTOR_SECRET_KEY",
|
||||
"KUBERNETES_SERVICE_HOST",
|
||||
];
|
||||
|
||||
let saved: Record<string, string | undefined>;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
for (const k of ENV_KEYS) delete process.env[k];
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "inspector-selection-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Load a fresh copy of the selection module with the keychain probe forced
|
||||
* to a given answer. Fresh because the resolution is cached per module
|
||||
* instance, which is exactly the behavior we don't want leaking between
|
||||
* cases.
|
||||
*/
|
||||
async function loadWithProbe(available: boolean, detail = "no D-Bus session") {
|
||||
vi.resetModules();
|
||||
vi.doMock("@inspector/core/auth/node/secret-store.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@inspector/core/auth/node/secret-store.js")
|
||||
>("@inspector/core/auth/node/secret-store.js");
|
||||
return {
|
||||
...actual,
|
||||
probeKeyringAvailable: vi.fn(async () =>
|
||||
available ? { available: true } : { available: false, detail },
|
||||
),
|
||||
};
|
||||
});
|
||||
return import("@inspector/core/auth/node/secret-store-selection.js");
|
||||
}
|
||||
|
||||
describe("parseSecretStoreEnv", () => {
|
||||
it("accepts each kind, case- and whitespace-insensitively", () => {
|
||||
expect(parseSecretStoreEnv("keyring")).toBe("keyring");
|
||||
expect(parseSecretStoreEnv(" FILE ")).toBe("file");
|
||||
expect(parseSecretStoreEnv("Memory")).toBe("memory");
|
||||
});
|
||||
|
||||
it("treats unset and blank as unset", () => {
|
||||
expect(parseSecretStoreEnv(undefined)).toBeUndefined();
|
||||
expect(parseSecretStoreEnv(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns and falls through on a typo rather than refusing to start", () => {
|
||||
// A misspelled env var in a compose file should not be the reason the
|
||||
// Inspector won't boot — the automatic policy is a working default.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(parseSecretStoreEnv("keychain")).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("keyring, file, memory"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chooseFallbackKind", () => {
|
||||
it("uses memory in a container with nothing mounted", () => {
|
||||
// The honest answer: a file in the writable layer is discarded by
|
||||
// `docker run --rm` and by any image update, so promising durability
|
||||
// there is worse than declining to.
|
||||
expect(chooseFallbackKind({ container: true, mounted: false })).toBe(
|
||||
"memory",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses file in a container once a volume is mounted", () => {
|
||||
// Mounting the volume *is* the user saying this directory should
|
||||
// survive, so no extra configuration should be needed to honor it.
|
||||
expect(chooseFallbackKind({ container: true, mounted: true })).toBe("file");
|
||||
});
|
||||
|
||||
it("uses file outside a container, mounted or not", () => {
|
||||
expect(chooseFallbackKind({ container: false, mounted: false })).toBe(
|
||||
"file",
|
||||
);
|
||||
expect(chooseFallbackKind({ container: false, mounted: true })).toBe(
|
||||
"file",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isContainer", () => {
|
||||
/**
|
||||
* Load the module with `node:fs` stubbed, so each detection signal can be
|
||||
* exercised on a developer's laptop. The three exist because no single one
|
||||
* covers the runtimes people use — Docker, Podman/containerd, and
|
||||
* Kubernetes each announce themselves differently.
|
||||
*/
|
||||
async function loadWithFs(opts: {
|
||||
dockerenv?: boolean;
|
||||
cgroup?: string | Error;
|
||||
}) {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (p: string) =>
|
||||
p === "/.dockerenv" ? !!opts.dockerenv : actual.existsSync(p),
|
||||
readFileSync: (p: string, enc?: unknown) => {
|
||||
if (p === "/proc/1/cgroup") {
|
||||
if (opts.cgroup instanceof Error) throw opts.cgroup;
|
||||
if (opts.cgroup === undefined) throw new Error("ENOENT");
|
||||
return opts.cgroup;
|
||||
}
|
||||
return actual.readFileSync(p, enc as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
return import("@inspector/core/auth/node/secret-store-selection.js");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("node:fs");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("is true inside Kubernetes, without touching the filesystem", async () => {
|
||||
process.env.KUBERNETES_SERVICE_HOST = "10.0.0.1";
|
||||
const mod = await loadWithFs({});
|
||||
expect(mod.isContainer()).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when /.dockerenv exists", async () => {
|
||||
const mod = await loadWithFs({ dockerenv: true });
|
||||
expect(mod.isContainer()).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when a container runtime appears in /proc/1/cgroup", async () => {
|
||||
const mod = await loadWithFs({
|
||||
cgroup: "0::/system.slice/containerd.service\n",
|
||||
});
|
||||
expect(mod.isContainer()).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a host cgroup line", async () => {
|
||||
const mod = await loadWithFs({
|
||||
cgroup: "0::/user.slice/user-1000.slice\n",
|
||||
});
|
||||
expect(mod.isContainer()).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when neither signal is readable (macOS, or no /proc)", async () => {
|
||||
// Not a container as far as we know — and a false negative is the cheap
|
||||
// direction, since it only biases the fallback between two working stores.
|
||||
const mod = await loadWithFs({ cgroup: new Error("ENOENT") });
|
||||
expect(mod.isContainer()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOnMountPoint", () => {
|
||||
it("is false for an ordinary directory", () => {
|
||||
expect(isOnMountPoint(tmpDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a path that doesn't exist yet, walking to its parent", () => {
|
||||
// The first-run shape: `~/.mcp-inspector` has not been created. Answering
|
||||
// from the nearest existing ancestor is what keeps this from being a
|
||||
// missing-file error at startup.
|
||||
expect(isOnMountPoint(path.join(tmpDir, "nope", "deeper"))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the path itself can't be resolved", () => {
|
||||
// A NUL byte makes `path.resolve` throw. Any failure to answer the
|
||||
// question resolves to "not a mount", which keeps a container on the
|
||||
// conservative side — memory, which never promises what it can't keep.
|
||||
expect(isOnMountPoint("bad\0path")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false at the filesystem root", () => {
|
||||
// `/` is its own parent and is trivially a mount — reporting true there
|
||||
// would make every container look like it had a volume.
|
||||
expect(isOnMountPoint(path.parse(process.cwd()).root)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnAboutSecretStorage", () => {
|
||||
const base: SecretStorageInfo = {
|
||||
kind: "keyring",
|
||||
reason: "default",
|
||||
durable: true,
|
||||
};
|
||||
|
||||
it("says nothing for the ordinary keychain case", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnAboutSecretStorage(base);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces a fallback, names the cause, and points at the override", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnAboutSecretStorage({
|
||||
kind: "memory",
|
||||
reason: "fallback",
|
||||
durable: false,
|
||||
detail: "no D-Bus session",
|
||||
});
|
||||
const output = warn.mock.calls.flat().join("\n");
|
||||
expect(output).toContain("OS keychain is not available");
|
||||
expect(output).toContain("no D-Bus session");
|
||||
expect(output).toContain("MCP_INSPECTOR_SECRET_STORE");
|
||||
// The lossy-store caveat rides along — this is the "loud" half of
|
||||
// "automatic fallback with a loud banner".
|
||||
expect(output).toContain("lost when the Inspector exits");
|
||||
});
|
||||
|
||||
it("warns about an unencrypted file even when it was explicitly chosen", () => {
|
||||
// `reason: "configured"` means the user picked `file`; it does not mean
|
||||
// they knew it would be unencrypted.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
warnAboutSecretStorage({
|
||||
kind: "file",
|
||||
reason: "configured",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/tmp/secrets.json",
|
||||
});
|
||||
expect(warn.mock.calls.flat().join("\n")).toContain(
|
||||
"MCP_INSPECTOR_SECRET_KEY",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSecretStore", () => {
|
||||
it("uses the keychain when the probe succeeds", async () => {
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(info).toMatchObject({ kind: "keyring", reason: "default" });
|
||||
});
|
||||
|
||||
it("caches the resolution so every consumer describes the same store", async () => {
|
||||
// The banner, `/api/config`, and the store doing the writing are three
|
||||
// readers of one decision; a re-probe that flipped would have the UI
|
||||
// naming a store nobody is using.
|
||||
const mod = await loadWithProbe(true);
|
||||
expect(await mod.resolveSecretStore()).toBe(await mod.resolveSecretStore());
|
||||
});
|
||||
|
||||
it("falls back with a reason and a detail when the probe fails", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json");
|
||||
const mod = await loadWithProbe(false, "Couldn't access platform storage");
|
||||
const { info, store } = await mod.resolveSecretStore();
|
||||
expect(info.reason).toBe("fallback");
|
||||
expect(info.detail).toContain("Couldn't access platform storage");
|
||||
// And the store it hands back actually works — the point of falling back.
|
||||
await store.set("alpha", "env:A", "1");
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
});
|
||||
|
||||
it("honors an explicit memory store without probing", async () => {
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "memory";
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(info).toMatchObject({
|
||||
kind: "memory",
|
||||
reason: "configured",
|
||||
durable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors an explicit file store, reporting its path and encryption state", async () => {
|
||||
const target = path.join(tmpDir, "custom", "secrets.json");
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = target;
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(info).toMatchObject({
|
||||
kind: "file",
|
||||
reason: "configured",
|
||||
path: target,
|
||||
plaintext: true,
|
||||
durable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an encrypted file when the passphrase is set", async () => {
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json");
|
||||
process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2";
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(info.plaintext).toBe(false);
|
||||
});
|
||||
|
||||
it("getSecretStorageInfo returns the same descriptor as the resolution", async () => {
|
||||
const mod = await loadWithProbe(true);
|
||||
const { info } = await mod.resolveSecretStore();
|
||||
expect(await mod.getSecretStorageInfo()).toBe(info);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultSecretStore", () => {
|
||||
it("delegates every operation to the resolved store", async () => {
|
||||
// The deferred wrapper exists so the synchronous `?? defaultSecretStore()`
|
||||
// call sites keep working; if it stopped forwarding, secrets would vanish
|
||||
// into an object nobody reads.
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "memory";
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const store = mod.defaultSecretStore();
|
||||
|
||||
await store.set("alpha", "env:A", "1");
|
||||
await store.set("alpha", "env:B", "2");
|
||||
await store.set("beta", "env:A", "3");
|
||||
expect(await store.get("alpha", "env:A")).toBe("1");
|
||||
|
||||
await store.delete("alpha", "env:A");
|
||||
expect(await store.get("alpha", "env:A")).toBe(null);
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
expect(await store.get("alpha", "env:B")).toBe(null);
|
||||
expect(await store.get("beta", "env:A")).toBe("3");
|
||||
|
||||
// And it is the *same* underlying store the resolution reports.
|
||||
const { store: resolvedStore } = await mod.resolveSecretStore();
|
||||
expect(await resolvedStore.get("beta", "env:A")).toBe("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultSecretFilePath", () => {
|
||||
it("resolves a relative override to an absolute path", async () => {
|
||||
// The path is shown in the UI footer, so a relative one would be
|
||||
// ambiguous about which directory it is relative to.
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = "./rel/secrets.json";
|
||||
const mod = await loadWithProbe(true);
|
||||
expect(path.isAbsolute(mod.defaultSecretFilePath())).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to ~/.mcp-inspector/secrets.json", async () => {
|
||||
const mod = await loadWithProbe(true);
|
||||
expect(mod.defaultSecretFilePath()).toContain(
|
||||
path.join(".mcp-inspector", "secrets.json"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
* its own describe built on `vi.resetModules()`.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { describeSecretStoreContract } from "./secretStoreContract.js";
|
||||
|
||||
// The mock must be hoisted above the `await import` of secret-store
|
||||
// inside the `KeyringSecretStore` describe block. Use `vi.hoisted` so
|
||||
|
|
@ -24,6 +25,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||
const keyringMocks = vi.hoisted(() => {
|
||||
const password = new Map<string, string | null>();
|
||||
const reset = () => password.clear();
|
||||
const storedAccounts = (): string[] => [...password.keys()];
|
||||
// Behavior hooks each test can flip to simulate keychain unavailability
|
||||
// on specific operations. Defaults: real-ish in-memory behavior.
|
||||
const failures = {
|
||||
|
|
@ -77,7 +79,14 @@ const keyringMocks = vi.hoisted(() => {
|
|||
if (failures.findThrows) throw new Error("keychain find unavailable");
|
||||
return credentials();
|
||||
};
|
||||
return { AsyncEntry, findCredentialsAsync, failures, password, reset };
|
||||
return {
|
||||
AsyncEntry,
|
||||
findCredentialsAsync,
|
||||
failures,
|
||||
password,
|
||||
reset,
|
||||
storedAccounts,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@napi-rs/keyring", () => ({
|
||||
|
|
@ -90,115 +99,20 @@ import {
|
|||
KeyringSecretStore,
|
||||
KeychainUnavailableError,
|
||||
KeyringModuleShapeError,
|
||||
probeKeyringAvailable,
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
envSecretField,
|
||||
parseAccount,
|
||||
type SecretStore,
|
||||
} from "@inspector/core/auth/node/secret-store.js";
|
||||
|
||||
describe("InMemorySecretStore", () => {
|
||||
let store: SecretStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new InMemorySecretStore();
|
||||
});
|
||||
|
||||
it("returns null for a missing entry", async () => {
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips a value set then get", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"shh",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats different server ids as separate namespaces", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "alpha-secret");
|
||||
await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "beta-secret");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"alpha-secret",
|
||||
);
|
||||
expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"beta-secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats different fields under the same server id as separate entries", async () => {
|
||||
await store.set("alpha", envSecretField("API_KEY"), "k1");
|
||||
await store.set("alpha", envSecretField("DB_PASS"), "k2");
|
||||
expect(await store.get("alpha", envSecretField("API_KEY"))).toBe("k1");
|
||||
expect(await store.get("alpha", envSecretField("DB_PASS"))).toBe("k2");
|
||||
});
|
||||
|
||||
it("overwrites an existing entry on set", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v1");
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v2");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"v2",
|
||||
);
|
||||
});
|
||||
|
||||
it("delete is a no-op for a missing entry", async () => {
|
||||
await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
// No throw, no state change.
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("delete removes only the targeted (id, field)", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha", envSecretField("KEY"), "b");
|
||||
await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", envSecretField("KEY"))).toBe("b");
|
||||
});
|
||||
|
||||
it("deleteAllForServer removes every field under that id", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha", envSecretField("KEY1"), "b");
|
||||
await store.set("alpha", envSecretField("KEY2"), "c");
|
||||
await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "untouched");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", envSecretField("KEY1"))).toBe(null);
|
||||
expect(await store.get("alpha", envSecretField("KEY2"))).toBe(null);
|
||||
expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"untouched",
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteAllForServer does not delete entries on a different id that happens to share a prefix", async () => {
|
||||
// The account scheme is `${serverId}:${field}` — a literal prefix match
|
||||
// would incorrectly sweep "alpha-prime" entries when deleting "alpha".
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET, "p");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
await store.get("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET),
|
||||
).toBe("p");
|
||||
});
|
||||
|
||||
it("round-trips an empty-string value (set + get returns '')", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe("");
|
||||
});
|
||||
});
|
||||
// The generic cases live in `secretStoreContract.ts` and are shared with
|
||||
// `FileSecretStore` (#1950) — a contract asserted against one implementation
|
||||
// is not a contract. `KeyringSecretStore` keeps its own (failure-focused)
|
||||
// block below, since it has no reachable backend in CI.
|
||||
describeSecretStoreContract(
|
||||
"InMemorySecretStore",
|
||||
() => new InMemorySecretStore(),
|
||||
);
|
||||
|
||||
describe("parseAccount", () => {
|
||||
it("splits `${serverId}:${field}` on the first colon", () => {
|
||||
|
|
@ -332,6 +246,22 @@ describe("KeyringSecretStore (mocked native bindings)", () => {
|
|||
).toBe("p");
|
||||
});
|
||||
|
||||
it("deleteAllForServer skips an account it cannot parse", async () => {
|
||||
// `findCredentialsAsync` returns whatever is in the keychain, including
|
||||
// an entry this build didn't write. An empty field yields the account
|
||||
// "alpha:", which `parseAccount` rejects — the guard is what keeps a
|
||||
// stray entry from being treated as belonging to this server.
|
||||
await store.set("alpha", "", "orphan");
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "real");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", "")).toBe("orphan");
|
||||
});
|
||||
|
||||
it("KeychainUnavailableError stringifies a non-Error cause", () => {
|
||||
const err = new KeychainUnavailableError("plain string cause");
|
||||
expect(err).toBeInstanceOf(KeychainUnavailableError);
|
||||
|
|
@ -447,6 +377,44 @@ describe("KeyringSecretStore (mocked native bindings)", () => {
|
|||
await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeKeyringAvailable (#1950)", () => {
|
||||
// The input to the automatic-fallback policy. It has to answer "is the
|
||||
// keychain *usable*", not "did the package import" — the container that
|
||||
// motivated #1848 imports the package fine and only fails when
|
||||
// `AsyncEntry::new` reaches for a Secret Service that isn't there.
|
||||
it("reports available when a read round-trips", async () => {
|
||||
await expect(probeKeyringAvailable()).resolves.toEqual({
|
||||
available: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unavailable, with the cause, when construction throws", async () => {
|
||||
keyringMocks.failures.constructorThrows = true;
|
||||
const result = await probeKeyringAvailable();
|
||||
expect(result.available).toBe(false);
|
||||
// The detail is what the fallback banner prints, and the three
|
||||
// realistic causes need different fixes — so it is surfaced rather
|
||||
// than summarized.
|
||||
expect(result).toHaveProperty("detail");
|
||||
expect((result as { detail: string }).detail).toContain(
|
||||
"Couldn't access platform storage",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports unavailable when the read itself throws", async () => {
|
||||
keyringMocks.failures.getThrows = true;
|
||||
expect((await probeKeyringAvailable()).available).toBe(false);
|
||||
});
|
||||
|
||||
it("never writes to the user's keychain", async () => {
|
||||
// Deliberate: a write probe would be a stronger signal but would
|
||||
// deposit a value in someone's login keyring at every startup, for a
|
||||
// store they may never use.
|
||||
await probeKeyringAvailable();
|
||||
expect(keyringMocks.storedAccounts()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("@napi-rs/keyring unloadable on this platform (#1905)", () => {
|
||||
|
|
@ -539,6 +507,21 @@ describe("@napi-rs/keyring unloadable on this platform (#1905)", () => {
|
|||
await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("the availability probe reports unavailable and names the load error", async () => {
|
||||
// The fallback policy's input on Android/Termux: nothing to probe, so
|
||||
// the answer must come from the load failure rather than from an
|
||||
// attempted construction that would throw somewhere less legible.
|
||||
const mod = await importWithUnloadableKeyring();
|
||||
const result = await mod.probeKeyringAvailable();
|
||||
expect(result.available).toBe(false);
|
||||
// The detail carries the loader's own message. Asserted as non-empty
|
||||
// rather than by content: vitest replaces whatever a throwing `doMock`
|
||||
// factory threw with its own "error when mocking a module" text (see the
|
||||
// hint-branch note above), so the literal `Cannot find module` string
|
||||
// never reaches us here even though it does in production.
|
||||
expect((result as { detail: string }).detail).not.toBe("");
|
||||
});
|
||||
|
||||
it("attempts the load once and caches the failure", async () => {
|
||||
// Without the cache every secret operation re-attempts (and re-throws)
|
||||
// the resolution — `expectedSecretFields` means that is once per
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* The behavioral contract every `SecretStore` implementation must satisfy,
|
||||
* as a reusable block of `it()`s.
|
||||
*
|
||||
* These assertions were written once against `InMemorySecretStore` and are
|
||||
* now shared with `FileSecretStore` (#1950), which is the point: the whole
|
||||
* design rests on callers not knowing which store they got, and a contract
|
||||
* asserted against only one implementation cannot support that claim. The
|
||||
* cases that matter most are the two that look like edge cases and are
|
||||
* not — the empty-string value (a real value that must round-trip, which
|
||||
* is why the interface returns `null` for "absent" rather than falsy) and
|
||||
* the shared-prefix server id (`alpha` vs `alpha-prime`, where a literal
|
||||
* prefix sweep in `deleteAllForServer` deletes the wrong server's
|
||||
* secrets).
|
||||
*
|
||||
* `KeyringSecretStore` is deliberately *not* run through this block: it
|
||||
* has no reachable backend in CI, so its own file drives a mocked native
|
||||
* layer and asserts the failure half of the contract instead.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { SecretStore } from "@inspector/core/auth/node/secret-store.js";
|
||||
import {
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
envSecretField,
|
||||
} from "@inspector/core/auth/secret-fields.js";
|
||||
|
||||
/**
|
||||
* @param name Suite label.
|
||||
* @param makeStore Produces a *fresh, empty* store. Called per test — a
|
||||
* file-backed implementation uses this to point at a new temp path, so
|
||||
* the shared cases stay ignorant of where the state lives.
|
||||
*/
|
||||
export function describeSecretStoreContract(
|
||||
name: string,
|
||||
makeStore: () => SecretStore | Promise<SecretStore>,
|
||||
): void {
|
||||
describe(`${name} (SecretStore contract)`, () => {
|
||||
let store: SecretStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = await makeStore();
|
||||
});
|
||||
|
||||
it("returns null for a missing entry", async () => {
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips a value set then get", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"shh",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats different server ids as separate namespaces", async () => {
|
||||
await store.set(
|
||||
"alpha",
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
"alpha-secret",
|
||||
);
|
||||
await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "beta-secret");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"alpha-secret",
|
||||
);
|
||||
expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"beta-secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats different fields under the same server id as separate entries", async () => {
|
||||
await store.set("alpha", envSecretField("API_KEY"), "k1");
|
||||
await store.set("alpha", envSecretField("DB_PASS"), "k2");
|
||||
expect(await store.get("alpha", envSecretField("API_KEY"))).toBe("k1");
|
||||
expect(await store.get("alpha", envSecretField("DB_PASS"))).toBe("k2");
|
||||
});
|
||||
|
||||
it("overwrites an existing entry on set", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v1");
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v2");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"v2",
|
||||
);
|
||||
});
|
||||
|
||||
it("delete is a no-op for a missing entry", async () => {
|
||||
await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
// No throw, no state change.
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("delete removes only the targeted (id, field)", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha", envSecretField("KEY"), "b");
|
||||
await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET);
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", envSecretField("KEY"))).toBe("b");
|
||||
});
|
||||
|
||||
it("deleteAllForServer removes every field under that id", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha", envSecretField("KEY1"), "b");
|
||||
await store.set("alpha", envSecretField("KEY2"), "c");
|
||||
await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "untouched");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(await store.get("alpha", envSecretField("KEY1"))).toBe(null);
|
||||
expect(await store.get("alpha", envSecretField("KEY2"))).toBe(null);
|
||||
expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"untouched",
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteAllForServer does not delete entries on a different id that happens to share a prefix", async () => {
|
||||
// The account scheme is `${serverId}:${field}` — a literal prefix match
|
||||
// would incorrectly sweep "alpha-prime" entries when deleting "alpha".
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a");
|
||||
await store.set("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET, "p");
|
||||
|
||||
await store.deleteAllForServer("alpha");
|
||||
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
await store.get("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET),
|
||||
).toBe("p");
|
||||
});
|
||||
|
||||
it("round-trips an empty-string value (set + get returns '')", async () => {
|
||||
await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "");
|
||||
expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue