@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.
- package/.github/workflows/.gitkeep +0 -0
- package/.github/workflows/ci.yml +23 -15
- package/.github/workflows/release.yml +75 -24
- package/dist/services/audit/ad-audit.service.js +2 -2
- package/dist/services/audit/ad-audit.service.js.map +1 -1
- package/dist/services/export/formatters/json.formatter.js +1 -1
- package/dist/services/export/formatters/json.formatter.js.map +1 -1
- package/eslint.config.js +8 -13
- package/jest.config.js +5 -16
- package/package.json +5 -16
- package/scripts/build-binary.sh +139 -0
- package/src/services/audit/ad-audit.service.ts +2 -2
- package/src/services/export/formatters/json.formatter.ts +1 -1
- package/tests/integration/data/.gitkeep +0 -0
- package/tests/integration/data/token-persistence.integration.test.ts +299 -0
- package/tests/unit/data/.gitkeep +0 -0
- package/tests/unit/data/migration.runner.test.ts +117 -0
- package/tests/unit/data/token-cleanup.job.test.ts +264 -0
- package/tests/unit/data/token.repository.test.ts +386 -0
|
@@ -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
|
+
});
|