@vellumai/credential-executor 0.7.0 → 0.7.2

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.
@@ -0,0 +1,227 @@
1
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
4
+
5
+ import type { CesMigration } from "../migrations/types.js";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Mock state
9
+ // ---------------------------------------------------------------------------
10
+
11
+ let mockFileExists = false;
12
+ let mockFileContents: string | null = null;
13
+ let useMocks = true;
14
+
15
+ const existsSyncFn = mock((_path: string): boolean => mockFileExists);
16
+ const mkdirSyncFn = mock((): void => {});
17
+ const readFileSyncFn = mock((): string => {
18
+ if (mockFileContents === null) {
19
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
20
+ }
21
+ return mockFileContents;
22
+ });
23
+ const writeFileSyncFn = mock((): void => {});
24
+ const renameSyncFn = mock((): void => {});
25
+ const logWarnFn = mock((): void => {});
26
+ const logInfoFn = mock((): void => {});
27
+ const logErrorFn = mock((..._args: unknown[]): void => {});
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Mock modules — before importing module under test
31
+ //
32
+ // mock.module is process-global in bun. To avoid poisoning other test files,
33
+ // each overridden function delegates to a real implementation (captured via
34
+ // require() before mocking) once `useMocks` is flipped false in afterAll.
35
+ // All other node:fs exports are forwarded unchanged.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
39
+ const _realFs = require("node:fs");
40
+ /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
41
+
42
+ mock.module("node:fs", () => {
43
+ const proxy: Record<string, unknown> = {};
44
+ // Forward every export from the real module.
45
+ for (const key of Object.keys(_realFs)) {
46
+ proxy[key] = _realFs[key];
47
+ }
48
+ // Override only the five functions the migration runner uses.
49
+ // The proxy captures args as any[] and delegates to either our mocks or the
50
+ // real fs. We cast through Function.apply to satisfy overloaded signatures.
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ type AnyFn = (...args: any[]) => any;
53
+ proxy.existsSync = (...a: unknown[]) =>
54
+ useMocks ? existsSyncFn(a[0] as string) : (_realFs.existsSync as AnyFn)(...a);
55
+ proxy.mkdirSync = (...a: unknown[]) =>
56
+ useMocks ? (mkdirSyncFn as AnyFn)(...a) : (_realFs.mkdirSync as AnyFn)(...a);
57
+ proxy.readFileSync = (...a: unknown[]) =>
58
+ useMocks ? (readFileSyncFn as AnyFn)(...a) : (_realFs.readFileSync as AnyFn)(...a);
59
+ proxy.writeFileSync = (...a: unknown[]) =>
60
+ useMocks ? (writeFileSyncFn as AnyFn)(...a) : (_realFs.writeFileSync as AnyFn)(...a);
61
+ proxy.renameSync = (...a: unknown[]) =>
62
+ useMocks ? (renameSyncFn as AnyFn)(...a) : (_realFs.renameSync as AnyFn)(...a);
63
+ return proxy;
64
+ });
65
+
66
+ // Intercept pino at the package level (same technique as workspace-migrations-runner.test.ts)
67
+ // so that the lazy proxy in getLogger() returns our mock child logger.
68
+ const mockChildLogger = {
69
+ debug: (): void => {},
70
+ info: logInfoFn,
71
+ warn: logWarnFn,
72
+ error: logErrorFn,
73
+ child: () => mockChildLogger,
74
+ };
75
+ const mockPinoLogger = Object.assign(() => mockChildLogger, {
76
+ destination: () => ({}),
77
+ multistream: () => ({}),
78
+ });
79
+ mock.module("pino", () => ({ default: mockPinoLogger }));
80
+ mock.module("pino-pretty", () => ({ default: (): object => ({}) }));
81
+
82
+ // Import after mocking
83
+ import { runCesMigrations } from "../migrations/runner.js";
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Restore real behavior after all tests so other files aren't poisoned.
87
+ // ---------------------------------------------------------------------------
88
+ afterAll(() => {
89
+ useMocks = false;
90
+ });
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const CES_DATA_ROOT = "/tmp/test-ces";
97
+
98
+ function makeBackend(): SecureKeyBackend {
99
+ return {
100
+ get: mock(() => Promise.resolve(undefined)),
101
+ set: mock(() => Promise.resolve(true)),
102
+ delete: mock(() => Promise.resolve({ deleted: true })),
103
+ list: mock(() => Promise.resolve([])),
104
+ } as unknown as SecureKeyBackend;
105
+ }
106
+
107
+ function makeMigration(id: string): CesMigration {
108
+ return {
109
+ id,
110
+ description: `Migration ${id}`,
111
+ run: mock((): void => {}),
112
+ down: mock((): void => {}),
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Tests
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("runCesMigrations", () => {
121
+ beforeEach(() => {
122
+ mockFileExists = false;
123
+ mockFileContents = null;
124
+ existsSyncFn.mockClear();
125
+ mkdirSyncFn.mockClear();
126
+ readFileSyncFn.mockClear();
127
+ writeFileSyncFn.mockClear();
128
+ renameSyncFn.mockClear();
129
+ logWarnFn.mockClear();
130
+ logInfoFn.mockClear();
131
+ logErrorFn.mockClear();
132
+ });
133
+
134
+ test("fresh install — no checkpoint file — runs all migrations", async () => {
135
+ const backend = makeBackend();
136
+ const m1 = makeMigration("001");
137
+ const m2 = makeMigration("002");
138
+
139
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1, m2]);
140
+
141
+ expect(m1.run).toHaveBeenCalledTimes(1);
142
+ expect(m2.run).toHaveBeenCalledTimes(1);
143
+ expect(m1.run).toHaveBeenCalledWith(backend);
144
+ });
145
+
146
+ test("already-completed migration is skipped", async () => {
147
+ mockFileExists = true;
148
+ mockFileContents = JSON.stringify({
149
+ applied: {
150
+ "001": { appliedAt: "2025-01-01T00:00:00.000Z", status: "completed" },
151
+ },
152
+ });
153
+ const backend = makeBackend();
154
+ const m1 = makeMigration("001");
155
+ const m2 = makeMigration("002");
156
+
157
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1, m2]);
158
+
159
+ expect(m1.run).not.toHaveBeenCalled();
160
+ expect(m2.run).toHaveBeenCalledTimes(1);
161
+ });
162
+
163
+ test("interrupted migration (started status) is re-run", async () => {
164
+ mockFileExists = true;
165
+ mockFileContents = JSON.stringify({
166
+ applied: {
167
+ "001": { appliedAt: "2025-01-01T00:00:00.000Z", status: "started" },
168
+ },
169
+ });
170
+ const backend = makeBackend();
171
+ const m1 = makeMigration("001");
172
+
173
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1]);
174
+
175
+ expect(m1.run).toHaveBeenCalledTimes(1);
176
+ expect(logWarnFn).toHaveBeenCalled();
177
+ });
178
+
179
+ test("failed migration is NOT re-run", async () => {
180
+ mockFileExists = true;
181
+ mockFileContents = JSON.stringify({
182
+ applied: {
183
+ "001": { appliedAt: "2025-01-01T00:00:00.000Z", status: "failed" },
184
+ },
185
+ });
186
+ const backend = makeBackend();
187
+ const m1 = makeMigration("001");
188
+
189
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1]);
190
+
191
+ expect(m1.run).not.toHaveBeenCalled();
192
+ });
193
+
194
+ test("duplicate migration IDs throw at startup", async () => {
195
+ const backend = makeBackend();
196
+ const m1 = makeMigration("001");
197
+ const m2 = makeMigration("001");
198
+
199
+ await expect(
200
+ runCesMigrations(CES_DATA_ROOT, backend, [m1, m2]),
201
+ ).rejects.toThrow('Duplicate CES migration id: "001"');
202
+
203
+ expect(m1.run).not.toHaveBeenCalled();
204
+ });
205
+
206
+ test("migration that throws is marked failed and startup continues", async () => {
207
+ const backend = makeBackend();
208
+ const m1 = makeMigration("001");
209
+ const m2 = makeMigration("002");
210
+ (m1.run as ReturnType<typeof mock>).mockImplementation((): never => {
211
+ throw new Error("m1 blew up");
212
+ });
213
+
214
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1, m2]);
215
+
216
+ // m2 should still run after m1's failure
217
+ expect(m2.run).toHaveBeenCalledTimes(1);
218
+ // error was logged
219
+ expect(logErrorFn).toHaveBeenCalled();
220
+
221
+ // Checkpoint writes: started m1, failed m1, started m2, completed m2 = 4
222
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(4);
223
+ const failedWrite = (writeFileSyncFn.mock.calls[1] as unknown[])[1] as string;
224
+ const failedParsed = JSON.parse(failedWrite);
225
+ expect(failedParsed.applied["001"].status).toBe("failed");
226
+ });
227
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * CES CLI integration tests.
3
+ *
4
+ * Exercises the CLI entrypoint (src/cli.ts) against a temporary encrypted
5
+ * key store. Each test gets a fresh temp directory with its own keys.enc
6
+ * and store.key so tests are fully isolated.
7
+ */
8
+
9
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
10
+ import { mkdirSync, rmSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+ import { tmpdir } from "node:os";
14
+
15
+ const CLI_PATH = join(import.meta.dir, "..", "cli.ts");
16
+
17
+ function makeTempDir(): string {
18
+ const dir = join(tmpdir(), `ces-cli-test-${randomUUID()}`);
19
+ mkdirSync(dir, { recursive: true });
20
+ return dir;
21
+ }
22
+
23
+ function runCli(
24
+ args: string[],
25
+ env: Record<string, string>,
26
+ ): { exitCode: number; stdout: string; stderr: string } {
27
+ const result = Bun.spawnSync(["bun", "run", CLI_PATH, ...args], {
28
+ env: { ...process.env, ...env },
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ });
32
+ return {
33
+ exitCode: result.exitCode,
34
+ stdout: result.stdout.toString(),
35
+ stderr: result.stderr.toString(),
36
+ };
37
+ }
38
+
39
+ describe("ces", () => {
40
+ let secDir: string;
41
+ let env: Record<string, string>;
42
+
43
+ beforeEach(() => {
44
+ secDir = makeTempDir();
45
+ env = { CREDENTIAL_SECURITY_DIR: secDir };
46
+ });
47
+
48
+ afterEach(() => {
49
+ rmSync(secDir, { recursive: true, force: true });
50
+ });
51
+
52
+ test("list on empty store shows no credentials", () => {
53
+ const { exitCode, stdout } = runCli(["list"], env);
54
+ expect(exitCode).toBe(0);
55
+ expect(stdout.trim()).toBe("(no credentials stored)");
56
+ });
57
+
58
+ test("set + get round-trip", () => {
59
+ const account = "credential/vellum/platform_organization_id";
60
+ const value = "test-org-uuid-1234";
61
+
62
+ const set = runCli(["set", account, value], env);
63
+ expect(set.exitCode).toBe(0);
64
+ expect(set.stdout).toContain(`Set: ${account}`);
65
+
66
+ const get = runCli(["get", account], env);
67
+ expect(get.exitCode).toBe(0);
68
+ expect(get.stdout).toBe(value);
69
+ });
70
+
71
+ test("list shows stored credentials", () => {
72
+ runCli(["set", "credential/vellum/org_id", "org-1"], env);
73
+ runCli(["set", "credential/vellum/user_id", "user-1"], env);
74
+
75
+ const { exitCode, stdout } = runCli(["list"], env);
76
+ expect(exitCode).toBe(0);
77
+ expect(stdout).toContain("credential/vellum/org_id");
78
+ expect(stdout).toContain("credential/vellum/user_id");
79
+ });
80
+
81
+ test("get on missing key exits 1", () => {
82
+ const { exitCode, stderr } = runCli(["get", "credential/nonexistent/key"], env);
83
+ expect(exitCode).toBe(1);
84
+ expect(stderr).toContain("Not found");
85
+ });
86
+
87
+ test("set + delete + get shows not found", () => {
88
+ const account = "credential/test/deleteme";
89
+ runCli(["set", account, "temporary"], env);
90
+
91
+ const del = runCli(["delete", account], env);
92
+ expect(del.exitCode).toBe(0);
93
+ expect(del.stdout).toContain("Deleted");
94
+
95
+ const get = runCli(["get", account], env);
96
+ expect(get.exitCode).toBe(1);
97
+ expect(get.stderr).toContain("Not found");
98
+ });
99
+
100
+ test("delete on missing key exits 1", () => {
101
+ const { exitCode, stderr } = runCli(["delete", "credential/nonexistent/key"], env);
102
+ expect(exitCode).toBe(1);
103
+ expect(stderr).toContain("Not found");
104
+ });
105
+
106
+ test("no args prints usage", () => {
107
+ const { exitCode, stdout } = runCli([], env);
108
+ expect(exitCode).toBe(0);
109
+ expect(stdout).toContain("CES CLI");
110
+ expect(stdout).toContain("ces list");
111
+ });
112
+
113
+ test("--help prints usage", () => {
114
+ const { exitCode, stdout } = runCli(["--help"], env);
115
+ expect(exitCode).toBe(0);
116
+ expect(stdout).toContain("CES CLI");
117
+ });
118
+
119
+ test("unknown command exits 1", () => {
120
+ const { exitCode, stderr } = runCli(["frobnicate"], env);
121
+ expect(exitCode).toBe(1);
122
+ expect(stderr).toContain("Unknown command");
123
+ });
124
+
125
+ test("set without value exits 1", () => {
126
+ const { exitCode, stderr } = runCli(["set", "credential/test/key"], env);
127
+ expect(exitCode).toBe(1);
128
+ expect(stderr).toContain("Usage");
129
+ });
130
+
131
+ test("overwrite existing credential", () => {
132
+ const account = "credential/vellum/overwrite_test";
133
+ runCli(["set", account, "first"], env);
134
+ runCli(["set", account, "second"], env);
135
+
136
+ const { stdout } = runCli(["get", account], env);
137
+ expect(stdout).toBe("second");
138
+ });
139
+ });