@brika/auth 0.1.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.
- package/README.md +207 -0
- package/package.json +50 -0
- package/src/__tests__/AuthClient.test.ts +736 -0
- package/src/__tests__/AuthService.test.ts +140 -0
- package/src/__tests__/ScopeService.test.ts +156 -0
- package/src/__tests__/SessionService.test.ts +311 -0
- package/src/__tests__/UserService-avatar.test.ts +277 -0
- package/src/__tests__/UserService.test.ts +223 -0
- package/src/__tests__/canAccess.test.ts +166 -0
- package/src/__tests__/disabledScopes.test.ts +101 -0
- package/src/__tests__/middleware.test.ts +190 -0
- package/src/__tests__/plugin.test.ts +78 -0
- package/src/__tests__/requireSession.test.ts +78 -0
- package/src/__tests__/routes-auth.test.ts +248 -0
- package/src/__tests__/routes-profile.test.ts +403 -0
- package/src/__tests__/routes-scopes.test.ts +64 -0
- package/src/__tests__/routes-sessions.test.ts +235 -0
- package/src/__tests__/routes-users.test.ts +477 -0
- package/src/__tests__/serveImage.test.ts +277 -0
- package/src/__tests__/setup.test.ts +270 -0
- package/src/__tests__/verifyToken.test.ts +219 -0
- package/src/client/AuthClient.ts +312 -0
- package/src/client/http-client.ts +84 -0
- package/src/client/index.ts +19 -0
- package/src/config.ts +82 -0
- package/src/constants.ts +10 -0
- package/src/index.ts +16 -0
- package/src/lib/define-roles.ts +35 -0
- package/src/lib/define-scopes.ts +48 -0
- package/src/middleware/canAccess.ts +126 -0
- package/src/middleware/index.ts +13 -0
- package/src/middleware/requireAuth.ts +35 -0
- package/src/middleware/requireScope.ts +46 -0
- package/src/middleware/verifyToken.ts +52 -0
- package/src/plugin.ts +86 -0
- package/src/react/AuthProvider.tsx +105 -0
- package/src/react/hooks.ts +128 -0
- package/src/react/index.ts +51 -0
- package/src/react/withScopeGuard.tsx +73 -0
- package/src/roles.ts +40 -0
- package/src/schemas.ts +112 -0
- package/src/scopes.ts +60 -0
- package/src/server/index.ts +44 -0
- package/src/server/requireSession.ts +44 -0
- package/src/server/routes/auth.ts +102 -0
- package/src/server/routes/cookie.ts +7 -0
- package/src/server/routes/index.ts +32 -0
- package/src/server/routes/profile.ts +162 -0
- package/src/server/routes/scopes.ts +22 -0
- package/src/server/routes/sessions.ts +68 -0
- package/src/server/routes/setup.ts +50 -0
- package/src/server/routes/users.ts +175 -0
- package/src/server/serveImage.ts +91 -0
- package/src/services/AuthService.ts +80 -0
- package/src/services/ScopeService.ts +94 -0
- package/src/services/SessionService.ts +245 -0
- package/src/services/UserService.ts +245 -0
- package/src/setup.ts +99 -0
- package/src/tanstack/index.ts +15 -0
- package/src/tanstack/routeBuilder.ts +311 -0
- package/src/types.ts +118 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - AuthClient Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the AuthClient class that makes fetch() calls to the auth API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'bun:test';
|
|
8
|
+
import { AuthClient, createAuthClient, getAuthClient } from '../client/AuthClient';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Mock fetch
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const mockFetch = vi.fn();
|
|
15
|
+
let originalFetch: typeof globalThis.fetch;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
originalFetch = globalThis.fetch;
|
|
19
|
+
globalThis.fetch = Object.assign(mockFetch, {
|
|
20
|
+
preconnect: () => {},
|
|
21
|
+
});
|
|
22
|
+
mockFetch.mockReset();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
globalThis.fetch = originalFetch;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function fetchCall(index: number) {
|
|
34
|
+
const call = mockFetch.mock.calls[index];
|
|
35
|
+
if (!call) {
|
|
36
|
+
throw new Error(`Expected fetch call at index ${index}`);
|
|
37
|
+
}
|
|
38
|
+
return call;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
42
|
+
return new Response(JSON.stringify(body), {
|
|
43
|
+
status,
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const mockUser = {
|
|
51
|
+
id: 'user-1',
|
|
52
|
+
email: 'test@example.com',
|
|
53
|
+
name: 'Test User',
|
|
54
|
+
role: 'user',
|
|
55
|
+
avatarHash: null,
|
|
56
|
+
createdAt: '2025-01-01T00:00:00Z',
|
|
57
|
+
updatedAt: '2025-01-01T00:00:00Z',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Constructor
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
describe('AuthClient', () => {
|
|
65
|
+
describe('constructor', () => {
|
|
66
|
+
it('should use provided apiUrl', () => {
|
|
67
|
+
const client = new AuthClient({
|
|
68
|
+
apiUrl: 'http://custom:9000',
|
|
69
|
+
});
|
|
70
|
+
// Verify by building an avatar URL (exposes the apiUrl)
|
|
71
|
+
const url = client.avatarUrl({
|
|
72
|
+
id: 'user-1',
|
|
73
|
+
});
|
|
74
|
+
expect(url).toStartWith('http://custom:9000');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should default to localhost:3001 when no window', () => {
|
|
78
|
+
const client = new AuthClient();
|
|
79
|
+
const url = client.avatarUrl({
|
|
80
|
+
id: 'user-1',
|
|
81
|
+
});
|
|
82
|
+
expect(url).toStartWith('http://localhost:3001');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// login
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('login', () => {
|
|
91
|
+
it('should POST credentials and return session', async () => {
|
|
92
|
+
// login() makes 2 calls: POST /login then GET /session
|
|
93
|
+
mockFetch.mockResolvedValueOnce(
|
|
94
|
+
jsonResponse({
|
|
95
|
+
user: mockUser,
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
mockFetch.mockResolvedValueOnce(
|
|
99
|
+
jsonResponse({
|
|
100
|
+
user: mockUser,
|
|
101
|
+
scopes: ['workflow:read'],
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const client = new AuthClient({
|
|
106
|
+
apiUrl: 'http://test',
|
|
107
|
+
});
|
|
108
|
+
const session = await client.login('test@example.com', 'password123');
|
|
109
|
+
|
|
110
|
+
expect(session.user).toEqual(mockUser);
|
|
111
|
+
expect(session.scopes).toEqual(['workflow:read']);
|
|
112
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
113
|
+
|
|
114
|
+
const [url, opts] = fetchCall(0);
|
|
115
|
+
expect(url).toBe('http://test/api/auth/login');
|
|
116
|
+
expect(opts.method).toBe('POST');
|
|
117
|
+
expect(opts.credentials).toBe('include');
|
|
118
|
+
expect(JSON.parse(opts.body)).toEqual({
|
|
119
|
+
email: 'test@example.com',
|
|
120
|
+
password: 'password123',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const [sessionUrl] = fetchCall(1);
|
|
124
|
+
expect(sessionUrl).toBe('http://test/api/auth/session');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should throw on failed login with 401', async () => {
|
|
128
|
+
mockFetch.mockResolvedValueOnce(
|
|
129
|
+
jsonResponse(
|
|
130
|
+
{
|
|
131
|
+
error: 'Invalid credentials',
|
|
132
|
+
},
|
|
133
|
+
401
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const client = new AuthClient({
|
|
138
|
+
apiUrl: 'http://test',
|
|
139
|
+
});
|
|
140
|
+
await expect(client.login('bad@example.com', 'wrong')).rejects.toThrow('Invalid credentials');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should throw on failed login with non-ok response', async () => {
|
|
144
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({}, 400));
|
|
145
|
+
|
|
146
|
+
const client = new AuthClient({
|
|
147
|
+
apiUrl: 'http://test',
|
|
148
|
+
});
|
|
149
|
+
await expect(client.login('x@x.com', 'x')).rejects.toThrow('Request failed');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// logout
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe('logout', () => {
|
|
158
|
+
it('should POST to logout endpoint', async () => {
|
|
159
|
+
mockFetch.mockResolvedValueOnce(
|
|
160
|
+
new Response(null, {
|
|
161
|
+
status: 204,
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const client = new AuthClient({
|
|
166
|
+
apiUrl: 'http://test',
|
|
167
|
+
});
|
|
168
|
+
await client.logout();
|
|
169
|
+
|
|
170
|
+
const [url, opts] = fetchCall(0);
|
|
171
|
+
expect(url).toBe('http://test/api/auth/logout');
|
|
172
|
+
expect(opts.method).toBe('POST');
|
|
173
|
+
expect(opts.credentials).toBe('include');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should silently fail on network error', async () => {
|
|
177
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
178
|
+
|
|
179
|
+
const client = new AuthClient({
|
|
180
|
+
apiUrl: 'http://test',
|
|
181
|
+
});
|
|
182
|
+
// Should not throw
|
|
183
|
+
await client.logout();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// getSession
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
describe('getSession', () => {
|
|
192
|
+
it('should return session on success', async () => {
|
|
193
|
+
mockFetch.mockResolvedValueOnce(
|
|
194
|
+
jsonResponse({
|
|
195
|
+
user: mockUser,
|
|
196
|
+
scopes: ['workflow:read'],
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const client = new AuthClient({
|
|
201
|
+
apiUrl: 'http://test',
|
|
202
|
+
});
|
|
203
|
+
const session = await client.getSession();
|
|
204
|
+
|
|
205
|
+
expect(session).not.toBeNull();
|
|
206
|
+
expect(session?.user).toEqual(mockUser);
|
|
207
|
+
expect(session?.scopes).toEqual(['workflow:read']);
|
|
208
|
+
|
|
209
|
+
const [url, opts] = fetchCall(0);
|
|
210
|
+
expect(url).toBe('http://test/api/auth/session');
|
|
211
|
+
expect(opts.credentials).toBe('include');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should return null on non-ok response', async () => {
|
|
215
|
+
mockFetch.mockResolvedValueOnce(
|
|
216
|
+
new Response(null, {
|
|
217
|
+
status: 401,
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const client = new AuthClient({
|
|
222
|
+
apiUrl: 'http://test',
|
|
223
|
+
});
|
|
224
|
+
const session = await client.getSession();
|
|
225
|
+
expect(session).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should return null on network error', async () => {
|
|
229
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
230
|
+
|
|
231
|
+
const client = new AuthClient({
|
|
232
|
+
apiUrl: 'http://test',
|
|
233
|
+
});
|
|
234
|
+
const session = await client.getSession();
|
|
235
|
+
expect(session).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// updateProfile
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
describe('updateProfile', () => {
|
|
244
|
+
it('should PUT profile updates and return session', async () => {
|
|
245
|
+
const updatedUser = {
|
|
246
|
+
...mockUser,
|
|
247
|
+
name: 'New Name',
|
|
248
|
+
};
|
|
249
|
+
mockFetch.mockResolvedValueOnce(
|
|
250
|
+
jsonResponse({
|
|
251
|
+
user: updatedUser,
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const client = new AuthClient({
|
|
256
|
+
apiUrl: 'http://test',
|
|
257
|
+
});
|
|
258
|
+
const result = await client.updateProfile({
|
|
259
|
+
name: 'New Name',
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(result).toEqual({
|
|
263
|
+
user: updatedUser,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const [url, opts] = fetchCall(0);
|
|
267
|
+
expect(url).toBe('http://test/api/auth/profile');
|
|
268
|
+
expect(opts.method).toBe('PUT');
|
|
269
|
+
expect(opts.credentials).toBe('include');
|
|
270
|
+
expect(JSON.parse(opts.body)).toEqual({
|
|
271
|
+
name: 'New Name',
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should throw on 401', async () => {
|
|
276
|
+
mockFetch.mockResolvedValueOnce(
|
|
277
|
+
new Response(null, {
|
|
278
|
+
status: 401,
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const client = new AuthClient({
|
|
283
|
+
apiUrl: 'http://test',
|
|
284
|
+
});
|
|
285
|
+
await expect(
|
|
286
|
+
client.updateProfile({
|
|
287
|
+
name: 'X',
|
|
288
|
+
})
|
|
289
|
+
).rejects.toThrow('Unauthorized');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// uploadAvatar
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
describe('uploadAvatar', () => {
|
|
298
|
+
it('should PUT blob and return avatar hash', async () => {
|
|
299
|
+
mockFetch.mockResolvedValueOnce(
|
|
300
|
+
jsonResponse({
|
|
301
|
+
ok: true,
|
|
302
|
+
avatarHash: 'abc123',
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const blob = new Blob(['fake-image'], {
|
|
307
|
+
type: 'image/png',
|
|
308
|
+
});
|
|
309
|
+
const client = new AuthClient({
|
|
310
|
+
apiUrl: 'http://test',
|
|
311
|
+
});
|
|
312
|
+
const hash = await client.uploadAvatar(blob);
|
|
313
|
+
|
|
314
|
+
expect(hash).toBe('abc123');
|
|
315
|
+
|
|
316
|
+
const [url, opts] = fetchCall(0);
|
|
317
|
+
expect(url).toBe('http://test/api/auth/profile/avatar');
|
|
318
|
+
expect(opts.method).toBe('PUT');
|
|
319
|
+
expect(opts.credentials).toBe('include');
|
|
320
|
+
expect(opts.body).toBe(blob);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('should throw on upload failure with server error', async () => {
|
|
324
|
+
mockFetch.mockResolvedValueOnce(
|
|
325
|
+
jsonResponse(
|
|
326
|
+
{
|
|
327
|
+
error: 'File too large',
|
|
328
|
+
},
|
|
329
|
+
413
|
|
330
|
+
)
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const blob = new Blob(['big-image']);
|
|
334
|
+
const client = new AuthClient({
|
|
335
|
+
apiUrl: 'http://test',
|
|
336
|
+
});
|
|
337
|
+
await expect(client.uploadAvatar(blob)).rejects.toThrow('File too large');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should throw generic message when no error field', async () => {
|
|
341
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({}, 500));
|
|
342
|
+
|
|
343
|
+
const blob = new Blob(['x']);
|
|
344
|
+
const client = new AuthClient({
|
|
345
|
+
apiUrl: 'http://test',
|
|
346
|
+
});
|
|
347
|
+
await expect(client.uploadAvatar(blob)).rejects.toThrow('Request failed');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// removeAvatar
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
describe('removeAvatar', () => {
|
|
356
|
+
it('should DELETE avatar', async () => {
|
|
357
|
+
mockFetch.mockResolvedValueOnce(
|
|
358
|
+
jsonResponse({
|
|
359
|
+
ok: true,
|
|
360
|
+
})
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const client = new AuthClient({
|
|
364
|
+
apiUrl: 'http://test',
|
|
365
|
+
});
|
|
366
|
+
await client.removeAvatar();
|
|
367
|
+
|
|
368
|
+
const [url, opts] = fetchCall(0);
|
|
369
|
+
expect(url).toBe('http://test/api/auth/profile/avatar');
|
|
370
|
+
expect(opts.method).toBe('DELETE');
|
|
371
|
+
expect(opts.credentials).toBe('include');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should throw on 401', async () => {
|
|
375
|
+
mockFetch.mockResolvedValueOnce(
|
|
376
|
+
new Response(null, {
|
|
377
|
+
status: 401,
|
|
378
|
+
})
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const client = new AuthClient({
|
|
382
|
+
apiUrl: 'http://test',
|
|
383
|
+
});
|
|
384
|
+
await expect(client.removeAvatar()).rejects.toThrow('Unauthorized');
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// avatarUrl
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
describe('avatarUrl', () => {
|
|
393
|
+
const user = {
|
|
394
|
+
id: 'user-1',
|
|
395
|
+
avatarHash: null as string | null,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
it('should build basic avatar URL', () => {
|
|
399
|
+
const client = new AuthClient({
|
|
400
|
+
apiUrl: 'http://test',
|
|
401
|
+
});
|
|
402
|
+
const url = client.avatarUrl(user);
|
|
403
|
+
expect(url).toBe('http://test/api/auth/avatar/user-1');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should include size as s param (size * dpr)', () => {
|
|
407
|
+
const client = new AuthClient({
|
|
408
|
+
apiUrl: 'http://test',
|
|
409
|
+
});
|
|
410
|
+
const url = client.avatarUrl(user, {
|
|
411
|
+
size: 128,
|
|
412
|
+
dpr: 1,
|
|
413
|
+
});
|
|
414
|
+
expect(url).toContain('s=128');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should multiply size by dpr', () => {
|
|
418
|
+
const client = new AuthClient({
|
|
419
|
+
apiUrl: 'http://test',
|
|
420
|
+
});
|
|
421
|
+
const url = client.avatarUrl(user, {
|
|
422
|
+
size: 64,
|
|
423
|
+
dpr: 2,
|
|
424
|
+
});
|
|
425
|
+
expect(url).toContain('s=128');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should not include s param when no size given', () => {
|
|
429
|
+
const client = new AuthClient({
|
|
430
|
+
apiUrl: 'http://test',
|
|
431
|
+
});
|
|
432
|
+
const url = client.avatarUrl(user, {
|
|
433
|
+
dpr: 2,
|
|
434
|
+
});
|
|
435
|
+
expect(url).not.toContain('s=');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should include hash param as v for cache busting', () => {
|
|
439
|
+
const client = new AuthClient({
|
|
440
|
+
apiUrl: 'http://test',
|
|
441
|
+
});
|
|
442
|
+
const url = client.avatarUrl({
|
|
443
|
+
id: 'user-1',
|
|
444
|
+
avatarHash: 'deadbeef',
|
|
445
|
+
});
|
|
446
|
+
expect(url).toContain('v=deadbeef');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should not include v param when avatarHash is null', () => {
|
|
450
|
+
const client = new AuthClient({
|
|
451
|
+
apiUrl: 'http://test',
|
|
452
|
+
});
|
|
453
|
+
const url = client.avatarUrl({
|
|
454
|
+
id: 'user-1',
|
|
455
|
+
avatarHash: null,
|
|
456
|
+
});
|
|
457
|
+
expect(url).not.toContain('v=');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should combine multiple params', () => {
|
|
461
|
+
const client = new AuthClient({
|
|
462
|
+
apiUrl: 'http://test',
|
|
463
|
+
});
|
|
464
|
+
const url = client.avatarUrl(
|
|
465
|
+
{
|
|
466
|
+
id: 'user-1',
|
|
467
|
+
avatarHash: 'abc',
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
size: 64,
|
|
471
|
+
dpr: 2,
|
|
472
|
+
}
|
|
473
|
+
);
|
|
474
|
+
expect(url).toContain('s=128');
|
|
475
|
+
expect(url).toContain('v=abc');
|
|
476
|
+
expect(url).toStartWith('http://test/api/auth/avatar/user-1?');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should have no query string when no options and no avatarHash', () => {
|
|
480
|
+
const client = new AuthClient({
|
|
481
|
+
apiUrl: 'http://test',
|
|
482
|
+
});
|
|
483
|
+
const url = client.avatarUrl(user);
|
|
484
|
+
expect(url).not.toContain('?');
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// listSessions
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
describe('listSessions', () => {
|
|
493
|
+
it('should return sessions array', async () => {
|
|
494
|
+
const sessions = [
|
|
495
|
+
{
|
|
496
|
+
id: 's1',
|
|
497
|
+
ip: '127.0.0.1',
|
|
498
|
+
userAgent: 'Chrome',
|
|
499
|
+
createdAt: 1,
|
|
500
|
+
lastSeenAt: 2,
|
|
501
|
+
current: true,
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
id: 's2',
|
|
505
|
+
ip: null,
|
|
506
|
+
userAgent: null,
|
|
507
|
+
createdAt: 3,
|
|
508
|
+
lastSeenAt: 4,
|
|
509
|
+
current: false,
|
|
510
|
+
},
|
|
511
|
+
];
|
|
512
|
+
mockFetch.mockResolvedValueOnce(
|
|
513
|
+
jsonResponse({
|
|
514
|
+
sessions,
|
|
515
|
+
})
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const client = new AuthClient({
|
|
519
|
+
apiUrl: 'http://test',
|
|
520
|
+
});
|
|
521
|
+
const result = await client.listSessions();
|
|
522
|
+
|
|
523
|
+
expect(result).toEqual(sessions);
|
|
524
|
+
expect(result).toHaveLength(2);
|
|
525
|
+
|
|
526
|
+
const [url] = fetchCall(0);
|
|
527
|
+
expect(url).toBe('http://test/api/auth/sessions');
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// revokeSession
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
describe('revokeSession', () => {
|
|
536
|
+
it('should DELETE a specific session', async () => {
|
|
537
|
+
mockFetch.mockResolvedValueOnce(
|
|
538
|
+
jsonResponse({
|
|
539
|
+
ok: true,
|
|
540
|
+
})
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
const client = new AuthClient({
|
|
544
|
+
apiUrl: 'http://test',
|
|
545
|
+
});
|
|
546
|
+
await client.revokeSession('sess-123');
|
|
547
|
+
|
|
548
|
+
const [url, opts] = fetchCall(0);
|
|
549
|
+
expect(url).toBe('http://test/api/auth/sessions/sess-123');
|
|
550
|
+
expect(opts.method).toBe('DELETE');
|
|
551
|
+
expect(opts.credentials).toBe('include');
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// changePassword
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
describe('changePassword', () => {
|
|
560
|
+
it('should PUT password change', async () => {
|
|
561
|
+
mockFetch.mockResolvedValueOnce(
|
|
562
|
+
jsonResponse({
|
|
563
|
+
ok: true,
|
|
564
|
+
})
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
const client = new AuthClient({
|
|
568
|
+
apiUrl: 'http://test',
|
|
569
|
+
});
|
|
570
|
+
await client.changePassword('oldPass1!', 'newPass2@');
|
|
571
|
+
|
|
572
|
+
const [url, opts] = fetchCall(0);
|
|
573
|
+
expect(url).toBe('http://test/api/auth/profile/password');
|
|
574
|
+
expect(opts.method).toBe('PUT');
|
|
575
|
+
expect(JSON.parse(opts.body)).toEqual({
|
|
576
|
+
currentPassword: 'oldPass1!',
|
|
577
|
+
newPassword: 'newPass2@',
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('should throw on failure', async () => {
|
|
582
|
+
mockFetch.mockResolvedValueOnce(
|
|
583
|
+
jsonResponse(
|
|
584
|
+
{
|
|
585
|
+
error: 'Current password is incorrect',
|
|
586
|
+
},
|
|
587
|
+
400
|
|
588
|
+
)
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const client = new AuthClient({
|
|
592
|
+
apiUrl: 'http://test',
|
|
593
|
+
});
|
|
594
|
+
await expect(client.changePassword('wrong', 'new')).rejects.toThrow(
|
|
595
|
+
'Current password is incorrect'
|
|
596
|
+
);
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
// revokeAllSessions
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
describe('revokeAllSessions', () => {
|
|
605
|
+
it('should DELETE all sessions', async () => {
|
|
606
|
+
mockFetch.mockResolvedValueOnce(
|
|
607
|
+
jsonResponse({
|
|
608
|
+
ok: true,
|
|
609
|
+
})
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const client = new AuthClient({
|
|
613
|
+
apiUrl: 'http://test',
|
|
614
|
+
});
|
|
615
|
+
await client.revokeAllSessions();
|
|
616
|
+
|
|
617
|
+
const [url, opts] = fetchCall(0);
|
|
618
|
+
expect(url).toBe('http://test/api/auth/sessions');
|
|
619
|
+
expect(opts.method).toBe('DELETE');
|
|
620
|
+
expect(opts.credentials).toBe('include');
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// request (generic)
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
describe('request', () => {
|
|
629
|
+
it('should throw Unauthorized on 401', async () => {
|
|
630
|
+
mockFetch.mockResolvedValueOnce(
|
|
631
|
+
new Response(null, {
|
|
632
|
+
status: 401,
|
|
633
|
+
})
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const client = new AuthClient({
|
|
637
|
+
apiUrl: 'http://test',
|
|
638
|
+
});
|
|
639
|
+
await expect(client.request('/api/anything')).rejects.toThrow('Unauthorized');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('should throw server error message on non-ok response', async () => {
|
|
643
|
+
mockFetch.mockResolvedValueOnce(
|
|
644
|
+
jsonResponse(
|
|
645
|
+
{
|
|
646
|
+
error: 'Not found',
|
|
647
|
+
},
|
|
648
|
+
404
|
|
649
|
+
)
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
const client = new AuthClient({
|
|
653
|
+
apiUrl: 'http://test',
|
|
654
|
+
});
|
|
655
|
+
await expect(client.request('/api/missing')).rejects.toThrow('Not found');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('should throw generic message when error field is missing', async () => {
|
|
659
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({}, 500));
|
|
660
|
+
|
|
661
|
+
const client = new AuthClient({
|
|
662
|
+
apiUrl: 'http://test',
|
|
663
|
+
});
|
|
664
|
+
await expect(client.request('/api/broken')).rejects.toThrow('Request failed');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('should return parsed JSON on success', async () => {
|
|
668
|
+
mockFetch.mockResolvedValueOnce(
|
|
669
|
+
jsonResponse({
|
|
670
|
+
data: 'hello',
|
|
671
|
+
})
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
const client = new AuthClient({
|
|
675
|
+
apiUrl: 'http://test',
|
|
676
|
+
});
|
|
677
|
+
const result = await client.request<{
|
|
678
|
+
data: string;
|
|
679
|
+
}>('/api/test');
|
|
680
|
+
expect(result).toEqual({
|
|
681
|
+
data: 'hello',
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('should always include credentials', async () => {
|
|
686
|
+
mockFetch.mockResolvedValueOnce(
|
|
687
|
+
jsonResponse({
|
|
688
|
+
ok: true,
|
|
689
|
+
})
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
const client = new AuthClient({
|
|
693
|
+
apiUrl: 'http://test',
|
|
694
|
+
});
|
|
695
|
+
await client.request('/api/test', {
|
|
696
|
+
method: 'PATCH',
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const [, opts] = fetchCall(0);
|
|
700
|
+
expect(opts.credentials).toBe('include');
|
|
701
|
+
expect(opts.method).toBe('PATCH');
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
// getAuthClient / createAuthClient
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
describe('getAuthClient', () => {
|
|
710
|
+
it('should return the same singleton instance', () => {
|
|
711
|
+
// Note: getAuthClient uses a module-level singleton. We call createAuthClient
|
|
712
|
+
// to avoid polluting across tests, but we can still test the factory.
|
|
713
|
+
const a = createAuthClient({
|
|
714
|
+
apiUrl: 'http://a',
|
|
715
|
+
});
|
|
716
|
+
const b = createAuthClient({
|
|
717
|
+
apiUrl: 'http://b',
|
|
718
|
+
});
|
|
719
|
+
expect(a).not.toBe(b); // createAuthClient always returns a new instance
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
describe('createAuthClient', () => {
|
|
724
|
+
it('should create new instances each time', () => {
|
|
725
|
+
const a = createAuthClient({
|
|
726
|
+
apiUrl: 'http://test',
|
|
727
|
+
});
|
|
728
|
+
const b = createAuthClient({
|
|
729
|
+
apiUrl: 'http://test',
|
|
730
|
+
});
|
|
731
|
+
expect(a).not.toBe(b);
|
|
732
|
+
expect(a).toBeInstanceOf(AuthClient);
|
|
733
|
+
expect(b).toBeInstanceOf(AuthClient);
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
});
|