Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 434 lines 12 kB view raw
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});