relic 0.4.0 → 0.4.1

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.
@@ -1,404 +0,0 @@
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
- });