Openstatus
www.openstatus.dev
1import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
3import { db, eq } from "@openstatus/db";
4import { apiKey } from "@openstatus/db/src/schema";
5import { verifyApiKeyHash } from "@openstatus/db/src/utils/api-key";
6
7import {
8 createApiKey,
9 getApiKeys,
10 revokeApiKey,
11 updateLastUsed,
12 verifyApiKey,
13} from "./apiKey";
14
15// Test data setup
16let testWorkspaceId: number;
17let testUserId: number;
18let testApiKeyId: number;
19let testToken: string;
20
21beforeAll(async () => {
22 // Clean up any existing test data
23 await db.delete(apiKey).where(eq(apiKey.name, "Test API Key"));
24 await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Description"));
25 await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Expiration"));
26
27 // Use existing test workspace and user from seed data
28 testWorkspaceId = 1;
29 testUserId = 1;
30});
31
32afterAll(async () => {
33 // Clean up test data
34 await db.delete(apiKey).where(eq(apiKey.name, "Test API Key"));
35 await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Description"));
36 await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Expiration"));
37});
38
39describe("createApiKey", () => {
40 test("should create API key with minimal parameters", async () => {
41 const result = await createApiKey(
42 testWorkspaceId,
43 testUserId,
44 "Test API Key",
45 );
46
47 expect(result).toBeDefined();
48 expect(result.token).toMatch(/^os_[a-f0-9]{32}$/);
49 expect(result.key).toMatchObject({
50 name: "Test API Key",
51 workspaceId: testWorkspaceId,
52 createdById: testUserId,
53 description: null,
54 expiresAt: null,
55 });
56 expect(result.key.prefix).toBe(result.token.slice(0, 11));
57 expect(await verifyApiKeyHash(result.token, result.key.hashedToken)).toBe(
58 true,
59 );
60
61 // Save for later tests
62 testApiKeyId = result.key.id;
63 testToken = result.token;
64 });
65
66 test("should create API key with description", async () => {
67 const description = "This is a test API key for integration testing";
68 const result = await createApiKey(
69 testWorkspaceId,
70 testUserId,
71 "Test Key with Description",
72 description,
73 );
74
75 expect(result.key.description).toBe(description);
76 });
77
78 test("should create API key with expiration", async () => {
79 const expiresAt = new Date(Date.now() + 86400000); // 1 day from now
80 const result = await createApiKey(
81 testWorkspaceId,
82 testUserId,
83 "Test Key with Expiration",
84 undefined,
85 expiresAt,
86 );
87
88 // SQLite stores timestamps with second precision, so compare with tolerance
89 expect(result.key.expiresAt?.getTime()).toBeCloseTo(
90 expiresAt.getTime(),
91 -4,
92 );
93 });
94
95 test("should create API key with both description and expiration", async () => {
96 const description = "Full featured key";
97 const expiresAt = new Date(Date.now() + 86400000);
98 const result = await createApiKey(
99 testWorkspaceId,
100 testUserId,
101 "Full Featured Key",
102 description,
103 expiresAt,
104 );
105
106 expect(result.key).toMatchObject({
107 name: "Full Featured Key",
108 description,
109 expiresAt,
110 });
111
112 // Clean up
113 await db.delete(apiKey).where(eq(apiKey.id, result.key.id));
114 });
115
116 test("should generate unique tokens", async () => {
117 const result1 = await createApiKey(
118 testWorkspaceId,
119 testUserId,
120 "Unique Key 1",
121 );
122 const result2 = await createApiKey(
123 testWorkspaceId,
124 testUserId,
125 "Unique Key 2",
126 );
127
128 expect(result1.token).not.toBe(result2.token);
129 expect(result1.key.prefix).not.toBe(result2.key.prefix);
130 expect(result1.key.hashedToken).not.toBe(result2.key.hashedToken);
131
132 // Clean up
133 await db.delete(apiKey).where(eq(apiKey.id, result1.key.id));
134 await db.delete(apiKey).where(eq(apiKey.id, result2.key.id));
135 });
136});
137
138describe("verifyApiKey", () => {
139 test("should verify valid API key", async () => {
140 const result = await verifyApiKey(testToken);
141
142 expect(result).not.toBeNull();
143 expect(result).toMatchObject({
144 id: testApiKeyId,
145 name: "Test API Key",
146 workspaceId: testWorkspaceId,
147 createdById: testUserId,
148 });
149 });
150
151 test("should return null for invalid token format", async () => {
152 const invalidToken = "os_invalid";
153 const result = await verifyApiKey(invalidToken);
154
155 expect(result).toBeNull();
156 });
157
158 test("should return null for non-existent token", async () => {
159 const nonExistentToken = `os_${"a".repeat(32)}`;
160 const result = await verifyApiKey(nonExistentToken);
161
162 expect(result).toBeNull();
163 });
164
165 test("should return null for token with incorrect hash", async () => {
166 // Create a token with same prefix but different hash
167 const wrongToken = testToken.slice(0, 11) + "0".repeat(24);
168 const result = await verifyApiKey(wrongToken);
169
170 expect(result).toBeNull();
171 });
172
173 test("should return null for expired token", async () => {
174 // Create an expired key
175 const expiredDate = new Date(Date.now() - 86400000); // 1 day ago
176 const expiredKey = await createApiKey(
177 testWorkspaceId,
178 testUserId,
179 "Expired Key",
180 undefined,
181 expiredDate,
182 );
183
184 const result = await verifyApiKey(expiredKey.token);
185
186 expect(result).toBeNull();
187
188 // Clean up
189 await db.delete(apiKey).where(eq(apiKey.id, expiredKey.key.id));
190 });
191
192 test("should verify token that expires in the future", async () => {
193 // Create a key that expires in the future
194 const futureDate = new Date(Date.now() + 86400000); // 1 day from now
195 const futureKey = await createApiKey(
196 testWorkspaceId,
197 testUserId,
198 "Future Expiry Key",
199 undefined,
200 futureDate,
201 );
202
203 const result = await verifyApiKey(futureKey.token);
204
205 expect(result).not.toBeNull();
206 expect(result?.id).toBe(futureKey.key.id);
207
208 // Clean up
209 await db.delete(apiKey).where(eq(apiKey.id, futureKey.key.id));
210 });
211});
212
213describe("revokeApiKey", () => {
214 test("should revoke API key successfully", async () => {
215 // Create a key to revoke
216 const keyToRevoke = await createApiKey(
217 testWorkspaceId,
218 testUserId,
219 "Key to Revoke",
220 );
221
222 const result = await revokeApiKey(keyToRevoke.key.id, testWorkspaceId);
223
224 expect(result).toBe(true);
225
226 // Verify key is deleted
227 const deletedKey = await db
228 .select()
229 .from(apiKey)
230 .where(eq(apiKey.id, keyToRevoke.key.id))
231 .get();
232
233 expect(deletedKey).toBeUndefined();
234 });
235
236 test("should return false for non-existent key", async () => {
237 const result = await revokeApiKey(999999, testWorkspaceId);
238
239 expect(result).toBe(false);
240 });
241
242 test("should return false when workspace ID doesn't match", async () => {
243 // Create a key
244 const key = await createApiKey(testWorkspaceId, testUserId, "Test Key");
245
246 // Try to revoke with wrong workspace ID
247 const result = await revokeApiKey(key.key.id, 999);
248
249 expect(result).toBe(false);
250
251 // Verify key still exists
252 const stillExists = await db
253 .select()
254 .from(apiKey)
255 .where(eq(apiKey.id, key.key.id))
256 .get();
257
258 expect(stillExists).toBeDefined();
259
260 // Clean up
261 await db.delete(apiKey).where(eq(apiKey.id, key.key.id));
262 });
263});
264
265describe("getApiKeys", () => {
266 test("should get all API keys for a workspace", async () => {
267 // Create multiple keys
268 const key1 = await createApiKey(
269 testWorkspaceId,
270 testUserId,
271 "Workspace Key 1",
272 );
273 const key2 = await createApiKey(
274 testWorkspaceId,
275 testUserId,
276 "Workspace Key 2",
277 );
278 const key3 = await createApiKey(
279 testWorkspaceId,
280 testUserId,
281 "Workspace Key 3",
282 );
283
284 const keys = await getApiKeys(testWorkspaceId);
285
286 // Should include at least the 3 keys we just created plus the test key from earlier
287 expect(keys.length).toBeGreaterThanOrEqual(4);
288 expect(keys.some((k) => k.name === "Workspace Key 1")).toBe(true);
289 expect(keys.some((k) => k.name === "Workspace Key 2")).toBe(true);
290 expect(keys.some((k) => k.name === "Workspace Key 3")).toBe(true);
291
292 // All keys should belong to the test workspace
293 keys.forEach((key) => {
294 expect(key.workspaceId).toBe(testWorkspaceId);
295 });
296
297 // Clean up
298 await db.delete(apiKey).where(eq(apiKey.id, key1.key.id));
299 await db.delete(apiKey).where(eq(apiKey.id, key2.key.id));
300 await db.delete(apiKey).where(eq(apiKey.id, key3.key.id));
301 });
302
303 test("should return empty array for workspace with no keys", async () => {
304 // Use a non-existent workspace ID
305 const keys = await getApiKeys(999999);
306
307 expect(keys).toEqual([]);
308 });
309
310 test("should not include keys from other workspaces", async () => {
311 // Assuming there might be other workspaces, verify isolation
312 const keys = await getApiKeys(testWorkspaceId);
313
314 keys.forEach((key) => {
315 expect(key.workspaceId).toBe(testWorkspaceId);
316 });
317 });
318});
319
320describe("updateLastUsed", () => {
321 test("should update lastUsedAt when never used", async () => {
322 const key = await createApiKey(
323 testWorkspaceId,
324 testUserId,
325 "Never Used Key",
326 );
327
328 const result = await updateLastUsed(key.key.id, null);
329
330 expect(result).toBe(true);
331
332 // Verify the update
333 const updatedKey = await db
334 .select()
335 .from(apiKey)
336 .where(eq(apiKey.id, key.key.id))
337 .get();
338
339 expect(updatedKey?.lastUsedAt).not.toBeNull();
340 expect(updatedKey?.lastUsedAt).toBeInstanceOf(Date);
341
342 // Clean up
343 await db.delete(apiKey).where(eq(apiKey.id, key.key.id));
344 });
345
346 test("should update lastUsedAt when debounce period has passed", async () => {
347 const key = await createApiKey(
348 testWorkspaceId,
349 testUserId,
350 "Debounce Test Key",
351 );
352
353 // Set lastUsedAt to 10 minutes ago (beyond 5-minute debounce)
354 const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
355 await db
356 .update(apiKey)
357 .set({ lastUsedAt: tenMinutesAgo })
358 .where(eq(apiKey.id, key.key.id));
359
360 const result = await updateLastUsed(key.key.id, tenMinutesAgo);
361
362 expect(result).toBe(true);
363
364 // Verify the update
365 const updatedKey = await db
366 .select()
367 .from(apiKey)
368 .where(eq(apiKey.id, key.key.id))
369 .get();
370
371 expect(updatedKey?.lastUsedAt?.getTime()).toBeGreaterThan(
372 tenMinutesAgo.getTime(),
373 );
374
375 // Clean up
376 await db.delete(apiKey).where(eq(apiKey.id, key.key.id));
377 });
378
379 test("should not update lastUsedAt within debounce period", async () => {
380 const key = await createApiKey(
381 testWorkspaceId,
382 testUserId,
383 "Recent Use Key",
384 );
385
386 // Set lastUsedAt to 2 minutes ago (within 5-minute debounce)
387 const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
388 await db
389 .update(apiKey)
390 .set({ lastUsedAt: twoMinutesAgo })
391 .where(eq(apiKey.id, key.key.id));
392
393 const result = await updateLastUsed(key.key.id, twoMinutesAgo);
394
395 expect(result).toBe(false);
396
397 // Verify no update occurred (compare with tolerance due to SQLite timestamp precision)
398 const updatedKey = await db
399 .select()
400 .from(apiKey)
401 .where(eq(apiKey.id, key.key.id))
402 .get();
403
404 expect(updatedKey?.lastUsedAt?.getTime()).toBeCloseTo(
405 twoMinutesAgo.getTime(),
406 -4,
407 );
408
409 // Clean up
410 await db.delete(apiKey).where(eq(apiKey.id, key.key.id));
411 });
412
413 test("should update at exactly 5 minutes (boundary test)", async () => {
414 const key = await createApiKey(
415 testWorkspaceId,
416 testUserId,
417 "Boundary Test Key",
418 );
419
420 // Set lastUsedAt to exactly 5 minutes and 1ms ago
421 const fiveMinutesAgo = new Date(Date.now() - (5 * 60 * 1000 + 1));
422 await db
423 .update(apiKey)
424 .set({ lastUsedAt: fiveMinutesAgo })
425 .where(eq(apiKey.id, key.key.id));
426
427 const result = await updateLastUsed(key.key.id, fiveMinutesAgo);
428
429 expect(result).toBe(true);
430
431 // Clean up
432 await db.delete(apiKey).where(eq(apiKey.id, key.key.id));
433 });
434});