@etcsec-com/etc-collector 1.4.0 → 1.5.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.
@@ -0,0 +1,264 @@
1
+ import Database from 'better-sqlite3';
2
+ import { TokenRepository } from '../../../src/data/repositories/token.repository';
3
+ import { TokenCleanupJob } from '../../../src/data/jobs/token-cleanup.job';
4
+
5
+ describe('TokenCleanupJob', () => {
6
+ let db: Database.Database;
7
+ let repository: TokenRepository;
8
+ let cleanupJob: TokenCleanupJob;
9
+
10
+ // Helper function to create expired token (bypasses CHECK constraint)
11
+ const createExpiredToken = (jti: string, daysAgo = 0): void => {
12
+ // First create with future date
13
+ repository.create({
14
+ jti,
15
+ public_key: 'key',
16
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
17
+ });
18
+
19
+ // Then update to expired date and created_at
20
+ if (daysAgo > 0) {
21
+ db.prepare(`UPDATE tokens SET
22
+ created_at = datetime('now', '-${daysAgo + 1} days'),
23
+ expires_at = datetime('now', '-${daysAgo} days')
24
+ WHERE jti = ?`).run(jti);
25
+ } else {
26
+ db.prepare(`UPDATE tokens SET
27
+ created_at = datetime('now', '-2 hours'),
28
+ expires_at = datetime('now', '-1 hour')
29
+ WHERE jti = ?`).run(jti);
30
+ }
31
+ };
32
+
33
+ beforeEach(() => {
34
+ // Create in-memory database for testing
35
+ db = new Database(':memory:');
36
+
37
+ // Create schema
38
+ db.exec(`
39
+ CREATE TABLE tokens (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ jti TEXT UNIQUE NOT NULL,
42
+ public_key TEXT NOT NULL,
43
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
44
+ expires_at TEXT NOT NULL,
45
+ max_uses INTEGER NOT NULL DEFAULT 0,
46
+ used_count INTEGER NOT NULL DEFAULT 0,
47
+ revoked_at TEXT,
48
+ revoked_by TEXT,
49
+ revoked_reason TEXT,
50
+ metadata TEXT,
51
+ CONSTRAINT check_usage CHECK (used_count <= max_uses OR max_uses = 0),
52
+ CONSTRAINT check_dates CHECK (datetime(expires_at) > datetime(created_at))
53
+ );
54
+
55
+ CREATE VIEW v_active_tokens AS
56
+ SELECT
57
+ id, jti, created_at, expires_at, max_uses, used_count,
58
+ CASE WHEN max_uses = 0 THEN -1 ELSE (max_uses - used_count) END AS remaining_uses
59
+ FROM tokens
60
+ WHERE revoked_at IS NULL AND datetime(expires_at) > datetime('now');
61
+ `);
62
+
63
+ repository = new TokenRepository(db);
64
+ cleanupJob = new TokenCleanupJob(repository);
65
+ });
66
+
67
+ afterEach(() => {
68
+ db.close();
69
+ });
70
+
71
+ describe('run', () => {
72
+ it('should delete expired non-revoked tokens', () => {
73
+ // Create expired non-revoked token
74
+ createExpiredToken('expired-1');
75
+
76
+ // Create active token
77
+ repository.create({
78
+ jti: 'active-1',
79
+ public_key: 'key',
80
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
81
+ });
82
+
83
+ const deletedCount = cleanupJob.run();
84
+
85
+ expect(deletedCount).toBe(1);
86
+ expect(repository.findByJti('expired-1')).toBeNull();
87
+ expect(repository.findByJti('active-1')).not.toBeNull();
88
+ });
89
+
90
+ it('should delete old revoked expired tokens (90+ days)', () => {
91
+ // Create token that expired 100 days ago
92
+ createExpiredToken('old-revoked', 100);
93
+
94
+ // Revoke it and set revoked_at to 91 days ago
95
+ repository.revoke('old-revoked', 'admin', 'old token');
96
+ db.prepare("UPDATE tokens SET revoked_at = datetime('now', '-91 days') WHERE jti = ?").run(
97
+ 'old-revoked'
98
+ );
99
+
100
+ const deletedCount = cleanupJob.run();
101
+
102
+ expect(deletedCount).toBe(1);
103
+ expect(repository.findByJti('old-revoked')).toBeNull();
104
+ });
105
+
106
+ it('should retain recent revoked expired tokens (< 90 days)', () => {
107
+ // Create token that expired 40 days ago
108
+ createExpiredToken('recent-revoked', 40);
109
+
110
+ // Revoke it and set revoked_at to 30 days ago
111
+ repository.revoke('recent-revoked', 'admin', 'recent token');
112
+ db.prepare("UPDATE tokens SET revoked_at = datetime('now', '-30 days') WHERE jti = ?").run(
113
+ 'recent-revoked'
114
+ );
115
+
116
+ const deletedCount = cleanupJob.run();
117
+
118
+ expect(deletedCount).toBe(0);
119
+ expect(repository.findByJti('recent-revoked')).not.toBeNull();
120
+ });
121
+
122
+ it('should not delete active tokens', () => {
123
+ repository.create({
124
+ jti: 'active-1',
125
+ public_key: 'key',
126
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
127
+ });
128
+
129
+ repository.create({
130
+ jti: 'active-2',
131
+ public_key: 'key',
132
+ expires_at: new Date(Date.now() + 7200000).toISOString(),
133
+ });
134
+
135
+ const deletedCount = cleanupJob.run();
136
+
137
+ expect(deletedCount).toBe(0);
138
+ expect(repository.count()).toBe(2);
139
+ });
140
+
141
+ it('should return 0 when no tokens to delete', () => {
142
+ const deletedCount = cleanupJob.run();
143
+ expect(deletedCount).toBe(0);
144
+ });
145
+
146
+ it('should handle mixed scenarios correctly', () => {
147
+ // Active token
148
+ repository.create({
149
+ jti: 'active',
150
+ public_key: 'key',
151
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
152
+ });
153
+
154
+ // Expired non-revoked (DELETE)
155
+ createExpiredToken('expired-no-revoke');
156
+
157
+ // Old revoked expired (DELETE)
158
+ createExpiredToken('old-revoked-expired', 100);
159
+ repository.revoke('old-revoked-expired', 'admin', 'old');
160
+ db.prepare("UPDATE tokens SET revoked_at = datetime('now', '-91 days') WHERE jti = ?").run(
161
+ 'old-revoked-expired'
162
+ );
163
+
164
+ // Recent revoked expired (KEEP for audit)
165
+ createExpiredToken('recent-revoked-expired', 40);
166
+ repository.revoke('recent-revoked-expired', 'admin', 'recent');
167
+ db.prepare("UPDATE tokens SET revoked_at = datetime('now', '-30 days') WHERE jti = ?").run(
168
+ 'recent-revoked-expired'
169
+ );
170
+
171
+ const deletedCount = cleanupJob.run();
172
+
173
+ expect(deletedCount).toBe(2); // expired-no-revoke + old-revoked-expired
174
+ expect(repository.count()).toBe(2); // active + recent-revoked-expired
175
+ expect(repository.findByJti('active')).not.toBeNull();
176
+ expect(repository.findByJti('recent-revoked-expired')).not.toBeNull();
177
+ });
178
+ });
179
+
180
+ describe('getStatistics', () => {
181
+ it('should return accurate statistics', () => {
182
+ // Create different token types
183
+ // 1. Active token
184
+ repository.create({
185
+ jti: 'active',
186
+ public_key: 'key',
187
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
188
+ });
189
+
190
+ // 2. Expired non-revoked
191
+ createExpiredToken('expired-no-revoke');
192
+
193
+ // 3. Old revoked expired
194
+ createExpiredToken('old-revoked', 100);
195
+ repository.revoke('old-revoked', 'admin', 'old');
196
+ db.prepare("UPDATE tokens SET revoked_at = datetime('now', '-91 days') WHERE jti = ?").run(
197
+ 'old-revoked'
198
+ );
199
+
200
+ // 4. Recent revoked expired
201
+ createExpiredToken('recent-revoked', 40);
202
+ repository.revoke('recent-revoked', 'admin', 'recent');
203
+ db.prepare("UPDATE tokens SET revoked_at = datetime('now', '-30 days') WHERE jti = ?").run(
204
+ 'recent-revoked'
205
+ );
206
+
207
+ const stats = cleanupJob.getStatistics();
208
+
209
+ expect(stats.totalTokens).toBe(4);
210
+ expect(stats.activeTokens).toBe(1);
211
+ expect(stats.expiredNonRevoked).toBe(1);
212
+ expect(stats.oldRevokedExpired).toBe(1);
213
+ expect(stats.recentRevokedExpired).toBe(1);
214
+ expect(stats.totalDeletionCandidates).toBe(2);
215
+ });
216
+
217
+ it('should return zeros for empty database', () => {
218
+ const stats = cleanupJob.getStatistics();
219
+
220
+ expect(stats.totalTokens).toBe(0);
221
+ expect(stats.activeTokens).toBe(0);
222
+ expect(stats.expiredNonRevoked).toBe(0);
223
+ expect(stats.oldRevokedExpired).toBe(0);
224
+ expect(stats.recentRevokedExpired).toBe(0);
225
+ expect(stats.totalDeletionCandidates).toBe(0);
226
+ });
227
+ });
228
+
229
+ describe('runWithStatistics', () => {
230
+ it('should return statistics before and after cleanup', () => {
231
+ // Create expired token
232
+ createExpiredToken('expired');
233
+
234
+ // Create active token
235
+ repository.create({
236
+ jti: 'active',
237
+ public_key: 'key',
238
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
239
+ });
240
+
241
+ const result = cleanupJob.runWithStatistics();
242
+
243
+ expect(result.deletedCount).toBe(1);
244
+ expect(result.statsBefore.totalTokens).toBe(2);
245
+ expect(result.statsAfter.totalTokens).toBe(1);
246
+ expect(result.statsBefore.expiredNonRevoked).toBe(1);
247
+ expect(result.statsAfter.expiredNonRevoked).toBe(0);
248
+ });
249
+
250
+ it('should show no changes when nothing to delete', () => {
251
+ repository.create({
252
+ jti: 'active',
253
+ public_key: 'key',
254
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
255
+ });
256
+
257
+ const result = cleanupJob.runWithStatistics();
258
+
259
+ expect(result.deletedCount).toBe(0);
260
+ expect(result.statsBefore.totalTokens).toBe(result.statsAfter.totalTokens);
261
+ expect(result.statsBefore.activeTokens).toBe(result.statsAfter.activeTokens);
262
+ });
263
+ });
264
+ });
@@ -0,0 +1,386 @@
1
+ import Database from 'better-sqlite3';
2
+ import { TokenRepository } from '../../../src/data/repositories/token.repository';
3
+ import { TokenCreateInput } from '../../../src/data/models/Token.model';
4
+
5
+ describe('TokenRepository', () => {
6
+ let db: Database.Database;
7
+ let repository: TokenRepository;
8
+
9
+ // Helper function to create expired token (bypasses CHECK constraint)
10
+ const createExpiredToken = (jti: string): void => {
11
+ // First create with future date
12
+ repository.create({
13
+ jti,
14
+ public_key: 'key',
15
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
16
+ });
17
+
18
+ // Then update both created_at and expires_at to maintain constraint
19
+ db.prepare(`UPDATE tokens SET
20
+ created_at = datetime('now', '-2 hours'),
21
+ expires_at = datetime('now', '-1 hour')
22
+ WHERE jti = ?`).run(jti);
23
+ };
24
+
25
+ beforeEach(() => {
26
+ // Create in-memory database for testing
27
+ db = new Database(':memory:');
28
+
29
+ // Create schema
30
+ db.exec(`
31
+ CREATE TABLE tokens (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ jti TEXT UNIQUE NOT NULL,
34
+ public_key TEXT NOT NULL,
35
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
36
+ expires_at TEXT NOT NULL,
37
+ max_uses INTEGER NOT NULL DEFAULT 0,
38
+ used_count INTEGER NOT NULL DEFAULT 0,
39
+ revoked_at TEXT,
40
+ revoked_by TEXT,
41
+ revoked_reason TEXT,
42
+ metadata TEXT,
43
+ CONSTRAINT check_usage CHECK (used_count <= max_uses OR max_uses = 0),
44
+ CONSTRAINT check_dates CHECK (datetime(expires_at) > datetime(created_at))
45
+ );
46
+
47
+ CREATE INDEX idx_tokens_jti ON tokens(jti);
48
+ CREATE INDEX idx_tokens_expires_at ON tokens(expires_at);
49
+ CREATE INDEX idx_tokens_revoked_at ON tokens(revoked_at);
50
+
51
+ CREATE VIEW v_active_tokens AS
52
+ SELECT
53
+ id, jti, created_at, expires_at, max_uses, used_count,
54
+ CASE WHEN max_uses = 0 THEN -1 ELSE (max_uses - used_count) END AS remaining_uses
55
+ FROM tokens
56
+ WHERE revoked_at IS NULL AND datetime(expires_at) > datetime('now');
57
+ `);
58
+
59
+ repository = new TokenRepository(db);
60
+ });
61
+
62
+ afterEach(() => {
63
+ db.close();
64
+ });
65
+
66
+ describe('create', () => {
67
+ it('should create a token successfully', () => {
68
+ const input: TokenCreateInput = {
69
+ jti: 'test-jti-123',
70
+ public_key: '-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----',
71
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
72
+ max_uses: 10,
73
+ metadata: '{"purpose": "test"}',
74
+ };
75
+
76
+ const token = repository.create(input);
77
+
78
+ expect(token).toBeDefined();
79
+ expect(token.id).toBeGreaterThan(0);
80
+ expect(token.jti).toBe(input.jti);
81
+ expect(token.public_key).toBe(input.public_key);
82
+ expect(token.max_uses).toBe(10);
83
+ expect(token.used_count).toBe(0);
84
+ expect(token.revoked_at).toBeNull();
85
+ });
86
+
87
+ it('should create token with default values', () => {
88
+ const input: TokenCreateInput = {
89
+ jti: 'test-jti-456',
90
+ public_key: '-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----',
91
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
92
+ };
93
+
94
+ const token = repository.create(input);
95
+
96
+ expect(token.max_uses).toBe(0);
97
+ expect(token.metadata).toBeNull();
98
+ });
99
+
100
+ it('should throw error on duplicate jti', () => {
101
+ const input: TokenCreateInput = {
102
+ jti: 'duplicate-jti',
103
+ public_key: 'test-key',
104
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
105
+ };
106
+
107
+ repository.create(input);
108
+
109
+ expect(() => repository.create(input)).toThrow();
110
+ });
111
+ });
112
+
113
+ describe('findByJti', () => {
114
+ it('should find token by jti', () => {
115
+ const input: TokenCreateInput = {
116
+ jti: 'find-me',
117
+ public_key: 'test-key',
118
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
119
+ };
120
+
121
+ repository.create(input);
122
+ const found = repository.findByJti('find-me');
123
+
124
+ expect(found).not.toBeNull();
125
+ expect(found?.jti).toBe('find-me');
126
+ });
127
+
128
+ it('should return null for non-existent jti', () => {
129
+ const found = repository.findByJti('non-existent');
130
+ expect(found).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe('findAll', () => {
135
+ it('should return all tokens', () => {
136
+ repository.create({
137
+ jti: 'token-1',
138
+ public_key: 'key-1',
139
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
140
+ });
141
+
142
+ repository.create({
143
+ jti: 'token-2',
144
+ public_key: 'key-2',
145
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
146
+ });
147
+
148
+ const tokens = repository.findAll();
149
+ expect(tokens).toHaveLength(2);
150
+ });
151
+
152
+ it('should return empty array when no tokens', () => {
153
+ const tokens = repository.findAll();
154
+ expect(tokens).toEqual([]);
155
+ });
156
+ });
157
+
158
+ describe('findActive', () => {
159
+ it('should return only active tokens', () => {
160
+ // Create active token
161
+ repository.create({
162
+ jti: 'active-1',
163
+ public_key: 'key-1',
164
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
165
+ });
166
+
167
+ // Create expired token
168
+ createExpiredToken('expired-1');
169
+
170
+ // Create revoked token
171
+ const token3 = repository.create({
172
+ jti: 'revoked-1',
173
+ public_key: 'key-3',
174
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
175
+ });
176
+ repository.revoke(token3.jti, 'test', 'testing');
177
+
178
+ const activeTokens = repository.findActive();
179
+ expect(activeTokens).toHaveLength(1);
180
+ expect(activeTokens[0]!.jti).toBe('active-1');
181
+ });
182
+
183
+ it('should calculate remaining_uses correctly', () => {
184
+ repository.create({
185
+ jti: 'limited-token',
186
+ public_key: 'key',
187
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
188
+ max_uses: 5,
189
+ });
190
+
191
+ const activeTokens = repository.findActive();
192
+ expect(activeTokens[0]!.remaining_uses).toBe(5);
193
+ });
194
+
195
+ it('should return -1 for unlimited tokens', () => {
196
+ repository.create({
197
+ jti: 'unlimited-token',
198
+ public_key: 'key',
199
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
200
+ max_uses: 0,
201
+ });
202
+
203
+ const activeTokens = repository.findActive();
204
+ expect(activeTokens[0]!.remaining_uses).toBe(-1);
205
+ });
206
+ });
207
+
208
+ describe('incrementUsage', () => {
209
+ it('should increment usage count', () => {
210
+ repository.create({
211
+ jti: 'usage-test',
212
+ public_key: 'key',
213
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
214
+ max_uses: 10,
215
+ });
216
+
217
+ repository.incrementUsage('usage-test');
218
+
219
+ const token = repository.findByJti('usage-test');
220
+ expect(token?.used_count).toBe(1);
221
+
222
+ repository.incrementUsage('usage-test');
223
+ const token2 = repository.findByJti('usage-test');
224
+ expect(token2?.used_count).toBe(2);
225
+ });
226
+
227
+ it('should throw error when usage limit exceeded', () => {
228
+ repository.create({
229
+ jti: 'limited-usage',
230
+ public_key: 'key',
231
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
232
+ max_uses: 2,
233
+ });
234
+
235
+ repository.incrementUsage('limited-usage');
236
+ repository.incrementUsage('limited-usage');
237
+
238
+ expect(() => repository.incrementUsage('limited-usage')).toThrow();
239
+ });
240
+
241
+ it('should throw error for expired token', () => {
242
+ createExpiredToken('expired-usage');
243
+ expect(() => repository.incrementUsage('expired-usage')).toThrow();
244
+ });
245
+
246
+ it('should throw error for revoked token', () => {
247
+ repository.create({
248
+ jti: 'revoked-usage',
249
+ public_key: 'key',
250
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
251
+ });
252
+
253
+ repository.revoke('revoked-usage', 'test', 'testing');
254
+
255
+ expect(() => repository.incrementUsage('revoked-usage')).toThrow();
256
+ });
257
+
258
+ it('should allow unlimited usage when max_uses is 0', () => {
259
+ repository.create({
260
+ jti: 'unlimited-usage',
261
+ public_key: 'key',
262
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
263
+ max_uses: 0,
264
+ });
265
+
266
+ for (let i = 0; i < 100; i++) {
267
+ repository.incrementUsage('unlimited-usage');
268
+ }
269
+
270
+ const token = repository.findByJti('unlimited-usage');
271
+ expect(token?.used_count).toBe(100);
272
+ });
273
+ });
274
+
275
+ describe('revoke', () => {
276
+ it('should revoke token successfully', () => {
277
+ repository.create({
278
+ jti: 'revoke-test',
279
+ public_key: 'key',
280
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
281
+ });
282
+
283
+ repository.revoke('revoke-test', 'admin', 'security incident');
284
+
285
+ const token = repository.findByJti('revoke-test');
286
+ expect(token?.revoked_at).not.toBeNull();
287
+ expect(token?.revoked_by).toBe('admin');
288
+ expect(token?.revoked_reason).toBe('security incident');
289
+ });
290
+
291
+ it('should throw error when revoking already revoked token', () => {
292
+ repository.create({
293
+ jti: 'already-revoked',
294
+ public_key: 'key',
295
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
296
+ });
297
+
298
+ repository.revoke('already-revoked', 'admin', 'first revocation');
299
+
300
+ expect(() => repository.revoke('already-revoked', 'admin', 'second revocation')).toThrow();
301
+ });
302
+
303
+ it('should throw error for non-existent token', () => {
304
+ expect(() => repository.revoke('non-existent', 'admin', 'test')).toThrow();
305
+ });
306
+ });
307
+
308
+ describe('deleteExpired', () => {
309
+ it('should delete expired tokens', () => {
310
+ // Create expired token
311
+ createExpiredToken('expired-delete');
312
+
313
+ // Create active token
314
+ repository.create({
315
+ jti: 'active-delete',
316
+ public_key: 'key',
317
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
318
+ });
319
+
320
+ const deletedCount = repository.deleteExpired();
321
+
322
+ expect(deletedCount).toBe(1);
323
+ expect(repository.findByJti('expired-delete')).toBeNull();
324
+ expect(repository.findByJti('active-delete')).not.toBeNull();
325
+ });
326
+
327
+ it('should return 0 when no expired tokens', () => {
328
+ repository.create({
329
+ jti: 'active-only',
330
+ public_key: 'key',
331
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
332
+ });
333
+
334
+ const deletedCount = repository.deleteExpired();
335
+ expect(deletedCount).toBe(0);
336
+ });
337
+ });
338
+
339
+ describe('count', () => {
340
+ it('should return total token count', () => {
341
+ expect(repository.count()).toBe(0);
342
+
343
+ repository.create({
344
+ jti: 'count-1',
345
+ public_key: 'key',
346
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
347
+ });
348
+
349
+ expect(repository.count()).toBe(1);
350
+
351
+ repository.create({
352
+ jti: 'count-2',
353
+ public_key: 'key',
354
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
355
+ });
356
+
357
+ expect(repository.count()).toBe(2);
358
+ });
359
+ });
360
+
361
+ describe('countActive', () => {
362
+ it('should return active token count', () => {
363
+ // Create active tokens
364
+ repository.create({
365
+ jti: 'active-count-1',
366
+ public_key: 'key',
367
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
368
+ });
369
+
370
+ repository.create({
371
+ jti: 'active-count-2',
372
+ public_key: 'key',
373
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
374
+ });
375
+
376
+ // Create expired token
377
+ createExpiredToken('expired-count');
378
+
379
+ expect(repository.countActive()).toBe(2);
380
+ });
381
+
382
+ it('should return 0 when no active tokens', () => {
383
+ expect(repository.countActive()).toBe(0);
384
+ });
385
+ });
386
+ });