relic 0.2.0 → 0.4.0
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.
- package/README.md +129 -0
- package/commands/init.ts +105 -0
- package/commands/login.ts +85 -0
- package/commands/logout.ts +39 -0
- package/commands/projects.ts +154 -0
- package/commands/run.api-key.test.ts +240 -0
- package/commands/run.test.ts +404 -0
- package/commands/run.ts +532 -0
- package/commands/run.validation.test.ts +135 -0
- package/commands/telemetry.ts +28 -0
- package/commands/whoami.ts +45 -0
- package/ffi/bridge.ts +36 -0
- package/ffi/constants.ts +3 -0
- package/ffi/helper.ts +134 -0
- package/index.ts +116 -0
- package/lib/api.ts +411 -0
- package/lib/config.ts +118 -0
- package/lib/crypto.ts +81 -0
- package/lib/types.ts +1 -0
- package/package.json +62 -6
- package/prebuilds/darwin-arm64/.gitkeep +0 -0
- package/prebuilds/darwin-arm64/librelic_runner.d +1 -0
- package/prebuilds/darwin-arm64/librelic_runner.dylib +0 -0
- package/prebuilds/darwin-x64/.gitkeep +0 -0
- package/prebuilds/darwin-x64/librelic_runner.d +1 -0
- package/prebuilds/darwin-x64/librelic_runner.dylib +0 -0
- package/prebuilds/linux-x64/.gitkeep +0 -0
- package/prebuilds/linux-x64/librelic_runner.d +1 -0
- package/prebuilds/linux-x64/librelic_runner.so +0 -0
- package/prebuilds/win32-x64/.gitkeep +0 -0
- package/prebuilds/win32-x64/relic_runner.dll +0 -0
- package/index.js +0 -52
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { CachedUserKeys } from "@repo/auth";
|
|
3
|
+
import type { RunOptions } from "./run";
|
|
4
|
+
|
|
5
|
+
const userKeyState: { value: CachedUserKeys | null } = { value: null };
|
|
6
|
+
|
|
7
|
+
const mockGetCachedUserKeys = mock(() => userKeyState.value);
|
|
8
|
+
const mockCacheUserKeys = mock((_db: unknown, keys: CachedUserKeys) => {
|
|
9
|
+
userKeyState.value = keys;
|
|
10
|
+
});
|
|
11
|
+
const mockClearCachedUserKeys = mock(() => {
|
|
12
|
+
userKeyState.value = null;
|
|
13
|
+
});
|
|
14
|
+
const mockGetPasswordFromStorage = mock(() => Promise.resolve("test-password"));
|
|
15
|
+
const mockGetUserKeyCacheDb = mock(() => Promise.resolve({}));
|
|
16
|
+
|
|
17
|
+
const mockExportSecretsViaApiKey = mock(() =>
|
|
18
|
+
Promise.resolve({
|
|
19
|
+
secrets: [{ key: "API_KEY", encryptedValue: "enc_val_1" }],
|
|
20
|
+
count: 1,
|
|
21
|
+
encryptedProjectKey: "enc_project_key",
|
|
22
|
+
environmentId: "env_123",
|
|
23
|
+
folderId: null,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
const mockFetchUserKeysViaApiKey = mock(() =>
|
|
27
|
+
Promise.resolve({
|
|
28
|
+
encryptedPrivateKey: "fresh_encrypted_private_key",
|
|
29
|
+
salt: "fresh_salt",
|
|
30
|
+
publicKey: "fresh_public_key",
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
const mockDecryptSecrets = mock(() =>
|
|
34
|
+
Promise.resolve([{ key: "API_KEY", value: "decrypted-value" }]),
|
|
35
|
+
);
|
|
36
|
+
const mockUnwrapProjectKey = mock(() =>
|
|
37
|
+
Promise.resolve("mock_project_key" as unknown as CryptoKey),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
mock.module("@repo/auth", () => ({
|
|
41
|
+
cacheUserKeys: mockCacheUserKeys,
|
|
42
|
+
clearCachedUserKeys: mockClearCachedUserKeys,
|
|
43
|
+
getCachedUserKeys: mockGetCachedUserKeys,
|
|
44
|
+
getPasswordFromStorage: mockGetPasswordFromStorage,
|
|
45
|
+
getUserKeyCacheDb: mockGetUserKeyCacheDb,
|
|
46
|
+
hasPassword: mock(() => Promise.resolve(true)),
|
|
47
|
+
validateSession: mock(() => Promise.resolve({ isValid: true, isExpired: false })),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
class ProPlanRequiredError extends Error {
|
|
51
|
+
upgradeUrl: string;
|
|
52
|
+
constructor(message: string, upgradeUrl: string) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "ProPlanRequiredError";
|
|
55
|
+
this.upgradeUrl = upgradeUrl;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
mock.module("../lib/api", () => ({
|
|
60
|
+
exportSecretsViaApiKey: mockExportSecretsViaApiKey,
|
|
61
|
+
fetchUserKeysViaApiKey: mockFetchUserKeysViaApiKey,
|
|
62
|
+
getApi: mock(() => ({})),
|
|
63
|
+
ProPlanRequiredError,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
mock.module("../lib/crypto", () => ({
|
|
67
|
+
decryptSecrets: mockDecryptSecrets,
|
|
68
|
+
getProjectKey: mock(() => Promise.resolve("unused_project_key")),
|
|
69
|
+
ProjectKeyError: class ProjectKeyError extends Error {
|
|
70
|
+
code: string;
|
|
71
|
+
constructor(message: string, code: string) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.code = code;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
mock.module("@repo/crypto", () => ({
|
|
79
|
+
unwrapProjectKey: mockUnwrapProjectKey,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const { prepareSecretsWithApiKey } = await import("./run");
|
|
83
|
+
|
|
84
|
+
const DEFAULT_OPTIONS: RunOptions = {
|
|
85
|
+
environment: "development",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
describe("prepareSecretsWithApiKey", () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
process.env.RELIC_API_KEY = "test-api-key";
|
|
91
|
+
userKeyState.value = null;
|
|
92
|
+
|
|
93
|
+
mockGetCachedUserKeys.mockClear();
|
|
94
|
+
mockCacheUserKeys.mockClear();
|
|
95
|
+
mockClearCachedUserKeys.mockClear();
|
|
96
|
+
mockGetPasswordFromStorage.mockClear();
|
|
97
|
+
mockGetUserKeyCacheDb.mockClear();
|
|
98
|
+
mockExportSecretsViaApiKey.mockClear();
|
|
99
|
+
mockFetchUserKeysViaApiKey.mockClear();
|
|
100
|
+
mockDecryptSecrets.mockClear();
|
|
101
|
+
mockUnwrapProjectKey.mockClear();
|
|
102
|
+
|
|
103
|
+
mockGetPasswordFromStorage.mockImplementation(() => Promise.resolve("test-password"));
|
|
104
|
+
mockExportSecretsViaApiKey.mockImplementation(() =>
|
|
105
|
+
Promise.resolve({
|
|
106
|
+
secrets: [{ key: "API_KEY", encryptedValue: "enc_val_1" }],
|
|
107
|
+
count: 1,
|
|
108
|
+
encryptedProjectKey: "enc_project_key",
|
|
109
|
+
environmentId: "env_123",
|
|
110
|
+
folderId: null,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
mockFetchUserKeysViaApiKey.mockImplementation(() =>
|
|
114
|
+
Promise.resolve({
|
|
115
|
+
encryptedPrivateKey: "fresh_encrypted_private_key",
|
|
116
|
+
salt: "fresh_salt",
|
|
117
|
+
publicKey: "fresh_public_key",
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
mockDecryptSecrets.mockImplementation(() =>
|
|
121
|
+
Promise.resolve([{ key: "API_KEY", value: "decrypted-value" }]),
|
|
122
|
+
);
|
|
123
|
+
mockUnwrapProjectKey.mockImplementation(() =>
|
|
124
|
+
Promise.resolve("mock_project_key" as unknown as CryptoKey),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
delete process.env.RELIC_API_KEY;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("retries with fresh keys when cached API-key keys are stale", async () => {
|
|
133
|
+
userKeyState.value = {
|
|
134
|
+
encryptedPrivateKey: "stale_encrypted_private_key",
|
|
135
|
+
salt: "stale_salt",
|
|
136
|
+
keysUpdatedAt: 1,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let unwrapCalls = 0;
|
|
140
|
+
mockUnwrapProjectKey.mockImplementation(() => {
|
|
141
|
+
unwrapCalls++;
|
|
142
|
+
if (unwrapCalls === 1) {
|
|
143
|
+
return Promise.reject(new Error("Failed to unwrap project key"));
|
|
144
|
+
}
|
|
145
|
+
return Promise.resolve("mock_project_key" as unknown as CryptoKey);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const result = await prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS);
|
|
149
|
+
|
|
150
|
+
expect(mockClearCachedUserKeys).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(mockFetchUserKeysViaApiKey).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect(mockUnwrapProjectKey).toHaveBeenCalledTimes(2);
|
|
153
|
+
expect(mockUnwrapProjectKey).toHaveBeenNthCalledWith(
|
|
154
|
+
1,
|
|
155
|
+
"enc_project_key",
|
|
156
|
+
"stale_encrypted_private_key",
|
|
157
|
+
"test-password",
|
|
158
|
+
"stale_salt",
|
|
159
|
+
);
|
|
160
|
+
expect(mockUnwrapProjectKey).toHaveBeenNthCalledWith(
|
|
161
|
+
2,
|
|
162
|
+
"enc_project_key",
|
|
163
|
+
"fresh_encrypted_private_key",
|
|
164
|
+
"test-password",
|
|
165
|
+
"fresh_salt",
|
|
166
|
+
);
|
|
167
|
+
expect(userKeyState.value?.encryptedPrivateKey).toBe("fresh_encrypted_private_key");
|
|
168
|
+
expect(userKeyState.value?.salt).toBe("fresh_salt");
|
|
169
|
+
expect(result.secrets).toEqual({ API_KEY: "decrypted-value" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("does not retry when keys are not from cache", async () => {
|
|
173
|
+
userKeyState.value = null;
|
|
174
|
+
mockUnwrapProjectKey.mockImplementation(() =>
|
|
175
|
+
Promise.reject(new Error("Failed to unwrap project key")),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await expect(prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS)).rejects.toThrow(
|
|
179
|
+
"Failed to unwrap project key",
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// first fetch is for normal key resolution; there should be no extra fetch for retry
|
|
183
|
+
expect(mockFetchUserKeysViaApiKey).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(mockClearCachedUserKeys).not.toHaveBeenCalled();
|
|
185
|
+
expect(mockUnwrapProjectKey).toHaveBeenCalledTimes(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("bubbles error when retry with fresh keys also fails", async () => {
|
|
189
|
+
userKeyState.value = {
|
|
190
|
+
encryptedPrivateKey: "stale_encrypted_private_key",
|
|
191
|
+
salt: "stale_salt",
|
|
192
|
+
keysUpdatedAt: 1,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
mockUnwrapProjectKey.mockImplementation(() =>
|
|
196
|
+
Promise.reject(new Error("Failed to unwrap project key")),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
await expect(prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS)).rejects.toThrow(
|
|
200
|
+
"Failed to unwrap project key",
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(mockClearCachedUserKeys).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(mockFetchUserKeysViaApiKey).toHaveBeenCalledTimes(1);
|
|
205
|
+
expect(mockUnwrapProjectKey).toHaveBeenCalledTimes(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("propagates ProPlanRequiredError from export", async () => {
|
|
209
|
+
mockExportSecretsViaApiKey.mockImplementation(() =>
|
|
210
|
+
Promise.reject(
|
|
211
|
+
new ProPlanRequiredError(
|
|
212
|
+
"API keys require a Pro plan.",
|
|
213
|
+
"https://relic.so/dashboard?action=upgrade",
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const err = await prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS).catch((e) => e);
|
|
219
|
+
|
|
220
|
+
expect(err).toBeInstanceOf(ProPlanRequiredError);
|
|
221
|
+
expect(err.message).toBe("API keys require a Pro plan.");
|
|
222
|
+
expect(err.upgradeUrl).toBe("https://relic.so/dashboard?action=upgrade");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("propagates ProPlanRequiredError from user keys fetch", async () => {
|
|
226
|
+
mockFetchUserKeysViaApiKey.mockImplementation(() =>
|
|
227
|
+
Promise.reject(
|
|
228
|
+
new ProPlanRequiredError(
|
|
229
|
+
"API keys require a Pro plan.",
|
|
230
|
+
"https://relic.so/dashboard?action=upgrade",
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const err = await prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS).catch((e) => e);
|
|
236
|
+
|
|
237
|
+
expect(err).toBeInstanceOf(ProPlanRequiredError);
|
|
238
|
+
expect(err.upgradeUrl).toBe("https://relic.so/dashboard?action=upgrade");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
type CachedUserKeys,
|
|
5
|
+
cacheUserKeys,
|
|
6
|
+
getCachedUserKeys,
|
|
7
|
+
initializeUserKeyCacheSchema,
|
|
8
|
+
} from "@repo/auth";
|
|
9
|
+
import {
|
|
10
|
+
cacheEnvironments,
|
|
11
|
+
cacheFolders,
|
|
12
|
+
cacheProject,
|
|
13
|
+
cacheSecrets,
|
|
14
|
+
getCachedEnvironmentId,
|
|
15
|
+
getCachedFolderId,
|
|
16
|
+
getCachedSecrets,
|
|
17
|
+
initializeSchema,
|
|
18
|
+
} from "helpers/cache";
|
|
19
|
+
import type { FullUser, ProtectedApi, SecretData } from "../lib/api";
|
|
20
|
+
import { prepareSecrets, type RunOptions } from "./run";
|
|
21
|
+
|
|
22
|
+
const PROJECT_ID = "project_123";
|
|
23
|
+
|
|
24
|
+
const MOCK_USER_KEYS: CachedUserKeys = {
|
|
25
|
+
encryptedPrivateKey: "enc_private_key_test",
|
|
26
|
+
salt: "salt_test",
|
|
27
|
+
keysUpdatedAt: 1700000000,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const MOCK_FULL_USER: FullUser = {
|
|
31
|
+
id: "user_123",
|
|
32
|
+
name: "Test User",
|
|
33
|
+
email: "test@test.com",
|
|
34
|
+
hasPro: false,
|
|
35
|
+
publicKey: "pub_key_test",
|
|
36
|
+
encryptedPrivateKey: "enc_private_key_test",
|
|
37
|
+
salt: "salt_test",
|
|
38
|
+
keysUpdatedAt: 1700000000,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const MOCK_SECRETS: SecretData[] = [
|
|
42
|
+
{ id: "s1", key: "API_KEY", encryptedValue: "enc_val_1", scope: "shared", valueType: "string" },
|
|
43
|
+
{ id: "s2", key: "DB_HOST", encryptedValue: "enc_val_2", scope: "server", valueType: "string" },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const MOCK_EXPORT_RESULT = {
|
|
47
|
+
secrets: MOCK_SECRETS,
|
|
48
|
+
count: 2,
|
|
49
|
+
encryptedProjectKey: "enc_project_key_test",
|
|
50
|
+
environmentId: "env_123",
|
|
51
|
+
folderId: null as string | null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const MOCK_DECRYPTED_SECRETS = [
|
|
55
|
+
{ key: "API_KEY", value: "my-api-key" },
|
|
56
|
+
{ key: "DB_HOST", value: "localhost:5432" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const DEFAULT_OPTIONS: RunOptions = {
|
|
60
|
+
environment: "development",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
class MockProjectKeyError extends Error {
|
|
64
|
+
code: string;
|
|
65
|
+
constructor(message: string, code: string) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "ProjectKeyError";
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const mockGetProjectKey = mock(() => Promise.resolve("mock_crypto_key" as unknown as CryptoKey));
|
|
73
|
+
const mockDecryptSecrets = mock(() => Promise.resolve(MOCK_DECRYPTED_SECRETS));
|
|
74
|
+
|
|
75
|
+
mock.module("../lib/crypto", () => ({
|
|
76
|
+
getProjectKey: mockGetProjectKey,
|
|
77
|
+
decryptSecrets: mockDecryptSecrets,
|
|
78
|
+
ProjectKeyError: MockProjectKeyError,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
function createMockApi(overrides: Partial<ProtectedApi> = {}): ProtectedApi {
|
|
82
|
+
return {
|
|
83
|
+
getFullUser: mock(() => Promise.resolve(MOCK_FULL_USER)),
|
|
84
|
+
exportSecrets: mock(() => Promise.resolve(MOCK_EXPORT_RESULT)),
|
|
85
|
+
getSecretsCacheValidation: mock(() => Promise.resolve(null)),
|
|
86
|
+
getProjectShare: mock(() => Promise.resolve(null)),
|
|
87
|
+
// NOTE: stubs for unused methods
|
|
88
|
+
getCurrentUser: mock(() => Promise.resolve({} as any)),
|
|
89
|
+
listProjects: mock(() => Promise.resolve([])),
|
|
90
|
+
listSharedProjects: mock(() => Promise.resolve([])),
|
|
91
|
+
getProjectEnvironments: mock(() => Promise.resolve([])),
|
|
92
|
+
getEnvironmentData: mock(() => Promise.resolve({} as any)),
|
|
93
|
+
getProject: mock(() => Promise.resolve({} as any)),
|
|
94
|
+
getSecretsForFolder: mock(() => Promise.resolve([])),
|
|
95
|
+
...overrides,
|
|
96
|
+
} as unknown as ProtectedApi;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createProjectCacheDb(): Database {
|
|
100
|
+
const db = new Database(":memory:");
|
|
101
|
+
initializeSchema(db);
|
|
102
|
+
return db;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createUserKeyCacheDb(): Database {
|
|
106
|
+
const db = new Database(":memory:");
|
|
107
|
+
initializeUserKeyCacheSchema(db);
|
|
108
|
+
return db;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function seedSecretCache(db: Database, opts?: { environmentId?: string; folderId?: string }) {
|
|
112
|
+
const envId = opts?.environmentId ?? "env_123";
|
|
113
|
+
const folderId = opts?.folderId ?? undefined;
|
|
114
|
+
|
|
115
|
+
cacheEnvironments(db, PROJECT_ID, [{ id: envId, name: "development" }]);
|
|
116
|
+
cacheProject(db, PROJECT_ID, "enc_project_key_test");
|
|
117
|
+
cacheSecrets(db, PROJECT_ID, envId, folderId, MOCK_SECRETS, Date.now());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("prepareSecrets", () => {
|
|
121
|
+
let db: Database;
|
|
122
|
+
let userKeyDb: Database;
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
db = createProjectCacheDb();
|
|
126
|
+
userKeyDb = createUserKeyCacheDb();
|
|
127
|
+
mockGetProjectKey.mockClear();
|
|
128
|
+
mockDecryptSecrets.mockClear();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("should return decrypted secrets not using cache data", async () => {
|
|
132
|
+
const api = createMockApi();
|
|
133
|
+
const result = await prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api);
|
|
134
|
+
|
|
135
|
+
expect(api.getFullUser).toHaveBeenCalled();
|
|
136
|
+
expect(api.exportSecrets).toHaveBeenCalled();
|
|
137
|
+
|
|
138
|
+
expect(result.count).toBe(MOCK_SECRETS.length);
|
|
139
|
+
expect(result.secrets).toEqual({
|
|
140
|
+
API_KEY: "my-api-key",
|
|
141
|
+
DB_HOST: "localhost:5432",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(mockGetProjectKey).toHaveBeenCalledWith(
|
|
145
|
+
"enc_project_key_test",
|
|
146
|
+
"enc_private_key_test",
|
|
147
|
+
"salt_test",
|
|
148
|
+
);
|
|
149
|
+
expect(mockDecryptSecrets).toHaveBeenCalledTimes(1);
|
|
150
|
+
|
|
151
|
+
const cached = getCachedUserKeys(userKeyDb);
|
|
152
|
+
expect(cached).not.toBeNull();
|
|
153
|
+
expect(cached!.encryptedPrivateKey).toBe("enc_private_key_test");
|
|
154
|
+
expect(cached!.salt).toBe("salt_test");
|
|
155
|
+
expect(api.getSecretsCacheValidation).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("should return decrypted secrets using cache data", async () => {
|
|
159
|
+
cacheUserKeys(userKeyDb, MOCK_USER_KEYS);
|
|
160
|
+
seedSecretCache(db);
|
|
161
|
+
|
|
162
|
+
const api = createMockApi({
|
|
163
|
+
getSecretsCacheValidation: mock(() => Promise.resolve({ updatedAt: 1 })),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = await prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api);
|
|
167
|
+
|
|
168
|
+
expect(api.getFullUser).not.toHaveBeenCalled();
|
|
169
|
+
expect(api.exportSecrets).not.toHaveBeenCalled();
|
|
170
|
+
expect(api.getSecretsCacheValidation).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(result.count).toBe(2);
|
|
172
|
+
expect(result.secrets).toEqual({
|
|
173
|
+
API_KEY: "my-api-key",
|
|
174
|
+
DB_HOST: "localhost:5432",
|
|
175
|
+
});
|
|
176
|
+
expect(mockGetProjectKey).toHaveBeenCalledWith(
|
|
177
|
+
"enc_project_key_test",
|
|
178
|
+
"enc_private_key_test",
|
|
179
|
+
"salt_test",
|
|
180
|
+
);
|
|
181
|
+
expect(mockDecryptSecrets).toHaveBeenCalledTimes(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("should fall through to API when project key is not cached", async () => {
|
|
185
|
+
cacheUserKeys(userKeyDb, MOCK_USER_KEYS);
|
|
186
|
+
|
|
187
|
+
cacheEnvironments(db, PROJECT_ID, [{ id: "env_123", name: "development" }]);
|
|
188
|
+
cacheSecrets(db, PROJECT_ID, "env_123", undefined, MOCK_SECRETS, Date.now());
|
|
189
|
+
// NOTE: no cacheProject call
|
|
190
|
+
|
|
191
|
+
const api = createMockApi({
|
|
192
|
+
getSecretsCacheValidation: mock(() => Promise.resolve({ updatedAt: 1 })),
|
|
193
|
+
});
|
|
194
|
+
const result = await prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api);
|
|
195
|
+
|
|
196
|
+
expect(api.exportSecrets).toHaveBeenCalledTimes(1);
|
|
197
|
+
expect(api.getFullUser).not.toHaveBeenCalled();
|
|
198
|
+
expect(result.count).toBe(2);
|
|
199
|
+
expect(result.secrets).toEqual({
|
|
200
|
+
API_KEY: "my-api-key",
|
|
201
|
+
DB_HOST: "localhost:5432",
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("should fetch from API when cache is expired", async () => {
|
|
206
|
+
cacheUserKeys(userKeyDb, MOCK_USER_KEYS);
|
|
207
|
+
seedSecretCache(db);
|
|
208
|
+
|
|
209
|
+
const api = createMockApi({
|
|
210
|
+
getSecretsCacheValidation: mock(() => Promise.resolve({ updatedAt: Date.now() + 100000 })),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const result = await prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api);
|
|
214
|
+
|
|
215
|
+
expect(api.getSecretsCacheValidation).toHaveBeenCalledTimes(1);
|
|
216
|
+
expect(api.exportSecrets).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(api.getFullUser).not.toHaveBeenCalled();
|
|
218
|
+
|
|
219
|
+
expect(result.count).toBe(2);
|
|
220
|
+
expect(result.secrets).toEqual({
|
|
221
|
+
API_KEY: "my-api-key",
|
|
222
|
+
DB_HOST: "localhost:5432",
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("should retry with fresh user keys when cached keys are stale", async () => {
|
|
227
|
+
cacheUserKeys(userKeyDb, {
|
|
228
|
+
encryptedPrivateKey: "stale_key",
|
|
229
|
+
salt: "stale_salt",
|
|
230
|
+
keysUpdatedAt: 1600000000,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
let callCount = 0;
|
|
234
|
+
mockGetProjectKey.mockImplementation(() => {
|
|
235
|
+
callCount++;
|
|
236
|
+
if (callCount === 1) {
|
|
237
|
+
return Promise.reject(
|
|
238
|
+
new MockProjectKeyError("Failed to decrypt project key.", "DECRYPTION_FAILED"),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return Promise.resolve("mock_crypto_key" as unknown as CryptoKey);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const api = createMockApi();
|
|
245
|
+
const result = await prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api);
|
|
246
|
+
|
|
247
|
+
// resolveUserKeys uses stale cached keys (no getFullUser call)
|
|
248
|
+
// resolveProjectKey fails, retries -> calls getFullUser once for fresh keys
|
|
249
|
+
expect(api.getFullUser).toHaveBeenCalledTimes(1);
|
|
250
|
+
expect(mockGetProjectKey).toHaveBeenCalledTimes(2);
|
|
251
|
+
|
|
252
|
+
// stale keys should be replaced with fresh ones from API
|
|
253
|
+
const cached = getCachedUserKeys(userKeyDb);
|
|
254
|
+
expect(cached).not.toBeNull();
|
|
255
|
+
expect(cached!.encryptedPrivateKey).toBe("enc_private_key_test");
|
|
256
|
+
expect(cached!.salt).toBe("salt_test");
|
|
257
|
+
|
|
258
|
+
expect(result.count).toBe(2);
|
|
259
|
+
expect(result.secrets).toEqual({
|
|
260
|
+
API_KEY: "my-api-key",
|
|
261
|
+
DB_HOST: "localhost:5432",
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("should throw when no secrets are found", async () => {
|
|
266
|
+
const api = createMockApi({
|
|
267
|
+
exportSecrets: mock(() => Promise.resolve({ ...MOCK_EXPORT_RESULT, count: 0, secrets: [] })),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api)).rejects.toThrow(
|
|
271
|
+
"No secrets found",
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("should throw when user has no encryption keys", async () => {
|
|
276
|
+
const api = createMockApi({
|
|
277
|
+
getFullUser: mock(() =>
|
|
278
|
+
Promise.resolve({
|
|
279
|
+
...MOCK_FULL_USER,
|
|
280
|
+
encryptedPrivateKey: undefined,
|
|
281
|
+
salt: undefined,
|
|
282
|
+
}),
|
|
283
|
+
),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api)).rejects.toThrow(
|
|
287
|
+
"No encryption keys found",
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("should cache environment ID after fetching from API", async () => {
|
|
292
|
+
const api = createMockApi();
|
|
293
|
+
await prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api);
|
|
294
|
+
|
|
295
|
+
expect(api.exportSecrets).toHaveBeenCalledTimes(1);
|
|
296
|
+
|
|
297
|
+
const cachedEnvId = getCachedEnvironmentId(db, PROJECT_ID, "development");
|
|
298
|
+
expect(cachedEnvId).toBe("env_123");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("should cache folder ID after fetching from API with folder option", async () => {
|
|
302
|
+
const api = createMockApi({
|
|
303
|
+
exportSecrets: mock(() =>
|
|
304
|
+
Promise.resolve({
|
|
305
|
+
...MOCK_EXPORT_RESULT,
|
|
306
|
+
folderId: "folder_123",
|
|
307
|
+
}),
|
|
308
|
+
),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const options: RunOptions = {
|
|
312
|
+
environment: "development",
|
|
313
|
+
folder: "backend",
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
await prepareSecrets(PROJECT_ID, options, db, userKeyDb, api);
|
|
317
|
+
|
|
318
|
+
expect(api.exportSecrets).toHaveBeenCalledTimes(1);
|
|
319
|
+
|
|
320
|
+
const cachedEnvId = getCachedEnvironmentId(db, PROJECT_ID, "development");
|
|
321
|
+
expect(cachedEnvId).toBe("env_123");
|
|
322
|
+
|
|
323
|
+
const cachedFoldId = getCachedFolderId(db, PROJECT_ID, "env_123", "backend");
|
|
324
|
+
expect(cachedFoldId).toBe("folder_123");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("should use cache on second run after API populates it", async () => {
|
|
328
|
+
const api = createMockApi({
|
|
329
|
+
getSecretsCacheValidation: mock(() => Promise.resolve({ updatedAt: 1 })),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// First run: hits API, should populate cache
|
|
333
|
+
await prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api);
|
|
334
|
+
expect(api.exportSecrets).toHaveBeenCalledTimes(1);
|
|
335
|
+
|
|
336
|
+
// Second run: should use cache, not hit API again
|
|
337
|
+
const result = await prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api);
|
|
338
|
+
expect(api.exportSecrets).toHaveBeenCalledTimes(1);
|
|
339
|
+
expect(api.getSecretsCacheValidation).toHaveBeenCalled();
|
|
340
|
+
expect(result.count).toBe(2);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("should filter cached secrets by scope", async () => {
|
|
344
|
+
cacheUserKeys(userKeyDb, MOCK_USER_KEYS);
|
|
345
|
+
seedSecretCache(db);
|
|
346
|
+
mockDecryptSecrets.mockImplementation(() =>
|
|
347
|
+
Promise.resolve([{ key: "DB_HOST", value: "localhost:5432" }]),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const api = createMockApi({
|
|
351
|
+
getSecretsCacheValidation: mock(() => Promise.resolve({ updatedAt: 1 })),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const options: RunOptions = { environment: "development", scope: "server" };
|
|
355
|
+
const result = await prepareSecrets(PROJECT_ID, options, db, userKeyDb, api);
|
|
356
|
+
|
|
357
|
+
expect(api.exportSecrets).not.toHaveBeenCalled();
|
|
358
|
+
expect(result.count).toBe(1);
|
|
359
|
+
expect(result.secrets).toEqual({ DB_HOST: "localhost:5432" });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("should filter API response by scope and cache all secrets", async () => {
|
|
363
|
+
mockDecryptSecrets.mockImplementation(() =>
|
|
364
|
+
Promise.resolve([{ key: "DB_HOST", value: "localhost:5432" }]),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const api = createMockApi({
|
|
368
|
+
getSecretsCacheValidation: mock(() => Promise.resolve({ updatedAt: 1 })),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const options: RunOptions = { environment: "development", scope: "server" };
|
|
372
|
+
const result = await prepareSecrets(PROJECT_ID, options, db, userKeyDb, api);
|
|
373
|
+
|
|
374
|
+
// Should only return the server-scoped secret
|
|
375
|
+
expect(api.exportSecrets).toHaveBeenCalledTimes(1);
|
|
376
|
+
expect(result.count).toBe(1);
|
|
377
|
+
expect(result.secrets).toEqual({ DB_HOST: "localhost:5432" });
|
|
378
|
+
|
|
379
|
+
// But all secrets should be cached (not just server-scoped)
|
|
380
|
+
const allCached = getCachedSecrets(db, PROJECT_ID, "env_123", undefined, undefined);
|
|
381
|
+
expect(allCached).not.toBeNull();
|
|
382
|
+
expect(allCached!.length).toBe(2);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("should serve scoped request from cache after unscoped run", async () => {
|
|
386
|
+
const api = createMockApi({
|
|
387
|
+
getSecretsCacheValidation: mock(() => Promise.resolve({ updatedAt: 1 })),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// First run: unscoped, populates cache with all secrets
|
|
391
|
+
await prepareSecrets(PROJECT_ID, DEFAULT_OPTIONS, db, userKeyDb, api);
|
|
392
|
+
expect(api.exportSecrets).toHaveBeenCalledTimes(1);
|
|
393
|
+
|
|
394
|
+
// Second run: scoped, should use cache without hitting API
|
|
395
|
+
mockDecryptSecrets.mockImplementation(() =>
|
|
396
|
+
Promise.resolve([{ key: "API_KEY", value: "my-api-key" }]),
|
|
397
|
+
);
|
|
398
|
+
const options: RunOptions = { environment: "development", scope: "shared" };
|
|
399
|
+
const result = await prepareSecrets(PROJECT_ID, options, db, userKeyDb, api);
|
|
400
|
+
expect(api.exportSecrets).toHaveBeenCalledTimes(1);
|
|
401
|
+
expect(result.count).toBe(1);
|
|
402
|
+
expect(result.secrets).toEqual({ API_KEY: "my-api-key" });
|
|
403
|
+
});
|
|
404
|
+
});
|