225 lines
8.0 KiB
TypeScript
225 lines
8.0 KiB
TypeScript
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 }
|
|
);
|
|
});
|
|
});
|