@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,140 @@
1
+ /**
2
+ * @brika/auth - AuthService Tests
3
+ */
4
+
5
+ import { beforeEach, describe, expect, it, vi } from 'bun:test';
6
+ import { provide, useTestBed } from '@brika/di/testing';
7
+ import { AuthService } from '../services/AuthService';
8
+ import { ScopeService } from '../services/ScopeService';
9
+ import { SessionService } from '../services/SessionService';
10
+ import { UserService } from '../services/UserService';
11
+ import { Role, Scope } from '../types';
12
+
13
+ describe('AuthService', () => {
14
+ let authService: AuthService;
15
+ let mockUserService: {
16
+ createUser: ReturnType<typeof vi.fn>;
17
+ getUser: ReturnType<typeof vi.fn>;
18
+ getUserByEmail: ReturnType<typeof vi.fn>;
19
+ listUsers: ReturnType<typeof vi.fn>;
20
+ deleteUser: ReturnType<typeof vi.fn>;
21
+ setPassword: ReturnType<typeof vi.fn>;
22
+ verifyPassword: ReturnType<typeof vi.fn>;
23
+ hasAdmin: ReturnType<typeof vi.fn>;
24
+ };
25
+ let mockSessionService: {
26
+ createSession: ReturnType<typeof vi.fn>;
27
+ validateSession: ReturnType<typeof vi.fn>;
28
+ revokeSession: ReturnType<typeof vi.fn>;
29
+ revokeAllUserSessions: ReturnType<typeof vi.fn>;
30
+ listUserSessions: ReturnType<typeof vi.fn>;
31
+ cleanExpiredSessions: ReturnType<typeof vi.fn>;
32
+ getSessionTTL: ReturnType<typeof vi.fn>;
33
+ };
34
+
35
+ useTestBed(() => {
36
+ mockUserService = {
37
+ createUser: vi.fn(),
38
+ getUser: vi.fn(),
39
+ getUserByEmail: vi.fn(),
40
+ listUsers: vi.fn().mockReturnValue([]),
41
+ deleteUser: vi.fn(),
42
+ setPassword: vi.fn(),
43
+ verifyPassword: vi.fn(),
44
+ hasAdmin: vi.fn(),
45
+ };
46
+
47
+ mockSessionService = {
48
+ createSession: vi.fn().mockReturnValue('test-session-token'),
49
+ validateSession: vi.fn(),
50
+ revokeSession: vi.fn(),
51
+ revokeAllUserSessions: vi.fn(),
52
+ listUserSessions: vi.fn().mockReturnValue([]),
53
+ cleanExpiredSessions: vi.fn().mockReturnValue(0),
54
+ getSessionTTL: vi.fn().mockReturnValue(604800),
55
+ };
56
+
57
+ provide(SessionService, mockSessionService);
58
+ provide(ScopeService, new ScopeService());
59
+ provide(UserService, mockUserService);
60
+
61
+ authService = new AuthService();
62
+ });
63
+
64
+ describe('login', () => {
65
+ it('should login with valid credentials and return session token', async () => {
66
+ mockUserService.getUserByEmail.mockReturnValueOnce({
67
+ id: '1',
68
+ email: 'test@example.com',
69
+ name: 'Test User',
70
+ role: Role.USER,
71
+ isActive: true,
72
+ });
73
+
74
+ mockUserService.verifyPassword.mockResolvedValueOnce(true);
75
+
76
+ const result = await authService.login(
77
+ 'test@example.com',
78
+ 'password123',
79
+ '127.0.0.1',
80
+ 'TestAgent'
81
+ );
82
+
83
+ expect(result).toBeDefined();
84
+ expect(result.token).toBe('test-session-token');
85
+ expect(result.user.email).toBe('test@example.com');
86
+ expect(result.expiresIn).toBe(604800);
87
+ expect(mockSessionService.createSession).toHaveBeenCalledWith('1', '127.0.0.1', 'TestAgent');
88
+ });
89
+
90
+ it('should reject invalid email', async () => {
91
+ mockUserService.getUserByEmail.mockReturnValueOnce(null);
92
+
93
+ await expect(authService.login('nonexistent@example.com', 'password')).rejects.toThrow(
94
+ 'Invalid credentials'
95
+ );
96
+ });
97
+
98
+ it('should reject invalid password', async () => {
99
+ mockUserService.getUserByEmail.mockReturnValueOnce({
100
+ id: '1',
101
+ email: 'test@example.com',
102
+ });
103
+
104
+ mockUserService.verifyPassword.mockResolvedValueOnce(false);
105
+
106
+ await expect(authService.login('test@example.com', 'wrongpassword')).rejects.toThrow(
107
+ 'Invalid credentials'
108
+ );
109
+ });
110
+ });
111
+
112
+ describe('logout', () => {
113
+ it('should revoke session by ID', () => {
114
+ authService.logout('session-123');
115
+ expect(mockSessionService.revokeSession).toHaveBeenCalledWith('session-123');
116
+ });
117
+ });
118
+
119
+ describe('getCurrentUser', () => {
120
+ it('should get user by ID', () => {
121
+ mockUserService.getUser.mockReturnValueOnce({
122
+ id: '1',
123
+ email: 'test@example.com',
124
+ name: 'Test User',
125
+ role: Role.USER,
126
+ });
127
+
128
+ const user = authService.getCurrentUser('1');
129
+ expect(user?.email).toBe('test@example.com');
130
+ expect(mockUserService.getUser).toHaveBeenCalledWith('1');
131
+ });
132
+
133
+ it('should return null for unknown user', () => {
134
+ mockUserService.getUser.mockReturnValueOnce(null);
135
+
136
+ const user = authService.getCurrentUser('unknown');
137
+ expect(user).toBeNull();
138
+ });
139
+ });
140
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @brika/auth - ScopeService Tests
3
+ */
4
+
5
+ import { beforeEach, describe, expect, it } from 'bun:test';
6
+ import { ScopeService } from '../services/ScopeService';
7
+ import { Role, Scope } from '../types';
8
+
9
+ describe('ScopeService', () => {
10
+ let service: ScopeService;
11
+
12
+ beforeEach(() => {
13
+ service = new ScopeService();
14
+ });
15
+
16
+ describe('isValidScope', () => {
17
+ it('should validate known scopes', () => {
18
+ expect(service.isValidScope(Scope.ADMIN_ALL)).toBe(true);
19
+ expect(service.isValidScope(Scope.WORKFLOW_READ)).toBe(true);
20
+ expect(service.isValidScope(Scope.SETTINGS_WRITE)).toBe(true);
21
+ });
22
+
23
+ it('should reject unknown scopes', () => {
24
+ expect(service.isValidScope('unknown:scope')).toBe(false);
25
+ expect(service.isValidScope('admin')).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe('validateScopes', () => {
30
+ it('should filter valid scopes', () => {
31
+ const mixed = [Scope.WORKFLOW_READ, 'invalid', Scope.WORKFLOW_WRITE];
32
+ const valid = service.validateScopes(mixed);
33
+
34
+ expect(valid).toHaveLength(2);
35
+ expect(valid).toContain(Scope.WORKFLOW_READ);
36
+ expect(valid).toContain(Scope.WORKFLOW_WRITE);
37
+ });
38
+
39
+ it('should return empty array for invalid scope strings', () => {
40
+ const result = service.validateScopes(['not-a-scope', 'also-invalid']);
41
+ expect(result).toEqual([]);
42
+ });
43
+ });
44
+
45
+ describe('getScopesForRole', () => {
46
+ it('should return admin scopes for admin role', () => {
47
+ const scopes = service.getScopesForRole(Role.ADMIN);
48
+ expect(scopes).toContain(Scope.ADMIN_ALL);
49
+ });
50
+
51
+ it('should return user scopes for user role', () => {
52
+ const scopes = service.getScopesForRole(Role.USER);
53
+ expect(scopes).toContain(Scope.WORKFLOW_READ);
54
+ expect(scopes).toContain(Scope.WORKFLOW_WRITE);
55
+ expect(scopes).not.toContain(Scope.ADMIN_ALL);
56
+ });
57
+
58
+ it('should return guest scopes for guest role', () => {
59
+ const scopes = service.getScopesForRole(Role.GUEST);
60
+ expect(scopes).toContain(Scope.WORKFLOW_READ);
61
+ expect(scopes).not.toContain(Scope.WORKFLOW_WRITE);
62
+ });
63
+
64
+ it('should return empty array for service role', () => {
65
+ const scopes = service.getScopesForRole(Role.SERVICE);
66
+ expect(scopes).toEqual([]);
67
+ });
68
+ });
69
+
70
+ describe('hasScope', () => {
71
+ it('should check if scope is present', () => {
72
+ const scopes = [Scope.WORKFLOW_READ, Scope.WORKFLOW_WRITE];
73
+ expect(service.hasScope(scopes, Scope.WORKFLOW_READ)).toBe(true);
74
+ expect(service.hasScope(scopes, Scope.WORKFLOW_EXECUTE)).toBe(false);
75
+ });
76
+
77
+ it('should grant all scopes to admin', () => {
78
+ const adminScopes = [Scope.ADMIN_ALL];
79
+ expect(service.hasScope(adminScopes, Scope.WORKFLOW_READ)).toBe(true);
80
+ expect(service.hasScope(adminScopes, Scope.SETTINGS_WRITE)).toBe(true);
81
+ expect(service.hasScope(adminScopes, Scope.PLUGIN_MANAGE)).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe('hasScopeAny', () => {
86
+ it('should check if any required scope is present', () => {
87
+ const scopes = [Scope.WORKFLOW_READ];
88
+ const required = [Scope.WORKFLOW_WRITE, Scope.WORKFLOW_READ];
89
+
90
+ expect(service.hasScopeAny(scopes, required)).toBe(true);
91
+ });
92
+
93
+ it('should return false if none present', () => {
94
+ const scopes = [Scope.BOARD_READ];
95
+ const required = [Scope.WORKFLOW_READ, Scope.WORKFLOW_WRITE];
96
+
97
+ expect(service.hasScopeAny(scopes, required)).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('hasScopeAll', () => {
102
+ it('should check if all required scopes are present', () => {
103
+ const scopes = [Scope.WORKFLOW_READ, Scope.WORKFLOW_WRITE];
104
+ const required = [Scope.WORKFLOW_READ, Scope.WORKFLOW_WRITE];
105
+
106
+ expect(service.hasScopeAll(scopes, required)).toBe(true);
107
+ });
108
+
109
+ it('should return false if any missing', () => {
110
+ const scopes = [Scope.WORKFLOW_READ];
111
+ const required = [Scope.WORKFLOW_READ, Scope.WORKFLOW_WRITE];
112
+
113
+ expect(service.hasScopeAll(scopes, required)).toBe(false);
114
+ });
115
+ });
116
+
117
+ describe('getAllScopes', () => {
118
+ it('should return all available scopes', () => {
119
+ const all = service.getAllScopes();
120
+ expect(all.length).toBeGreaterThan(0);
121
+ expect(all).toContain(Scope.ADMIN_ALL);
122
+ expect(all).toContain(Scope.WORKFLOW_READ);
123
+ });
124
+ });
125
+
126
+ describe('getScopesByCategory', () => {
127
+ it('should filter scopes by category', () => {
128
+ const workflow = service.getScopesByCategory('workflow');
129
+ expect(workflow).toContain(Scope.WORKFLOW_READ);
130
+ expect(workflow).toContain(Scope.WORKFLOW_WRITE);
131
+ expect(workflow).not.toContain(Scope.BOARD_READ);
132
+ });
133
+
134
+ it('should return admin category', () => {
135
+ const admin = service.getScopesByCategory('admin');
136
+ expect(admin).toContain(Scope.ADMIN_ALL);
137
+ });
138
+ });
139
+
140
+ describe('getScopeDescription', () => {
141
+ it('should return scope description', () => {
142
+ const desc = service.getScopeDescription(Scope.WORKFLOW_READ);
143
+ expect(typeof desc).toBe('string');
144
+ expect(desc.length).toBeGreaterThan(0);
145
+ });
146
+ });
147
+
148
+ describe('getRegistry', () => {
149
+ it('should return scope registry', () => {
150
+ const registry = service.getRegistry();
151
+ expect(registry).toBeDefined();
152
+ expect(registry[Scope.ADMIN_ALL]).toBeDefined();
153
+ expect(registry[Scope.ADMIN_ALL].category).toBe('admin');
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,311 @@
1
+ /**
2
+ * @brika/auth - SessionService Unit Tests
3
+ * Direct service tests against an in-memory SQLite database.
4
+ */
5
+
6
+ import type { Database } from 'bun:sqlite';
7
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
8
+ import { SessionService } from '../services/SessionService';
9
+ import { UserService } from '../services/UserService';
10
+ import { openAuthDatabase } from '../setup';
11
+ import { Role } from '../types';
12
+
13
+ let db: Database;
14
+ let sessionService: SessionService;
15
+ let userService: UserService;
16
+ let userId: string;
17
+
18
+ beforeEach(() => {
19
+ db = openAuthDatabase(':memory:');
20
+ sessionService = new SessionService(db, 3600);
21
+ userService = new UserService(db);
22
+ const user = userService.createUser('test@test.com', 'Test', Role.USER);
23
+ userId = user.id;
24
+ });
25
+
26
+ afterEach(() => {
27
+ db.close();
28
+ });
29
+
30
+ // ─── revokeSession ───────────────────────────────────────────────────────────
31
+
32
+ describe('revokeSession', () => {
33
+ it('sets revoked_at so the session no longer validates', async () => {
34
+ const token = sessionService.createSession(userId);
35
+ const session = sessionService.validateSession(token);
36
+ expect(session).not.toBeNull();
37
+ if (session === null) {
38
+ return;
39
+ }
40
+
41
+ sessionService.revokeSession(session.id);
42
+
43
+ const result = sessionService.validateSession(token);
44
+ expect(result).toBeNull();
45
+ });
46
+
47
+ it('does not throw when revoking a non-existent session id', () => {
48
+ expect(() => {
49
+ sessionService.revokeSession('does-not-exist');
50
+ }).not.toThrow();
51
+ });
52
+
53
+ it('does not affect other sessions belonging to the same user', () => {
54
+ const token1 = sessionService.createSession(userId);
55
+ const token2 = sessionService.createSession(userId);
56
+
57
+ const session1 = sessionService.validateSession(token1);
58
+ expect(session1).not.toBeNull();
59
+ if (session1 === null) {
60
+ return;
61
+ }
62
+
63
+ sessionService.revokeSession(session1.id);
64
+
65
+ expect(sessionService.validateSession(token1)).toBeNull();
66
+ expect(sessionService.validateSession(token2)).not.toBeNull();
67
+ });
68
+
69
+ it('is idempotent — revoking an already-revoked session does not throw', () => {
70
+ const token = sessionService.createSession(userId);
71
+ const session = sessionService.validateSession(token);
72
+ expect(session).not.toBeNull();
73
+ if (session === null) {
74
+ return;
75
+ }
76
+
77
+ expect(() => {
78
+ sessionService.revokeSession(session.id);
79
+ sessionService.revokeSession(session.id);
80
+ }).not.toThrow();
81
+ });
82
+ });
83
+
84
+ // ─── revokeAllUserSessions ───────────────────────────────────────────────────
85
+
86
+ describe('revokeAllUserSessions', () => {
87
+ it('revokes all active sessions for the user', () => {
88
+ const token1 = sessionService.createSession(userId);
89
+ const token2 = sessionService.createSession(userId);
90
+ const token3 = sessionService.createSession(userId);
91
+
92
+ sessionService.revokeAllUserSessions(userId);
93
+
94
+ expect(sessionService.validateSession(token1)).toBeNull();
95
+ expect(sessionService.validateSession(token2)).toBeNull();
96
+ expect(sessionService.validateSession(token3)).toBeNull();
97
+ });
98
+
99
+ it('does not affect sessions belonging to other users', () => {
100
+ const otherUser = userService.createUser('other@test.com', 'Other', Role.USER);
101
+ const otherToken = sessionService.createSession(otherUser.id);
102
+
103
+ sessionService.createSession(userId);
104
+ sessionService.revokeAllUserSessions(userId);
105
+
106
+ expect(sessionService.validateSession(otherToken)).not.toBeNull();
107
+ });
108
+
109
+ it('does not throw when the user has no sessions', () => {
110
+ expect(() => {
111
+ sessionService.revokeAllUserSessions(userId);
112
+ }).not.toThrow();
113
+ });
114
+
115
+ it('does not throw for a non-existent user id', () => {
116
+ expect(() => {
117
+ sessionService.revokeAllUserSessions('no-such-user');
118
+ }).not.toThrow();
119
+ });
120
+ });
121
+
122
+ // ─── listUserSessions ────────────────────────────────────────────────────────
123
+
124
+ describe('listUserSessions', () => {
125
+ it('returns an empty array when the user has no sessions', () => {
126
+ const sessions = sessionService.listUserSessions(userId);
127
+ expect(sessions).toEqual([]);
128
+ });
129
+
130
+ it('returns active sessions for the user', () => {
131
+ sessionService.createSession(userId);
132
+ sessionService.createSession(userId);
133
+
134
+ const sessions = sessionService.listUserSessions(userId);
135
+ expect(sessions).toHaveLength(2);
136
+ });
137
+
138
+ it('excludes revoked sessions', () => {
139
+ const token1 = sessionService.createSession(userId);
140
+ sessionService.createSession(userId);
141
+
142
+ const session1 = sessionService.validateSession(token1);
143
+ expect(session1).not.toBeNull();
144
+ if (session1 === null) {
145
+ return;
146
+ }
147
+
148
+ sessionService.revokeSession(session1.id);
149
+
150
+ const sessions = sessionService.listUserSessions(userId);
151
+ expect(sessions).toHaveLength(1);
152
+
153
+ const remaining = sessions[0];
154
+ expect(remaining).toBeDefined();
155
+ if (remaining === undefined) {
156
+ return;
157
+ }
158
+ expect(remaining.id).not.toBe(session1.id);
159
+ });
160
+
161
+ it('orders results by last_seen_at DESC', () => {
162
+ // Create sessions with a small gap so last_seen_at timestamps differ.
163
+ // validateSession updates last_seen_at, so validate in reverse desired order.
164
+ const token1 = sessionService.createSession(userId);
165
+ const token2 = sessionService.createSession(userId);
166
+ const token3 = sessionService.createSession(userId);
167
+
168
+ // Touch them in ascending order — token3 will be most-recently seen.
169
+ sessionService.validateSession(token1);
170
+ sessionService.validateSession(token2);
171
+ sessionService.validateSession(token3);
172
+
173
+ const sessions = sessionService.listUserSessions(userId);
174
+ expect(sessions).toHaveLength(3);
175
+
176
+ // Verify DESC ordering by comparing consecutive lastSeenAt values.
177
+ for (let i = 0; i < sessions.length - 1; i++) {
178
+ const current = sessions[i];
179
+ const next = sessions[i + 1];
180
+ if (current === undefined || next === undefined) {
181
+ continue;
182
+ }
183
+ expect(current.lastSeenAt).toBeGreaterThanOrEqual(next.lastSeenAt);
184
+ }
185
+ });
186
+
187
+ it('does not include sessions belonging to other users', () => {
188
+ const otherUser = userService.createUser('other@test.com', 'Other', Role.USER);
189
+ sessionService.createSession(otherUser.id);
190
+ sessionService.createSession(userId);
191
+
192
+ const sessions = sessionService.listUserSessions(userId);
193
+ expect(sessions).toHaveLength(1);
194
+
195
+ const onlySession = sessions[0];
196
+ expect(onlySession).toBeDefined();
197
+ if (onlySession === undefined) {
198
+ return;
199
+ }
200
+ expect(onlySession.userId).toBe(userId);
201
+ });
202
+
203
+ it('returns SessionRecord objects with the expected shape', () => {
204
+ const token = sessionService.createSession(userId, '127.0.0.1', 'TestAgent/1.0');
205
+ // Validate so we get the session id back.
206
+ const session = sessionService.validateSession(token, '127.0.0.1');
207
+ expect(session).not.toBeNull();
208
+ if (session === null) {
209
+ return;
210
+ }
211
+
212
+ const records = sessionService.listUserSessions(userId);
213
+ expect(records).toHaveLength(1);
214
+
215
+ const record = records[0];
216
+ expect(record).toBeDefined();
217
+ if (record === undefined) {
218
+ return;
219
+ }
220
+ expect(record.id).toBe(session.id);
221
+ expect(record.userId).toBe(userId);
222
+ expect(typeof record.tokenHash).toBe('string');
223
+ expect(record.ip).toBe('127.0.0.1');
224
+ expect(record.userAgent).toBe('TestAgent/1.0');
225
+ expect(typeof record.createdAt).toBe('number');
226
+ expect(typeof record.lastSeenAt).toBe('number');
227
+ expect(typeof record.expiresAt).toBe('number');
228
+ expect(record.revokedAt).toBeNull();
229
+ });
230
+ });
231
+
232
+ // ─── cleanExpiredSessions ────────────────────────────────────────────────────
233
+
234
+ describe('cleanExpiredSessions', () => {
235
+ it('returns 0 when there are no sessions to clean', () => {
236
+ const count = sessionService.cleanExpiredSessions();
237
+ expect(count).toBe(0);
238
+ });
239
+
240
+ it('returns 0 for active, non-expired sessions', () => {
241
+ // sessionService has TTL of 3600s — sessions are far from expired.
242
+ sessionService.createSession(userId);
243
+ sessionService.createSession(userId);
244
+
245
+ const count = sessionService.cleanExpiredSessions();
246
+ expect(count).toBe(0);
247
+ });
248
+
249
+ it('deletes expired sessions whose created_at is older than 30 days', () => {
250
+ // Insert a session directly with timestamps that fall outside the 30-day window.
251
+ const thirtyOneDaysAgo = Date.now() - 31 * 24 * 60 * 60 * 1000;
252
+
253
+ db.query(
254
+ `INSERT INTO sessions (id, user_id, token_hash, ip, user_agent, created_at, last_seen_at, expires_at)
255
+ VALUES ('old-sess', ?, 'oldhash', NULL, NULL, ?, ?, ?)`
256
+ ).run(userId, thirtyOneDaysAgo, thirtyOneDaysAgo, thirtyOneDaysAgo - 1);
257
+ // expires_at < now and created_at < cutoff => eligible for deletion.
258
+
259
+ const count = sessionService.cleanExpiredSessions();
260
+ expect(count).toBe(1);
261
+ });
262
+
263
+ it('deletes revoked sessions whose created_at is older than 30 days', () => {
264
+ const thirtyOneDaysAgo = Date.now() - 31 * 24 * 60 * 60 * 1000;
265
+ const futureExpiry = Date.now() + 3600 * 1000;
266
+
267
+ db.query(
268
+ `INSERT INTO sessions (id, user_id, token_hash, ip, user_agent, created_at, last_seen_at, expires_at, revoked_at)
269
+ VALUES ('revoked-old', ?, 'revokedhash', NULL, NULL, ?, ?, ?, ?)`
270
+ ).run(userId, thirtyOneDaysAgo, thirtyOneDaysAgo, futureExpiry, thirtyOneDaysAgo + 1);
271
+
272
+ const count = sessionService.cleanExpiredSessions();
273
+ expect(count).toBe(1);
274
+ });
275
+
276
+ it('does not delete recently-revoked sessions (created within 30 days)', () => {
277
+ // Create and immediately revoke — created_at is now, so it should not be cleaned.
278
+ const token = sessionService.createSession(userId);
279
+ const session = sessionService.validateSession(token);
280
+ expect(session).not.toBeNull();
281
+ if (session === null) {
282
+ return;
283
+ }
284
+
285
+ sessionService.revokeSession(session.id);
286
+
287
+ const count = sessionService.cleanExpiredSessions();
288
+ expect(count).toBe(0);
289
+ });
290
+
291
+ it('returns the correct count when multiple old sessions are cleaned', () => {
292
+ const thirtyOneDaysAgo = Date.now() - 31 * 24 * 60 * 60 * 1000;
293
+
294
+ for (let i = 0; i < 3; i++) {
295
+ db.query(
296
+ `INSERT INTO sessions (id, user_id, token_hash, ip, user_agent, created_at, last_seen_at, expires_at)
297
+ VALUES (?, ?, ?, NULL, NULL, ?, ?, ?)`
298
+ ).run(
299
+ `old-${i}`,
300
+ userId,
301
+ `hash-${i}`,
302
+ thirtyOneDaysAgo,
303
+ thirtyOneDaysAgo,
304
+ thirtyOneDaysAgo - 1
305
+ );
306
+ }
307
+
308
+ const count = sessionService.cleanExpiredSessions();
309
+ expect(count).toBe(3);
310
+ });
311
+ });