@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.
Files changed (62) hide show
  1. package/README.md +207 -0
  2. package/package.json +50 -0
  3. package/src/__tests__/AuthClient.test.ts +736 -0
  4. package/src/__tests__/AuthService.test.ts +140 -0
  5. package/src/__tests__/ScopeService.test.ts +156 -0
  6. package/src/__tests__/SessionService.test.ts +311 -0
  7. package/src/__tests__/UserService-avatar.test.ts +277 -0
  8. package/src/__tests__/UserService.test.ts +223 -0
  9. package/src/__tests__/canAccess.test.ts +166 -0
  10. package/src/__tests__/disabledScopes.test.ts +101 -0
  11. package/src/__tests__/middleware.test.ts +190 -0
  12. package/src/__tests__/plugin.test.ts +78 -0
  13. package/src/__tests__/requireSession.test.ts +78 -0
  14. package/src/__tests__/routes-auth.test.ts +248 -0
  15. package/src/__tests__/routes-profile.test.ts +403 -0
  16. package/src/__tests__/routes-scopes.test.ts +64 -0
  17. package/src/__tests__/routes-sessions.test.ts +235 -0
  18. package/src/__tests__/routes-users.test.ts +477 -0
  19. package/src/__tests__/serveImage.test.ts +277 -0
  20. package/src/__tests__/setup.test.ts +270 -0
  21. package/src/__tests__/verifyToken.test.ts +219 -0
  22. package/src/client/AuthClient.ts +312 -0
  23. package/src/client/http-client.ts +84 -0
  24. package/src/client/index.ts +19 -0
  25. package/src/config.ts +82 -0
  26. package/src/constants.ts +10 -0
  27. package/src/index.ts +16 -0
  28. package/src/lib/define-roles.ts +35 -0
  29. package/src/lib/define-scopes.ts +48 -0
  30. package/src/middleware/canAccess.ts +126 -0
  31. package/src/middleware/index.ts +13 -0
  32. package/src/middleware/requireAuth.ts +35 -0
  33. package/src/middleware/requireScope.ts +46 -0
  34. package/src/middleware/verifyToken.ts +52 -0
  35. package/src/plugin.ts +86 -0
  36. package/src/react/AuthProvider.tsx +105 -0
  37. package/src/react/hooks.ts +128 -0
  38. package/src/react/index.ts +51 -0
  39. package/src/react/withScopeGuard.tsx +73 -0
  40. package/src/roles.ts +40 -0
  41. package/src/schemas.ts +112 -0
  42. package/src/scopes.ts +60 -0
  43. package/src/server/index.ts +44 -0
  44. package/src/server/requireSession.ts +44 -0
  45. package/src/server/routes/auth.ts +102 -0
  46. package/src/server/routes/cookie.ts +7 -0
  47. package/src/server/routes/index.ts +32 -0
  48. package/src/server/routes/profile.ts +162 -0
  49. package/src/server/routes/scopes.ts +22 -0
  50. package/src/server/routes/sessions.ts +68 -0
  51. package/src/server/routes/setup.ts +50 -0
  52. package/src/server/routes/users.ts +175 -0
  53. package/src/server/serveImage.ts +91 -0
  54. package/src/services/AuthService.ts +80 -0
  55. package/src/services/ScopeService.ts +94 -0
  56. package/src/services/SessionService.ts +245 -0
  57. package/src/services/UserService.ts +245 -0
  58. package/src/setup.ts +99 -0
  59. package/src/tanstack/index.ts +15 -0
  60. package/src/tanstack/routeBuilder.ts +311 -0
  61. package/src/types.ts +118 -0
  62. package/tsconfig.json +8 -0
@@ -0,0 +1,403 @@
1
+ /**
2
+ * @brika/auth - Profile Route Tests (update, avatar, password)
3
+ */
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { stub, useTestBed } from '@brika/di/testing';
7
+ import type { Middleware } from '@brika/router';
8
+ import { TestApp } from '@brika/router/testing';
9
+ import { profileRoutes } from '../server/routes/profile';
10
+ import { UserService } from '../services/UserService';
11
+ import { Role, Scope, type Session, type User } from '../types';
12
+
13
+ function withSession(session: Session): Middleware {
14
+ return async (c, next) => {
15
+ c.set('session', session);
16
+ await next();
17
+ };
18
+ }
19
+
20
+ const userSession: Session = {
21
+ id: 'sess-user',
22
+ userId: 'user-1',
23
+ userEmail: 'user@test.com',
24
+ userName: 'User',
25
+ userRole: Role.USER,
26
+ scopes: [Scope.WORKFLOW_READ, Scope.BOARD_READ],
27
+ };
28
+
29
+ const now = new Date();
30
+
31
+ const mockUser: User = {
32
+ id: 'user-1',
33
+ email: 'user@test.com',
34
+ name: 'User',
35
+ role: Role.USER,
36
+ avatarHash: null,
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ isActive: true,
40
+ scopes: [],
41
+ };
42
+
43
+ // ─── PUT /profile ───────────────────────────────────────────────────────────
44
+
45
+ describe('PUT /profile — authenticated', () => {
46
+ let app: ReturnType<typeof TestApp.create>;
47
+
48
+ useTestBed(() => {
49
+ stub(UserService, {
50
+ updateUser: (
51
+ _id: string,
52
+ updates: {
53
+ name?: string;
54
+ }
55
+ ) => ({
56
+ ...mockUser,
57
+ name: updates.name ?? mockUser.name,
58
+ }),
59
+ });
60
+ app = TestApp.create(profileRoutes, [withSession(userSession)]);
61
+ });
62
+
63
+ test('updates name and returns user', async () => {
64
+ const res = await app.put('/profile', {
65
+ name: 'New Name',
66
+ });
67
+ expect(res.status).toBe(200);
68
+ const body = res.body as {
69
+ user: User;
70
+ };
71
+ expect(body.user.name).toBe('New Name');
72
+ });
73
+ });
74
+
75
+ describe('PUT /profile — unauthenticated', () => {
76
+ let app: ReturnType<typeof TestApp.create>;
77
+
78
+ useTestBed(() => {
79
+ stub(UserService);
80
+ app = TestApp.create(profileRoutes);
81
+ });
82
+
83
+ test('returns 401 without session', async () => {
84
+ const res = await app.put('/profile', {
85
+ name: 'Test',
86
+ });
87
+ expect(res.status).toBe(401);
88
+ });
89
+ });
90
+
91
+ // ─── PUT /profile/avatar — JSON base64 ─────────────────────────────────────
92
+
93
+ describe('PUT /profile/avatar — JSON base64 upload', () => {
94
+ let app: ReturnType<typeof TestApp.create>;
95
+
96
+ useTestBed(() => {
97
+ stub(UserService, {
98
+ setAvatar: () => 'abc12345',
99
+ });
100
+ app = TestApp.create(profileRoutes, [withSession(userSession)]);
101
+ });
102
+
103
+ test('uploads avatar from base64 JSON', async () => {
104
+ // PNG magic bytes + minimal padding
105
+ const pngHeader = Buffer.from([
106
+ 0x89,
107
+ 0x50,
108
+ 0x4e,
109
+ 0x47,
110
+ 0x0d,
111
+ 0x0a,
112
+ 0x1a,
113
+ 0x0a,
114
+ ...new Array(100).fill(0),
115
+ ]);
116
+ const imageData = pngHeader.toString('base64');
117
+ const res = await app.put('/profile/avatar', {
118
+ data: imageData,
119
+ });
120
+ expect(res.status).toBe(200);
121
+ const body = res.body as {
122
+ ok: boolean;
123
+ };
124
+ expect(body.ok).toBe(true);
125
+ });
126
+
127
+ test('returns 400 when data field is missing', async () => {
128
+ const res = await app.put('/profile/avatar', {});
129
+ expect(res.status).toBe(400);
130
+ });
131
+ });
132
+
133
+ // ─── PUT /profile/avatar — binary upload ────────────────────────────────────
134
+
135
+ describe('PUT /profile/avatar — binary upload', () => {
136
+ let app: ReturnType<typeof TestApp.create>;
137
+
138
+ useTestBed(() => {
139
+ stub(UserService, {
140
+ setAvatar: () => 'bin12345',
141
+ });
142
+ app = TestApp.create(profileRoutes, [withSession(userSession)]);
143
+ });
144
+
145
+ test('uploads avatar from binary body', async () => {
146
+ const hono = app.hono;
147
+ // PNG magic bytes + minimal padding
148
+ const imageBuffer = Buffer.from([
149
+ 0x89,
150
+ 0x50,
151
+ 0x4e,
152
+ 0x47,
153
+ 0x0d,
154
+ 0x0a,
155
+ 0x1a,
156
+ 0x0a,
157
+ ...new Array(100).fill(0),
158
+ ]);
159
+ const res = await hono.fetch(
160
+ new Request('http://test/profile/avatar', {
161
+ method: 'PUT',
162
+ headers: {
163
+ 'content-type': 'image/png',
164
+ },
165
+ body: imageBuffer,
166
+ })
167
+ );
168
+ expect(res.status).toBe(200);
169
+ const body = (await res.json()) as {
170
+ ok: boolean;
171
+ };
172
+ expect(body.ok).toBe(true);
173
+ });
174
+
175
+ test('returns 400 for empty binary body', async () => {
176
+ const hono = app.hono;
177
+ const res = await hono.fetch(
178
+ new Request('http://test/profile/avatar', {
179
+ method: 'PUT',
180
+ headers: {
181
+ 'content-type': 'image/png',
182
+ },
183
+ body: new Uint8Array(0),
184
+ })
185
+ );
186
+ expect(res.status).toBe(400);
187
+ });
188
+
189
+ test('returns 400 for oversized image (>5MB)', async () => {
190
+ const hono = app.hono;
191
+ const largeBuffer = Buffer.alloc(6 * 1024 * 1024); // 6MB
192
+ const res = await hono.fetch(
193
+ new Request('http://test/profile/avatar', {
194
+ method: 'PUT',
195
+ headers: {
196
+ 'content-type': 'image/png',
197
+ },
198
+ body: largeBuffer,
199
+ })
200
+ );
201
+ expect(res.status).toBe(400);
202
+ });
203
+ });
204
+
205
+ describe('PUT /profile/avatar — unauthenticated', () => {
206
+ let app: ReturnType<typeof TestApp.create>;
207
+
208
+ useTestBed(() => {
209
+ stub(UserService);
210
+ app = TestApp.create(profileRoutes);
211
+ });
212
+
213
+ test('returns 401 without session', async () => {
214
+ const res = await app.put('/profile/avatar', {
215
+ data: 'abc',
216
+ });
217
+ expect(res.status).toBe(401);
218
+ });
219
+ });
220
+
221
+ // ─── DELETE /profile/avatar ─────────────────────────────────────────────────
222
+
223
+ describe('DELETE /profile/avatar — authenticated', () => {
224
+ let app: ReturnType<typeof TestApp.create>;
225
+
226
+ useTestBed(() => {
227
+ stub(UserService, {
228
+ removeAvatar: () => {},
229
+ });
230
+ app = TestApp.create(profileRoutes, [withSession(userSession)]);
231
+ });
232
+
233
+ test('removes avatar and returns ok', async () => {
234
+ const res = await app.delete('/profile/avatar');
235
+ expect(res.status).toBe(200);
236
+ expect(
237
+ (
238
+ res.body as {
239
+ ok: boolean;
240
+ }
241
+ ).ok
242
+ ).toBe(true);
243
+ });
244
+ });
245
+
246
+ describe('DELETE /profile/avatar — unauthenticated', () => {
247
+ let app: ReturnType<typeof TestApp.create>;
248
+
249
+ useTestBed(() => {
250
+ stub(UserService);
251
+ app = TestApp.create(profileRoutes);
252
+ });
253
+
254
+ test('returns 401 without session', async () => {
255
+ const res = await app.delete('/profile/avatar');
256
+ expect(res.status).toBe(401);
257
+ });
258
+ });
259
+
260
+ // ─── PUT /profile/password ──────────────────────────────────────────────────
261
+
262
+ describe('PUT /profile/password — valid current password', () => {
263
+ let app: ReturnType<typeof TestApp.create>;
264
+
265
+ useTestBed(() => {
266
+ stub(UserService, {
267
+ verifyPassword: async (_userId: string, password: string) => password === 'OldPass123!',
268
+ setPassword: async () => {},
269
+ });
270
+ app = TestApp.create(profileRoutes, [withSession(userSession)]);
271
+ });
272
+
273
+ test('changes password with valid current password', async () => {
274
+ const res = await app.put('/profile/password', {
275
+ currentPassword: 'OldPass123!',
276
+ newPassword: 'NewPass456!',
277
+ });
278
+ expect(res.status).toBe(200);
279
+ expect(
280
+ (
281
+ res.body as {
282
+ ok: boolean;
283
+ }
284
+ ).ok
285
+ ).toBe(true);
286
+ });
287
+
288
+ test('returns 400 when current password is wrong', async () => {
289
+ const res = await app.put('/profile/password', {
290
+ currentPassword: 'WrongPass',
291
+ newPassword: 'NewPass456!',
292
+ });
293
+ expect(res.status).toBe(400);
294
+ });
295
+
296
+ test('returns 400 when fields are missing', async () => {
297
+ const res = await app.put('/profile/password', {});
298
+ expect(res.status).toBe(400);
299
+ });
300
+
301
+ test('returns 400 when only currentPassword is provided', async () => {
302
+ const res = await app.put('/profile/password', {
303
+ currentPassword: 'OldPass123!',
304
+ });
305
+ expect(res.status).toBe(400);
306
+ });
307
+ });
308
+
309
+ describe('PUT /profile/password — setPassword throws', () => {
310
+ let app: ReturnType<typeof TestApp.create>;
311
+
312
+ useTestBed(() => {
313
+ stub(UserService, {
314
+ verifyPassword: async () => true,
315
+ setPassword: async () => {
316
+ throw new Error('Password does not meet requirements');
317
+ },
318
+ });
319
+ app = TestApp.create(profileRoutes, [withSession(userSession)]);
320
+ });
321
+
322
+ test('returns 400 when setPassword throws (invalid new password)', async () => {
323
+ const res = await app.put('/profile/password', {
324
+ currentPassword: 'OldPass123!',
325
+ newPassword: 'weak',
326
+ });
327
+ expect(res.status).toBe(400);
328
+ });
329
+ });
330
+
331
+ describe('PUT /profile/password — unauthenticated', () => {
332
+ let app: ReturnType<typeof TestApp.create>;
333
+
334
+ useTestBed(() => {
335
+ stub(UserService);
336
+ app = TestApp.create(profileRoutes);
337
+ });
338
+
339
+ test('returns 401 without session', async () => {
340
+ const res = await app.put('/profile/password', {
341
+ currentPassword: 'OldPass123!',
342
+ newPassword: 'NewPass456!',
343
+ });
344
+ expect(res.status).toBe(401);
345
+ });
346
+ });
347
+
348
+ // ─── GET /avatar/:userId ────────────────────────────────────────────────────
349
+
350
+ describe('GET /avatar/:userId — no avatar', () => {
351
+ let app: ReturnType<typeof TestApp.create>;
352
+
353
+ useTestBed(() => {
354
+ stub(UserService, {
355
+ getAvatarData: () => null,
356
+ });
357
+ // Avatar endpoint is public (no requireSession)
358
+ app = TestApp.create(profileRoutes);
359
+ });
360
+
361
+ test('returns 204 when user has no avatar', async () => {
362
+ const res = await app.get('/avatar/user-1');
363
+ expect(res.status).toBe(204);
364
+ });
365
+ });
366
+
367
+ describe('GET /avatar/:userId — with avatar', () => {
368
+ let app: ReturnType<typeof TestApp.create>;
369
+ const fakeImage = Buffer.from('PNG-FAKE');
370
+
371
+ useTestBed(() => {
372
+ stub(UserService, {
373
+ getAvatarData: () => ({
374
+ data: fakeImage,
375
+ mimeType: 'image/webp',
376
+ }),
377
+ });
378
+ app = TestApp.create(profileRoutes);
379
+ });
380
+
381
+ test('returns image data when avatar exists', async () => {
382
+ const res = await app.get('/avatar/user-1');
383
+ expect(res.status).toBe(200);
384
+ expect(res.headers.get('content-type')).toBe('image/webp');
385
+ expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
386
+ expect(res.headers.get('etag')).toBeTruthy();
387
+ });
388
+
389
+ test('returns 304 with matching ETag', async () => {
390
+ // First request to get the ETag
391
+ const first = await app.get('/avatar/user-1');
392
+ const etag = first.headers.get('etag') ?? '';
393
+ expect(etag).toBeTruthy();
394
+
395
+ // Second request with If-None-Match
396
+ const second = await app.get('/avatar/user-1', {
397
+ headers: {
398
+ 'if-none-match': etag,
399
+ },
400
+ });
401
+ expect(second.status).toBe(304);
402
+ });
403
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @brika/auth - Scope Route Tests (list scopes)
3
+ */
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { provide, useTestBed } from '@brika/di/testing';
7
+ import { TestApp } from '@brika/router/testing';
8
+ import { SCOPES_REGISTRY } from '../constants';
9
+ import { scopeRoutes } from '../server/routes/scopes';
10
+ import { ScopeService } from '../services/ScopeService';
11
+ import { Scope } from '../types';
12
+
13
+ describe('GET /scopes', () => {
14
+ let app: ReturnType<typeof TestApp.create>;
15
+
16
+ useTestBed(() => {
17
+ // Use provide() instead of stub() to avoid deep-stub wrapping the SCOPES_REGISTRY object
18
+ provide(ScopeService, {
19
+ getRegistry: () => SCOPES_REGISTRY,
20
+ });
21
+ app = TestApp.create(scopeRoutes);
22
+ });
23
+
24
+ test('returns scopes registry and categories', async () => {
25
+ const res = await app.get('/scopes');
26
+ expect(res.status).toBe(200);
27
+
28
+ const body = res.body as {
29
+ scopes: Record<
30
+ string,
31
+ {
32
+ description: string;
33
+ category: string;
34
+ }
35
+ >;
36
+ categories: string[];
37
+ };
38
+
39
+ expect(body.categories).toEqual(['admin', 'workflow', 'board', 'plugin', 'settings']);
40
+ const adminScope = body.scopes[Scope.ADMIN_ALL];
41
+ expect(adminScope).toBeDefined();
42
+ if (!adminScope) {
43
+ throw new Error('Expected admin scope to be defined');
44
+ }
45
+ expect(adminScope.description).toBe('Full administrative access');
46
+ expect(adminScope.category).toBe('admin');
47
+ });
48
+
49
+ test('includes all defined scopes', async () => {
50
+ const res = await app.get('/scopes');
51
+ const body = res.body as {
52
+ scopes: Record<string, unknown>;
53
+ };
54
+
55
+ for (const scope of Object.values(Scope)) {
56
+ expect(body.scopes[scope]).toBeDefined();
57
+ }
58
+ });
59
+
60
+ test('is publicly accessible (no session required)', async () => {
61
+ const res = await app.get('/scopes');
62
+ expect(res.status).toBe(200);
63
+ });
64
+ });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @brika/auth - Session Route Tests (list, revoke, revoke all)
3
+ */
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { stub, useTestBed } from '@brika/di/testing';
7
+ import type { Middleware } from '@brika/router';
8
+ import { TestApp } from '@brika/router/testing';
9
+ import { sessionRoutes } from '../server/routes/sessions';
10
+ import { SessionService } from '../services/SessionService';
11
+ import { Role, Scope, type Session, type SessionRecord } from '../types';
12
+
13
+ function withSession(session: Session): Middleware {
14
+ return async (c, next) => {
15
+ c.set('session', session);
16
+ await next();
17
+ };
18
+ }
19
+
20
+ const adminSession: Session = {
21
+ id: 'sess-admin',
22
+ userId: 'user-admin',
23
+ userEmail: 'admin@test.com',
24
+ userName: 'Admin',
25
+ userRole: Role.ADMIN,
26
+ scopes: [Scope.ADMIN_ALL],
27
+ };
28
+
29
+ const userSession: Session = {
30
+ id: 'sess-user',
31
+ userId: 'user-regular',
32
+ userEmail: 'user@test.com',
33
+ userName: 'User',
34
+ userRole: Role.USER,
35
+ scopes: [Scope.WORKFLOW_READ, Scope.BOARD_READ],
36
+ };
37
+
38
+ const now = Date.now();
39
+
40
+ const mockUserSessions: SessionRecord[] = [
41
+ {
42
+ id: 'sess-user',
43
+ userId: 'user-regular',
44
+ tokenHash: 'hash1',
45
+ ip: '127.0.0.1',
46
+ userAgent: 'TestBrowser',
47
+ createdAt: now - 10000,
48
+ lastSeenAt: now,
49
+ expiresAt: now + 604800000,
50
+ revokedAt: null,
51
+ },
52
+ {
53
+ id: 'sess-user-2',
54
+ userId: 'user-regular',
55
+ tokenHash: 'hash2',
56
+ ip: '10.0.0.1',
57
+ userAgent: 'OtherBrowser',
58
+ createdAt: now - 20000,
59
+ lastSeenAt: now - 5000,
60
+ expiresAt: now + 604800000,
61
+ revokedAt: null,
62
+ },
63
+ ];
64
+
65
+ // ─── GET /sessions ──────────────────────────────────────────────────────────
66
+
67
+ describe('GET /sessions — authenticated', () => {
68
+ let app: ReturnType<typeof TestApp.create>;
69
+
70
+ useTestBed(() => {
71
+ stub(SessionService, {
72
+ listUserSessions: () => mockUserSessions,
73
+ });
74
+ app = TestApp.create(sessionRoutes, [withSession(userSession)]);
75
+ });
76
+
77
+ test('returns sessions for current user with current flag', async () => {
78
+ const res = await app.get('/sessions');
79
+ expect(res.status).toBe(200);
80
+
81
+ const body = res.body as {
82
+ sessions: Array<{
83
+ id: string;
84
+ ip: string | null;
85
+ userAgent: string | null;
86
+ createdAt: number;
87
+ lastSeenAt: number;
88
+ current: boolean;
89
+ }>;
90
+ };
91
+ expect(body.sessions).toHaveLength(2);
92
+
93
+ const current = body.sessions.find((s) => s.id === 'sess-user');
94
+ expect(current?.current).toBe(true);
95
+
96
+ const other = body.sessions.find((s) => s.id === 'sess-user-2');
97
+ expect(other?.current).toBe(false);
98
+ });
99
+
100
+ test('strips sensitive fields (tokenHash, expiresAt, revokedAt)', async () => {
101
+ const res = await app.get('/sessions');
102
+ const body = res.body as {
103
+ sessions: Array<Record<string, unknown>>;
104
+ };
105
+ for (const session of body.sessions) {
106
+ expect(session.tokenHash).toBeUndefined();
107
+ expect(session.expiresAt).toBeUndefined();
108
+ expect(session.revokedAt).toBeUndefined();
109
+ }
110
+ });
111
+ });
112
+
113
+ describe('GET /sessions — unauthenticated', () => {
114
+ let app: ReturnType<typeof TestApp.create>;
115
+
116
+ useTestBed(() => {
117
+ stub(SessionService);
118
+ app = TestApp.create(sessionRoutes);
119
+ });
120
+
121
+ test('returns 401 without session', async () => {
122
+ const res = await app.get('/sessions');
123
+ expect(res.status).toBe(401);
124
+ });
125
+ });
126
+
127
+ // ─── DELETE /sessions/:id ───────────────────────────────────────────────────
128
+
129
+ describe('DELETE /sessions/:id — as session owner', () => {
130
+ let app: ReturnType<typeof TestApp.create>;
131
+ let revokedId: string | undefined;
132
+
133
+ useTestBed(() => {
134
+ revokedId = undefined;
135
+ stub(SessionService, {
136
+ listUserSessions: () => mockUserSessions,
137
+ revokeSession: (id: string) => {
138
+ revokedId = id;
139
+ },
140
+ });
141
+ app = TestApp.create(sessionRoutes, [withSession(userSession)]);
142
+ });
143
+
144
+ test('revokes own session', async () => {
145
+ const res = await app.delete('/sessions/sess-user-2');
146
+ expect(res.status).toBe(200);
147
+ expect(revokedId).toBe('sess-user-2');
148
+ });
149
+
150
+ test('returns 403 when revoking another users session', async () => {
151
+ const res = await app.delete('/sessions/sess-foreign');
152
+ expect(res.status).toBe(403);
153
+ });
154
+ });
155
+
156
+ describe('DELETE /sessions/:id — as admin', () => {
157
+ let app: ReturnType<typeof TestApp.create>;
158
+ let revokedId: string | undefined;
159
+
160
+ useTestBed(() => {
161
+ revokedId = undefined;
162
+ stub(SessionService, {
163
+ // Admin's own sessions do NOT include 'sess-user-2', but admin can still revoke it
164
+ listUserSessions: () => [],
165
+ revokeSession: (id: string) => {
166
+ revokedId = id;
167
+ },
168
+ });
169
+ app = TestApp.create(sessionRoutes, [withSession(adminSession)]);
170
+ });
171
+
172
+ test('can revoke any session via admin scope', async () => {
173
+ const res = await app.delete('/sessions/sess-user-2');
174
+ expect(res.status).toBe(200);
175
+ expect(revokedId).toBe('sess-user-2');
176
+ });
177
+ });
178
+
179
+ describe('DELETE /sessions/:id — unauthenticated', () => {
180
+ let app: ReturnType<typeof TestApp.create>;
181
+
182
+ useTestBed(() => {
183
+ stub(SessionService);
184
+ app = TestApp.create(sessionRoutes);
185
+ });
186
+
187
+ test('returns 401 without session', async () => {
188
+ const res = await app.delete('/sessions/sess-user');
189
+ expect(res.status).toBe(401);
190
+ });
191
+ });
192
+
193
+ // ─── DELETE /sessions ───────────────────────────────────────────────────────
194
+
195
+ describe('DELETE /sessions — authenticated', () => {
196
+ let app: ReturnType<typeof TestApp.create>;
197
+ let revokedUserId: string | undefined;
198
+
199
+ useTestBed(() => {
200
+ revokedUserId = undefined;
201
+ stub(SessionService, {
202
+ revokeAllUserSessions: (userId: string) => {
203
+ revokedUserId = userId;
204
+ },
205
+ });
206
+ app = TestApp.create(sessionRoutes, [withSession(userSession)]);
207
+ });
208
+
209
+ test('revokes all sessions for current user', async () => {
210
+ const res = await app.delete('/sessions');
211
+ expect(res.status).toBe(200);
212
+ expect(
213
+ (
214
+ res.body as {
215
+ ok: boolean;
216
+ }
217
+ ).ok
218
+ ).toBe(true);
219
+ expect(revokedUserId).toBe('user-regular');
220
+ });
221
+ });
222
+
223
+ describe('DELETE /sessions — unauthenticated', () => {
224
+ let app: ReturnType<typeof TestApp.create>;
225
+
226
+ useTestBed(() => {
227
+ stub(SessionService);
228
+ app = TestApp.create(sessionRoutes);
229
+ });
230
+
231
+ test('returns 401 without session', async () => {
232
+ const res = await app.delete('/sessions');
233
+ expect(res.status).toBe(401);
234
+ });
235
+ });