Initial Commit
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// @integration — Darwin only. Skipped in CI; excluded from coverage.
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MacOSKeychainBridge } from "../src/index.js";
|
||||
|
||||
const isDarwin = process.platform === "darwin";
|
||||
// Test files exempt from §13 banned-pattern .skip rule because the platform
|
||||
// guard is the documented way to skip Darwin-only integration in CI.
|
||||
const itDarwin = isDarwin ? it : it.skip;
|
||||
|
||||
describe("MacOSKeychainBridge integration (real /usr/bin/security)", () => {
|
||||
const SERVICE = "@suaveplan/macos-keychain.integration-test";
|
||||
const ACCOUNT = "vitest-runner";
|
||||
const bridge = new MacOSKeychainBridge();
|
||||
|
||||
afterEach(async () => {
|
||||
if (!isDarwin) return;
|
||||
try {
|
||||
await bridge.deleteItem(SERVICE, ACCOUNT);
|
||||
} catch {
|
||||
// tolerate already-clean state
|
||||
}
|
||||
});
|
||||
|
||||
itDarwin("adds and finds an item", { timeout: 30_000 }, async () => {
|
||||
await bridge.addItem({
|
||||
service: SERVICE,
|
||||
account: ACCOUNT,
|
||||
password: "real-secret",
|
||||
});
|
||||
const item = await bridge.findItem(SERVICE, ACCOUNT);
|
||||
expect(item.password).toBe("real-secret");
|
||||
});
|
||||
|
||||
itDarwin(
|
||||
"itemExists returns true after add and false after delete",
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
async () => {
|
||||
await bridge.addItem({
|
||||
service: SERVICE,
|
||||
account: ACCOUNT,
|
||||
password: "x",
|
||||
});
|
||||
expect(await bridge.itemExists(SERVICE, ACCOUNT)).toBe(true);
|
||||
await bridge.deleteItem(SERVICE, ACCOUNT);
|
||||
expect(await bridge.itemExists(SERVICE, ACCOUNT)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
itDarwin("updates the stored password", { timeout: 30_000 }, async () => {
|
||||
await bridge.addItem({
|
||||
service: SERVICE,
|
||||
account: ACCOUNT,
|
||||
password: "v1",
|
||||
});
|
||||
await bridge.updateItem(SERVICE, ACCOUNT, "v2");
|
||||
const item = await bridge.findItem(SERVICE, ACCOUNT);
|
||||
expect(item.password).toBe("v2");
|
||||
});
|
||||
|
||||
itDarwin(
|
||||
"listItems surfaces the added item",
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
async () => {
|
||||
await bridge.addItem({
|
||||
service: SERVICE,
|
||||
account: ACCOUNT,
|
||||
password: "list-me",
|
||||
});
|
||||
const entries = await bridge.listItems(SERVICE);
|
||||
expect(entries.some((e) => e.account === ACCOUNT)).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
98
packages/identity/auth/macos-keychain/tests/otel.test.ts
Normal file
98
packages/identity/auth/macos-keychain/tests/otel.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { type CommandExecutor, MacOSKeychainBridge } from "../src/index.js";
|
||||
|
||||
// The default @opentelemetry/api ships a NoopMeterProvider; we exercise the
|
||||
// meter API and assert no throw. The bridge instruments every CRUD call with
|
||||
// keychain.ops_total + keychain.cli_duration_ms (plus keychain.items_returned
|
||||
// on listItems). Asserting on counter values requires registering an SDK
|
||||
// MeterProvider, which is out-of-scope for the package's runtime — the
|
||||
// instrumentation contract here is "we always call the meter API in the right
|
||||
// shape". A future @suaveplan/opentelemetry-test-harness fixture will assert
|
||||
// recorded values without changing this test.
|
||||
|
||||
describe("OTel instrumentation", () => {
|
||||
it("addItem records on the ops counter without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub: CommandExecutor = async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub);
|
||||
await expect(
|
||||
bridge.addItem({ service: "svc", account: "user", password: "p" })
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("findItem records on the ops counter without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub: CommandExecutor = async () => ({
|
||||
stdout: "",
|
||||
stderr: 'password: "p"',
|
||||
exitCode: 0,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub);
|
||||
await expect(bridge.findItem("svc", "user")).resolves.toMatchObject({
|
||||
password: "p",
|
||||
});
|
||||
});
|
||||
|
||||
it("updateItem records the delete + add invocations", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let n = 0;
|
||||
const stub: CommandExecutor = async () => {
|
||||
n += 1;
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
};
|
||||
const bridge = new MacOSKeychainBridge({}, stub);
|
||||
await bridge.updateItem("svc", "user", "newp");
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
|
||||
it("deleteItem records on the ops counter without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub: CommandExecutor = async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub);
|
||||
await expect(bridge.deleteItem("svc", "user")).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("listItems records the items_returned histogram", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const dump = `keychain: "/x"
|
||||
"svce"<blob>="svc"
|
||||
"acct"<blob>="alice"`;
|
||||
const stub: CommandExecutor = async () => ({
|
||||
stdout: dump,
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub);
|
||||
const entries = await bridge.listItems();
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("records on the ops counter even when the call fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stub: CommandExecutor = async () => ({
|
||||
stdout: "",
|
||||
stderr: "could not be found",
|
||||
exitCode: 44,
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge({}, stub);
|
||||
await expect(bridge.findItem("svc", "user")).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
224
packages/identity/auth/macos-keychain/tests/property.test.ts
Normal file
224
packages/identity/auth/macos-keychain/tests/property.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import type { CommandExecutor, CommandExecutorResult } from "../src/index.js";
|
||||
import {
|
||||
classifyError,
|
||||
EXIT_AUTH_FAILED,
|
||||
EXIT_DUPLICATE_ITEM,
|
||||
EXIT_INTERACTION_NOT_ALLOWED,
|
||||
EXIT_INVALID_KEYCHAIN,
|
||||
EXIT_ITEM_NOT_FOUND,
|
||||
keychainAccessFailed,
|
||||
keychainItemExists,
|
||||
keychainItemInvalid,
|
||||
keychainLocked,
|
||||
keychainNotFound,
|
||||
keychainPermissionDenied,
|
||||
keychainTimeout,
|
||||
keychainUnavailable,
|
||||
MacOSKeychainBridge,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("classifyError property coverage", () => {
|
||||
it("every documented exit code yields a non-null KeychainError", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(
|
||||
EXIT_ITEM_NOT_FOUND,
|
||||
EXIT_DUPLICATE_ITEM,
|
||||
EXIT_AUTH_FAILED,
|
||||
EXIT_INTERACTION_NOT_ALLOWED,
|
||||
EXIT_INVALID_KEYCHAIN
|
||||
),
|
||||
fc.string(),
|
||||
fc.string({ minLength: 1 }),
|
||||
(code, svc, acct) => {
|
||||
const err = classifyError(
|
||||
code,
|
||||
"",
|
||||
svc,
|
||||
acct.length === 0 ? "u" : acct
|
||||
);
|
||||
expect(err).not.toBeNull();
|
||||
expect(err?.code.startsWith("KEYCHAIN.")).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("very large unknown exit codes return null", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.integer({ min: 50_000, max: 99_999 })
|
||||
.filter(
|
||||
(n) =>
|
||||
n !== EXIT_INTERACTION_NOT_ALLOWED &&
|
||||
n !== EXIT_INVALID_KEYCHAIN
|
||||
),
|
||||
fc
|
||||
.string({ minLength: 1 })
|
||||
.filter(
|
||||
(s) =>
|
||||
!/found|exists|locked|denied|invalid|unavailable|interaction|passphrase|unlocked|directory|command not found/i.test(
|
||||
s
|
||||
)
|
||||
),
|
||||
(code, stderr) => {
|
||||
const err = classifyError(code, stderr, "svc", "user");
|
||||
expect(err).toBeNull();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error-factory idempotency", () => {
|
||||
it("every factory produces the documented code (idempotency)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1 }),
|
||||
fc.string({ minLength: 1 }),
|
||||
(svc, acct) => {
|
||||
expect(keychainNotFound(svc, acct).code).toBe(
|
||||
"KEYCHAIN.NOT_FOUND"
|
||||
);
|
||||
expect(keychainItemExists(svc, acct).code).toBe(
|
||||
"KEYCHAIN.ITEM_EXISTS"
|
||||
);
|
||||
expect(keychainAccessFailed(svc, acct).code).toBe(
|
||||
"KEYCHAIN.ACCESS_FAILED"
|
||||
);
|
||||
expect(keychainLocked(svc, acct).code).toBe(
|
||||
"KEYCHAIN.LOCKED"
|
||||
);
|
||||
expect(keychainUnavailable().code).toBe(
|
||||
"KEYCHAIN.UNAVAILABLE"
|
||||
);
|
||||
expect(keychainPermissionDenied(svc, acct).code).toBe(
|
||||
"KEYCHAIN.PERMISSION_DENIED"
|
||||
);
|
||||
expect(keychainItemInvalid(svc, acct, "r").code).toBe(
|
||||
"KEYCHAIN.ITEM_INVALID"
|
||||
);
|
||||
expect(keychainTimeout(svc, acct, 1).code).toBe(
|
||||
"KEYCHAIN.TIMEOUT"
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it("calling a factory twice produces equal code and message", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1 }),
|
||||
fc.string({ minLength: 1 }),
|
||||
(svc, acct) => {
|
||||
const a = keychainNotFound(svc, acct);
|
||||
const b = keychainNotFound(svc, acct);
|
||||
expect(a.code).toBe(b.code);
|
||||
expect(a.message).toBe(b.message);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip via stub executor", () => {
|
||||
it("addItem then findItem returns the original password", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc
|
||||
.string({ minLength: 1, maxLength: 32 })
|
||||
.filter((s) => /^[A-Za-z0-9_-]+$/.test(s)),
|
||||
fc
|
||||
.string({ minLength: 1, maxLength: 32 })
|
||||
.filter((s) => /^[A-Za-z0-9_-]+$/.test(s)),
|
||||
fc
|
||||
.string({ minLength: 1, maxLength: 64 })
|
||||
.filter((s) => !s.includes('"') && !s.includes("\\")),
|
||||
async (svc, acct, password) => {
|
||||
let stored: string | undefined;
|
||||
const runner: CommandExecutor = async (_cmd, args) => {
|
||||
if (args[0] === "add-generic-password") {
|
||||
stored = String(args[args.indexOf("-w") + 1]);
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
} satisfies CommandExecutorResult;
|
||||
}
|
||||
if (args[0] === "find-generic-password") {
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: `password: "${stored ?? ""}"`,
|
||||
exitCode: 0,
|
||||
} satisfies CommandExecutorResult;
|
||||
}
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
} satisfies CommandExecutorResult;
|
||||
};
|
||||
const bridge = new MacOSKeychainBridge({}, runner);
|
||||
await bridge.addItem({
|
||||
service: svc,
|
||||
account: acct,
|
||||
password,
|
||||
});
|
||||
const item = await bridge.findItem(svc, acct);
|
||||
expect(item.password).toBe(password);
|
||||
}
|
||||
),
|
||||
{ numRuns: 25 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeout detection", () => {
|
||||
it("timeout via AbortSignal yields KEYCHAIN.TIMEOUT", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 1, max: 50 }),
|
||||
async (timeoutMs) => {
|
||||
const slowRunner: CommandExecutor = (
|
||||
_cmd,
|
||||
_args,
|
||||
options
|
||||
) =>
|
||||
new Promise<CommandExecutorResult>((_, reject) => {
|
||||
options?.signal?.addEventListener("abort", () =>
|
||||
reject(new Error("aborted"))
|
||||
);
|
||||
});
|
||||
const bridge = new MacOSKeychainBridge(
|
||||
{ timeoutMs },
|
||||
slowRunner
|
||||
);
|
||||
await expect(
|
||||
bridge.findItem("svc", "user")
|
||||
).rejects.toMatchObject({
|
||||
code: "KEYCHAIN.TIMEOUT",
|
||||
});
|
||||
}
|
||||
),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user