@tumbaland/backend-core 1.31.0 → 1.32.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/package.json +1 -1
- package/src/apiKeys/service.test.ts +352 -0
package/package.json
CHANGED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
const ORIGINAL_ENV = process.env;
|
|
2
|
+
|
|
3
|
+
jest.mock('./ApiKey', () => ({
|
|
4
|
+
ApiKey: {
|
|
5
|
+
create: jest.fn(),
|
|
6
|
+
find: jest.fn(),
|
|
7
|
+
findOne: jest.fn(),
|
|
8
|
+
updateOne: jest.fn(),
|
|
9
|
+
deleteOne: jest.fn()
|
|
10
|
+
}
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { ApiKey } from './ApiKey';
|
|
14
|
+
import {
|
|
15
|
+
createApiKey,
|
|
16
|
+
listApiKeys,
|
|
17
|
+
revealApiKey,
|
|
18
|
+
revokeApiKey,
|
|
19
|
+
deleteApiKey,
|
|
20
|
+
verifyApiKey
|
|
21
|
+
} from './service';
|
|
22
|
+
import { encryptSecret, generateKey, parseKey, resetEncryptionKeyCache, sha256 } from './crypto';
|
|
23
|
+
|
|
24
|
+
const mockedCreate = ApiKey.create as unknown as jest.Mock;
|
|
25
|
+
const mockedFind = ApiKey.find as unknown as jest.Mock;
|
|
26
|
+
const mockedFindOne = ApiKey.findOne as unknown as jest.Mock;
|
|
27
|
+
const mockedUpdateOne = ApiKey.updateOne as unknown as jest.Mock;
|
|
28
|
+
const mockedDeleteOne = ApiKey.deleteOne as unknown as jest.Mock;
|
|
29
|
+
|
|
30
|
+
const CREATED_AT = new Date('2026-09-01T00:00:00.000Z');
|
|
31
|
+
|
|
32
|
+
/** A stored key document, with the save()/field mutation the service relies on. */
|
|
33
|
+
function storedKey(over: Record<string, unknown> = {}) {
|
|
34
|
+
return {
|
|
35
|
+
_id: 'k1',
|
|
36
|
+
userId: 'u1',
|
|
37
|
+
userEmail: 'u1@example.com',
|
|
38
|
+
userName: 'Tester',
|
|
39
|
+
name: 'Claude',
|
|
40
|
+
keyId: 'abcdef123456',
|
|
41
|
+
hash: sha256('a-secret-value-long-enough'),
|
|
42
|
+
sealedCiphertext: 'ct',
|
|
43
|
+
sealedIv: 'iv',
|
|
44
|
+
sealedTag: 'tag',
|
|
45
|
+
scopes: ['relationship:read'],
|
|
46
|
+
groupId: null,
|
|
47
|
+
revealCount: 0,
|
|
48
|
+
lastRevealedAt: undefined as Date | undefined,
|
|
49
|
+
createdAt: CREATED_AT,
|
|
50
|
+
lastUsedAt: undefined,
|
|
51
|
+
expiresAt: undefined,
|
|
52
|
+
revokedAt: undefined,
|
|
53
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
54
|
+
...over
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
jest.clearAllMocks();
|
|
60
|
+
process.env = { ...ORIGINAL_ENV, API_KEY_ENCRYPTION_SECRET: 'test-encryption-secret' };
|
|
61
|
+
resetEncryptionKeyCache();
|
|
62
|
+
mockedUpdateOne.mockResolvedValue({});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
process.env = ORIGINAL_ENV;
|
|
67
|
+
resetEncryptionKeyCache();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('createApiKey', () => {
|
|
71
|
+
beforeEach(() => mockedCreate.mockImplementation(async (doc) => ({ ...doc, _id: 'k1', createdAt: CREATED_AT })));
|
|
72
|
+
|
|
73
|
+
const input = {
|
|
74
|
+
userId: 'u1',
|
|
75
|
+
userEmail: 'u1@example.com',
|
|
76
|
+
userName: 'Tester',
|
|
77
|
+
name: 'Claude',
|
|
78
|
+
scopes: ['relationship:read' as const]
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
it('returns a usable token and stores only its digest', async () => {
|
|
82
|
+
const { token } = await createApiKey(input);
|
|
83
|
+
const [stored] = mockedCreate.mock.calls[0];
|
|
84
|
+
|
|
85
|
+
// The plaintext must never reach the hash column — that is the whole point
|
|
86
|
+
// of keeping a digest alongside the reversible copy.
|
|
87
|
+
expect(stored.hash).not.toContain(token);
|
|
88
|
+
expect(stored.hash).toBe(sha256(parseKey(token)!.secret));
|
|
89
|
+
expect(stored.keyId).toBe(parseKey(token)!.id);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('stores the token encrypted, recoverable only with the secret', async () => {
|
|
93
|
+
const { token } = await createApiKey(input);
|
|
94
|
+
const [stored] = mockedCreate.mock.calls[0];
|
|
95
|
+
|
|
96
|
+
expect(stored.sealedCiphertext).not.toContain(token);
|
|
97
|
+
expect(stored.sealedIv).toBeTruthy();
|
|
98
|
+
expect(stored.sealedTag).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('defaults an unpinned key to the owner’s personal scope', async () => {
|
|
102
|
+
await createApiKey(input);
|
|
103
|
+
expect(mockedCreate.mock.calls[0][0].groupId).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('pins a key to the group it was issued for', async () => {
|
|
107
|
+
await createApiKey({ ...input, groupId: 'g1' });
|
|
108
|
+
expect(mockedCreate.mock.calls[0][0].groupId).toBe('g1');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('snapshots the owner so verification needs no second query', async () => {
|
|
112
|
+
await createApiKey(input);
|
|
113
|
+
expect(mockedCreate.mock.calls[0][0]).toMatchObject({
|
|
114
|
+
userEmail: 'u1@example.com',
|
|
115
|
+
userName: 'Tester'
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('never returns the secret material in the summary', async () => {
|
|
120
|
+
const { summary } = await createApiKey(input);
|
|
121
|
+
expect(summary).not.toHaveProperty('hash');
|
|
122
|
+
expect(summary).not.toHaveProperty('sealedCiphertext');
|
|
123
|
+
expect(summary.prefix).toBe(`tmb_live_${mockedCreate.mock.calls[0][0].keyId}`);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('listApiKeys', () => {
|
|
128
|
+
it('lists the owner’s keys newest first, carrying no secrets', async () => {
|
|
129
|
+
const sort = jest.fn().mockResolvedValue([storedKey(), storedKey({ _id: 'k2', name: 'Other' })]);
|
|
130
|
+
mockedFind.mockReturnValue({ sort });
|
|
131
|
+
|
|
132
|
+
const keys = await listApiKeys('u1');
|
|
133
|
+
|
|
134
|
+
expect(mockedFind).toHaveBeenCalledWith({ userId: 'u1' });
|
|
135
|
+
expect(sort).toHaveBeenCalledWith({ createdAt: -1 });
|
|
136
|
+
for (const key of keys) {
|
|
137
|
+
expect(key).not.toHaveProperty('hash');
|
|
138
|
+
expect(key).not.toHaveProperty('sealedCiphertext');
|
|
139
|
+
expect(JSON.stringify(key)).not.toContain('ct');
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders absent timestamps as null rather than undefined', async () => {
|
|
144
|
+
mockedFind.mockReturnValue({ sort: jest.fn().mockResolvedValue([storedKey()]) });
|
|
145
|
+
const [key] = await listApiKeys('u1');
|
|
146
|
+
|
|
147
|
+
expect(key.lastUsedAt).toBeNull();
|
|
148
|
+
expect(key.expiresAt).toBeNull();
|
|
149
|
+
expect(key.revokedAt).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('revealApiKey', () => {
|
|
154
|
+
it('decrypts the stored token for its owner', async () => {
|
|
155
|
+
const { token } = generateKey();
|
|
156
|
+
const sealed = encryptSecret(token);
|
|
157
|
+
const key = storedKey({
|
|
158
|
+
sealedCiphertext: sealed.ciphertext,
|
|
159
|
+
sealedIv: sealed.iv,
|
|
160
|
+
sealedTag: sealed.tag
|
|
161
|
+
});
|
|
162
|
+
mockedFindOne.mockResolvedValue(key);
|
|
163
|
+
|
|
164
|
+
await expect(revealApiKey('u1', 'k1')).resolves.toBe(token);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('scopes the lookup to the owner, so another user’s key is simply not found', async () => {
|
|
168
|
+
mockedFindOne.mockResolvedValue(null);
|
|
169
|
+
|
|
170
|
+
await expect(revealApiKey('someone-else', 'k1')).resolves.toBeNull();
|
|
171
|
+
// Ownership is part of the query rather than a check afterwards — there is
|
|
172
|
+
// no path where a mismatched user still reaches the decrypt.
|
|
173
|
+
expect(mockedFindOne).toHaveBeenCalledWith({ _id: 'k1', userId: 'someone-else' });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('counts and timestamps every reveal', async () => {
|
|
177
|
+
const sealed = encryptSecret(generateKey().token);
|
|
178
|
+
const key = storedKey({
|
|
179
|
+
sealedCiphertext: sealed.ciphertext,
|
|
180
|
+
sealedIv: sealed.iv,
|
|
181
|
+
sealedTag: sealed.tag,
|
|
182
|
+
revealCount: 2
|
|
183
|
+
});
|
|
184
|
+
mockedFindOne.mockResolvedValue(key);
|
|
185
|
+
|
|
186
|
+
await revealApiKey('u1', 'k1');
|
|
187
|
+
|
|
188
|
+
expect(key.revealCount).toBe(3);
|
|
189
|
+
expect(key.lastRevealedAt).toBeInstanceOf(Date);
|
|
190
|
+
expect(key.save).toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('still reveals a revoked key, so the owner can see what they turned off', async () => {
|
|
194
|
+
const { token } = generateKey();
|
|
195
|
+
const sealed = encryptSecret(token);
|
|
196
|
+
mockedFindOne.mockResolvedValue(
|
|
197
|
+
storedKey({
|
|
198
|
+
sealedCiphertext: sealed.ciphertext,
|
|
199
|
+
sealedIv: sealed.iv,
|
|
200
|
+
sealedTag: sealed.tag,
|
|
201
|
+
revokedAt: new Date()
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await expect(revealApiKey('u1', 'k1')).resolves.toBe(token);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('revokeApiKey', () => {
|
|
210
|
+
it('stamps the key revoked', async () => {
|
|
211
|
+
const key = storedKey();
|
|
212
|
+
mockedFindOne.mockResolvedValue(key);
|
|
213
|
+
|
|
214
|
+
const summary = await revokeApiKey('u1', 'k1');
|
|
215
|
+
|
|
216
|
+
expect(key.revokedAt).toBeInstanceOf(Date);
|
|
217
|
+
expect(key.save).toHaveBeenCalled();
|
|
218
|
+
expect(summary?.revokedAt).not.toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('leaves an already-revoked key’s original timestamp alone', async () => {
|
|
222
|
+
const revokedAt = new Date('2026-01-01T00:00:00.000Z');
|
|
223
|
+
const key = storedKey({ revokedAt });
|
|
224
|
+
mockedFindOne.mockResolvedValue(key);
|
|
225
|
+
|
|
226
|
+
await revokeApiKey('u1', 'k1');
|
|
227
|
+
|
|
228
|
+
expect(key.revokedAt).toBe(revokedAt);
|
|
229
|
+
expect(key.save).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('returns null for a key that is not the caller’s', async () => {
|
|
233
|
+
mockedFindOne.mockResolvedValue(null);
|
|
234
|
+
await expect(revokeApiKey('u2', 'k1')).resolves.toBeNull();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('deleteApiKey', () => {
|
|
239
|
+
it('deletes only within the owner’s keys', async () => {
|
|
240
|
+
mockedDeleteOne.mockResolvedValue({ deletedCount: 1 });
|
|
241
|
+
|
|
242
|
+
await expect(deleteApiKey('u1', 'k1')).resolves.toBe(true);
|
|
243
|
+
expect(mockedDeleteOne).toHaveBeenCalledWith({ _id: 'k1', userId: 'u1' });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('reports false when nothing matched', async () => {
|
|
247
|
+
mockedDeleteOne.mockResolvedValue({ deletedCount: 0 });
|
|
248
|
+
await expect(deleteApiKey('u2', 'k1')).resolves.toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('verifyApiKey', () => {
|
|
253
|
+
/** A stored record that will actually accept `token`. */
|
|
254
|
+
const acceptingKey = (token: string, over: Record<string, unknown> = {}) =>
|
|
255
|
+
storedKey({
|
|
256
|
+
keyId: parseKey(token)!.id,
|
|
257
|
+
hash: sha256(parseKey(token)!.secret),
|
|
258
|
+
...over
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('rejects a malformed token without touching the database', async () => {
|
|
262
|
+
await expect(verifyApiKey('not-a-key')).resolves.toEqual({ ok: false, rejection: 'malformed' });
|
|
263
|
+
expect(mockedFindOne).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('rejects a token for a key that does not exist', async () => {
|
|
267
|
+
mockedFindOne.mockResolvedValue(null);
|
|
268
|
+
const { token } = generateKey();
|
|
269
|
+
|
|
270
|
+
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'unknown' });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('rejects a revoked key', async () => {
|
|
274
|
+
const { token } = generateKey();
|
|
275
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token, { revokedAt: new Date() }));
|
|
276
|
+
|
|
277
|
+
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'revoked' });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('rejects a key whose expiry has passed', async () => {
|
|
281
|
+
const { token } = generateKey();
|
|
282
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token, { expiresAt: new Date(Date.now() - 1000) }));
|
|
283
|
+
|
|
284
|
+
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'expired' });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('accepts a key whose expiry is still ahead', async () => {
|
|
288
|
+
const { token } = generateKey();
|
|
289
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token, { expiresAt: new Date(Date.now() + 60_000) }));
|
|
290
|
+
|
|
291
|
+
await expect(verifyApiKey(token)).resolves.toMatchObject({ ok: true });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects a right-shaped token whose secret is wrong', async () => {
|
|
295
|
+
const { token } = generateKey();
|
|
296
|
+
const other = generateKey();
|
|
297
|
+
// Same public id, a different secret's digest — the id half is not a credential.
|
|
298
|
+
mockedFindOne.mockResolvedValue(
|
|
299
|
+
storedKey({ keyId: parseKey(token)!.id, hash: sha256(parseKey(other.token)!.secret) })
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'bad-secret' });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('looks a key up by its public id alone', async () => {
|
|
306
|
+
const { token } = generateKey();
|
|
307
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token));
|
|
308
|
+
|
|
309
|
+
await verifyApiKey(token);
|
|
310
|
+
|
|
311
|
+
expect(mockedFindOne).toHaveBeenCalledWith({ keyId: parseKey(token)!.id });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('returns the identity, scopes and tenant a valid key carries', async () => {
|
|
315
|
+
const { token } = generateKey();
|
|
316
|
+
mockedFindOne.mockResolvedValue(
|
|
317
|
+
acceptingKey(token, { scopes: ['relationship:read', 'album:write'], groupId: 'g1' })
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
await expect(verifyApiKey(token)).resolves.toEqual({
|
|
321
|
+
ok: true,
|
|
322
|
+
userId: 'u1',
|
|
323
|
+
userEmail: 'u1@example.com',
|
|
324
|
+
userName: 'Tester',
|
|
325
|
+
keyId: 'k1',
|
|
326
|
+
scopes: ['relationship:read', 'album:write'],
|
|
327
|
+
groupId: 'g1'
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('records that the key was used', async () => {
|
|
332
|
+
const { token } = generateKey();
|
|
333
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token));
|
|
334
|
+
|
|
335
|
+
await verifyApiKey(token);
|
|
336
|
+
|
|
337
|
+
expect(mockedUpdateOne).toHaveBeenCalledWith(
|
|
338
|
+
{ _id: 'k1' },
|
|
339
|
+
{ $set: { lastUsedAt: expect.any(Date) } }
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('still authenticates when recording the usage timestamp fails', async () => {
|
|
344
|
+
const { token } = generateKey();
|
|
345
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token));
|
|
346
|
+
mockedUpdateOne.mockRejectedValue(new Error('write concern failed'));
|
|
347
|
+
|
|
348
|
+
// The timestamp is a convenience for the listing UI; losing it must never
|
|
349
|
+
// cost an otherwise valid request.
|
|
350
|
+
await expect(verifyApiKey(token)).resolves.toMatchObject({ ok: true });
|
|
351
|
+
});
|
|
352
|
+
});
|