Openstatus
www.openstatus.dev
1import { describe, expect, it } from "bun:test";
2import {
3 generateApiKey,
4 hashApiKey,
5 shouldUpdateLastUsed,
6 verifyApiKeyHash,
7} from "./api-key";
8
9describe("API Key Utilities", () => {
10 describe("generateApiKey", () => {
11 it("should generate a token with correct format", async () => {
12 const { token, prefix, hash } = await generateApiKey();
13
14 // Token should start with "os_" and be 35 chars total (os_ + 32 hex)
15 expect(token).toMatch(/^os_[a-f0-9]{32}$/);
16 expect(token.length).toBe(35);
17 });
18
19 it("should generate a prefix with correct format", async () => {
20 const { prefix } = await generateApiKey();
21
22 // Prefix should be "os_" + 8 chars = 11 chars total
23 expect(prefix).toMatch(/^os_[a-f0-9]{8}$/);
24 expect(prefix.length).toBe(11);
25 });
26
27 it("should generate unique tokens", async () => {
28 const key1 = await generateApiKey();
29 const key2 = await generateApiKey();
30
31 expect(key1.token).not.toBe(key2.token);
32 expect(key1.hash).not.toBe(key2.hash);
33 });
34
35 it("should generate prefix from token start", async () => {
36 const { token, prefix } = await generateApiKey();
37
38 expect(token.slice(0, 11)).toBe(prefix);
39 });
40 });
41
42 describe("hashApiKey", () => {
43 it("should generate hash that can verify the token", async () => {
44 const { token, hash } = await generateApiKey();
45
46 expect(await verifyApiKeyHash(token, hash)).toBe(true);
47 });
48 });
49
50 describe("hashApiKey", () => {
51 it("should generate different hashes for different tokens", async () => {
52 const hash1 = await hashApiKey("os_token1");
53 const hash2 = await hashApiKey("os_token2");
54
55 expect(hash1).not.toBe(hash2);
56 });
57
58 it("should generate a valid bcrypt hash", async () => {
59 const hash = await hashApiKey("os_test_token");
60
61 // Bcrypt hashes start with $2a$, $2b$, or $2y$
62 expect(hash).toMatch(/^\$2[aby]\$/);
63 });
64
65 it("should generate hash that can verify the original token", async () => {
66 const token = "os_test_token_12345";
67 const hash = await hashApiKey(token);
68
69 expect(await verifyApiKeyHash(token, hash)).toBe(true);
70 });
71
72 it("should generate different hashes for same token on multiple calls", async () => {
73 const token = "os_same_token";
74 const hash1 = await hashApiKey(token);
75 const hash2 = await hashApiKey(token);
76
77 // bcrypt uses salt, so same input produces different hashes
78 expect(hash1).not.toBe(hash2);
79 // But both should verify the token
80 expect(await verifyApiKeyHash(token, hash1)).toBe(true);
81 expect(await verifyApiKeyHash(token, hash2)).toBe(true);
82 });
83 });
84
85 describe("verifyApiKeyHash", () => {
86 it("should return true for valid bcrypt hash with correct token", async () => {
87 const token = "os_valid_token_12345";
88 const hash = await hashApiKey(token);
89
90 expect(await verifyApiKeyHash(token, hash)).toBe(true);
91 });
92
93 it("should return false for valid bcrypt hash with wrong token", async () => {
94 const correctToken = "os_correct_token";
95 const wrongToken = "os_wrong_token";
96 const hash = await hashApiKey(correctToken);
97
98 expect(await verifyApiKeyHash(wrongToken, hash)).toBe(false);
99 });
100
101 it("should return false for non-bcrypt hash format", async () => {
102 const token = "os_test_token";
103 const invalidHash = "not_a_bcrypt_hash";
104
105 expect(await verifyApiKeyHash(token, invalidHash)).toBe(false);
106 });
107
108 it("should return false for SHA-256 hash format", async () => {
109 const token = "os_test_token";
110 // SHA-256 hashes are 64 hex characters
111 const sha256Hash =
112 "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8";
113
114 expect(await verifyApiKeyHash(token, sha256Hash)).toBe(false);
115 });
116
117 it("should return false for empty hash", async () => {
118 const token = "os_test_token";
119
120 expect(await verifyApiKeyHash(token, "")).toBe(false);
121 });
122
123 it("should return false for empty token with valid hash", async () => {
124 const hash = await hashApiKey("os_some_token");
125
126 expect(await verifyApiKeyHash("", hash)).toBe(false);
127 });
128
129 it("should handle bcrypt hashes with different cost factors", async () => {
130 const token = "os_test_token";
131 const hash = await hashApiKey(token);
132
133 // Should work regardless of the $2a$, $2b$, or $2y$ variant
134 expect(await verifyApiKeyHash(token, hash)).toBe(true);
135 });
136 });
137
138 describe("shouldUpdateLastUsed", () => {
139 it("should return true when lastUsedAt is null", () => {
140 expect(shouldUpdateLastUsed(null)).toBe(true);
141 });
142
143 it("should return true when enough time has passed", () => {
144 const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
145 expect(shouldUpdateLastUsed(sixMinutesAgo, 5)).toBe(true);
146 });
147
148 it("should return false when not enough time has passed", () => {
149 const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
150 expect(shouldUpdateLastUsed(twoMinutesAgo, 5)).toBe(false);
151 });
152
153 it("should respect custom debounce period", () => {
154 const threeMinutesAgo = new Date(Date.now() - 3 * 60 * 1000);
155 expect(shouldUpdateLastUsed(threeMinutesAgo, 2)).toBe(true);
156 expect(shouldUpdateLastUsed(threeMinutesAgo, 4)).toBe(false);
157 });
158
159 it("should return false when just updated", () => {
160 const justNow = new Date();
161 expect(shouldUpdateLastUsed(justNow, 5)).toBe(false);
162 });
163
164 it("should handle boundary case at exact debounce time", () => {
165 const exactlyFiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
166 // At exactly the debounce time, it should not update (needs to be > not >=)
167 expect(shouldUpdateLastUsed(exactlyFiveMinutesAgo, 5)).toBe(false);
168 });
169
170 it("should handle boundary case just after debounce time", () => {
171 const justOverFiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000 - 1);
172 expect(shouldUpdateLastUsed(justOverFiveMinutesAgo, 5)).toBe(true);
173 });
174
175 it("should use default debounce of 5 minutes when not specified", () => {
176 const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000);
177 const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
178
179 expect(shouldUpdateLastUsed(fourMinutesAgo)).toBe(false);
180 expect(shouldUpdateLastUsed(sixMinutesAgo)).toBe(true);
181 });
182
183 it("should handle zero debounce period", () => {
184 const oneSecondAgo = new Date(Date.now() - 1000);
185 expect(shouldUpdateLastUsed(oneSecondAgo, 0)).toBe(true);
186 });
187
188 it("should handle very long debounce periods", () => {
189 const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
190 expect(shouldUpdateLastUsed(oneHourAgo, 120)).toBe(false); // 2 hours
191 expect(shouldUpdateLastUsed(oneHourAgo, 30)).toBe(true); // 30 minutes
192 });
193 });
194});