create-tigra 2.8.0 → 3.0.2

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 (53) hide show
  1. package/README.md +10 -3
  2. package/bin/create-tigra.js +77 -37
  3. package/package.json +5 -5
  4. package/template/_claude/commands/create-server.md +8 -2
  5. package/template/_claude/rules/client/01-project-structure.md +12 -0
  6. package/template/_claude/rules/client/03-data-and-state.md +1 -1
  7. package/template/_claude/rules/client/04-design-system.md +23 -0
  8. package/template/_claude/rules/client/07-deployment.md +99 -0
  9. package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
  10. package/template/_claude/rules/client/core.md +1 -0
  11. package/template/_claude/rules/global/core.md +20 -1
  12. package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
  13. package/template/_claude/rules/server/core.md +2 -0
  14. package/template/_claude/rules/server/deployment.md +78 -0
  15. package/template/client/next.config.ts +12 -2
  16. package/template/client/package-lock.json +12345 -0
  17. package/template/client/package.json +3 -2
  18. package/template/client/src/components/common/SafeImage.tsx +2 -1
  19. package/template/client/src/lib/api/axios.config.ts +19 -4
  20. package/template/client/src/middleware.ts +7 -0
  21. package/template/gitignore +1 -0
  22. package/template/server/.env.example +248 -194
  23. package/template/server/.env.example.production +221 -168
  24. package/template/server/Dockerfile +29 -5
  25. package/template/server/docker-compose.yml +32 -4
  26. package/template/server/package-lock.json +6544 -6823
  27. package/template/server/package.json +76 -75
  28. package/template/server/prisma/seed.ts +20 -4
  29. package/template/server/src/app.ts +316 -271
  30. package/template/server/src/config/env.ts +150 -99
  31. package/template/server/src/config/rate-limit.config.ts +16 -0
  32. package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
  33. package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
  34. package/template/server/src/libs/__tests__/http.test.ts +23 -9
  35. package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
  36. package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
  37. package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
  38. package/template/server/src/libs/auth-path.ts +14 -0
  39. package/template/server/src/libs/auth.ts +6 -16
  40. package/template/server/src/libs/client-ip.ts +77 -0
  41. package/template/server/src/libs/cookies.ts +1 -1
  42. package/template/server/src/libs/duration.ts +30 -0
  43. package/template/server/src/libs/ip-block.ts +220 -206
  44. package/template/server/src/libs/origin-check.ts +38 -0
  45. package/template/server/src/libs/query-counter.ts +59 -0
  46. package/template/server/src/libs/redis.ts +1 -1
  47. package/template/server/src/libs/url-safety.ts +121 -0
  48. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
  49. package/template/server/src/modules/auth/auth.controller.ts +128 -127
  50. package/template/server/src/modules/auth/auth.repo.ts +2 -0
  51. package/template/server/src/modules/auth/auth.service.ts +103 -12
  52. package/template/server/src/test/setup.ts +22 -2
  53. package/template/server/vitest.config.ts +43 -43
@@ -0,0 +1,121 @@
1
+ /**
2
+ * URL/IP safety helpers shared across outbound-HTTP code paths.
3
+ *
4
+ * - `redactUrl` — strips secrets (Telegram bot tokens, query strings)
5
+ * from a URL before it reaches any log sink.
6
+ * - `isPrivateOrReservedIp`— SSRF building block: returns true for any loopback /
7
+ * private / link-local / ULA / reserved address so the
8
+ * image-fetch guard can reject internal targets.
9
+ *
10
+ * Pure, dependency-free, and side-effect-free so they are trivial to unit test.
11
+ */
12
+
13
+ /**
14
+ * Redact a URL so it is safe to log.
15
+ *
16
+ * Telegram embeds the bot token directly in the request path:
17
+ * https://api.telegram.org/bot<TOKEN>/getFile
18
+ * https://api.telegram.org/file/bot<TOKEN>/photos/file_1.jpg
19
+ * Both forms (`/bot<token>/` and `/file/bot<token>/`) are rewritten to
20
+ * `/bot<redacted>/`. Any query string is dropped wholesale (it can also carry
21
+ * media tokens / signatures). Non-Telegram URLs pass through unchanged apart
22
+ * from query stripping. NEVER throws — an unparseable string is returned with
23
+ * just the same path-level redaction applied.
24
+ */
25
+ export function redactUrl(url: string): string {
26
+ if (typeof url !== 'string' || url.length === 0) {
27
+ return url;
28
+ }
29
+
30
+ // Drop the query string (and fragment) — may hold signed tokens.
31
+ const noQuery = url.split(/[?#]/, 1)[0];
32
+
33
+ // Rewrite Telegram bot-token path segments. The token charset is [A-Za-z0-9_-]
34
+ // plus the ':' separating bot-id from secret, e.g. 123456:AAH...
35
+ return noQuery.replace(/\/bot[A-Za-z0-9:_-]+/g, '/bot<redacted>');
36
+ }
37
+
38
+ /**
39
+ * True when `addr` is a loopback / private / link-local / ULA / unspecified /
40
+ * reserved IP that an SSRF target could use to reach internal infrastructure.
41
+ *
42
+ * Covers:
43
+ * IPv4: 0.0.0.0, 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10 (CGNAT)
44
+ * IPv6: ::, ::1, fc00::/7 (ULA), fe80::/10 (link-local)
45
+ * IPv4-mapped IPv6 (::ffff:a.b.c.d) — re-checked against the IPv4 rules.
46
+ *
47
+ * Unrecognised / unparseable input returns true (fail-closed) — the caller
48
+ * uses this purely to BLOCK, so an address it cannot classify is treated as
49
+ * unsafe rather than silently allowed.
50
+ */
51
+ export function isPrivateOrReservedIp(addr: string): boolean {
52
+ if (typeof addr !== 'string' || addr.length === 0) {
53
+ return true;
54
+ }
55
+
56
+ const ip = addr.trim().toLowerCase();
57
+
58
+ // IPv4-mapped IPv6, e.g. ::ffff:10.0.0.5 or ::ffff:0a00:0005 — extract the v4 tail.
59
+ const mapped = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
60
+ if (mapped) {
61
+ return isPrivateOrReservedIpv4(mapped[1]);
62
+ }
63
+
64
+ if (ip.includes(':')) {
65
+ return isReservedIpv6(ip);
66
+ }
67
+
68
+ return isPrivateOrReservedIpv4(ip);
69
+ }
70
+
71
+ function isPrivateOrReservedIpv4(ip: string): boolean {
72
+ const parts = ip.split('.');
73
+ if (parts.length !== 4) {
74
+ return true; // not a clean dotted-quad → block
75
+ }
76
+
77
+ const octets = parts.map((p) => Number(p));
78
+ if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) {
79
+ return true; // malformed → block
80
+ }
81
+
82
+ const [a, b] = octets;
83
+
84
+ if (a === 0) return true; // 0.0.0.0/8 (incl. unspecified)
85
+ if (a === 127) return true; // 127.0.0.0/8 loopback
86
+ if (a === 10) return true; // 10.0.0.0/8 private
87
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
88
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
89
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
90
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
91
+
92
+ return false;
93
+ }
94
+
95
+ function isReservedIpv6(ip: string): boolean {
96
+ // Normalise: drop zone id (fe80::1%eth0) and any brackets.
97
+ const bare = ip.replace(/^\[|\]$/g, '').split('%')[0];
98
+
99
+ if (bare === '::' || bare === '::0' || bare === '0:0:0:0:0:0:0:0') return true; // unspecified
100
+ if (bare === '::1') return true; // loopback
101
+
102
+ // First hextet determines ULA / link-local ranges.
103
+ const firstHextet = bare.split(':')[0];
104
+ if (firstHextet === '') {
105
+ // Address starts with '::' (e.g. '::abcd') — already handled the special
106
+ // cases above; remaining such addresses are not in fc00::/7 or fe80::/10.
107
+ return false;
108
+ }
109
+
110
+ const value = parseInt(firstHextet, 16);
111
+ if (Number.isNaN(value)) {
112
+ return true; // unparseable hextet → block
113
+ }
114
+
115
+ // fc00::/7 → first 7 bits = 1111110 → first byte 0xfc or 0xfd
116
+ if ((value & 0xfe00) === 0xfc00) return true;
117
+ // fe80::/10 → first 10 bits = 1111111010 → 0xfe80..0xfebf
118
+ if ((value & 0xffc0) === 0xfe80) return true;
119
+
120
+ return false;
121
+ }
@@ -3,8 +3,8 @@ import * as authService from '../auth.service.js';
3
3
  import * as authRepo from '../auth.repo.js';
4
4
  import * as authLib from '@libs/auth.js';
5
5
  import { hashPassword, verifyPassword } from '@libs/password.js';
6
- import { ConflictError, UnauthorizedError, NotFoundError } from '@shared/errors/errors.js';
7
- import { testUsers, testRefreshToken, resetMocks } from '@/test/setup.js';
6
+ import { ConflictError, UnauthorizedError, ForbiddenError, NotFoundError } from '@shared/errors/errors.js';
7
+ import { testUsers, testRefreshToken, testSession, resetMocks } from '@/test/setup.js';
8
8
  import { sessionRepository } from '../session.repo.js';
9
9
 
10
10
  // Mock dependencies
@@ -13,10 +13,31 @@ vi.mock('@libs/auth.js');
13
13
  vi.mock('@libs/password.js');
14
14
  vi.mock('../session.repo.js');
15
15
 
16
+ // In-memory Redis stub — account lockout (email+IP) uses Redis. Tests must
17
+ // never require a live Redis instance.
18
+ const mockRedis = vi.hoisted(() => ({
19
+ exists: vi.fn(),
20
+ incr: vi.fn(),
21
+ expire: vi.fn(),
22
+ set: vi.fn(),
23
+ get: vi.fn(),
24
+ del: vi.fn(),
25
+ }));
26
+
27
+ vi.mock('@libs/redis.js', () => ({
28
+ getRedis: (): typeof mockRedis => mockRedis,
29
+ }));
30
+
16
31
  describe('Auth Service', () => {
17
32
  beforeEach(() => {
18
33
  resetMocks();
19
34
  vi.clearAllMocks();
35
+ // Default Redis behavior: nothing locked, first failure
36
+ mockRedis.exists.mockResolvedValue(0);
37
+ mockRedis.incr.mockResolvedValue(1);
38
+ mockRedis.expire.mockResolvedValue(1);
39
+ mockRedis.set.mockResolvedValue('OK');
40
+ mockRedis.del.mockResolvedValue(1);
20
41
  });
21
42
 
22
43
  describe('register', () => {
@@ -28,7 +49,8 @@ describe('Auth Service', () => {
28
49
  };
29
50
 
30
51
  it('should successfully register a new user', async () => {
31
- // Arrange
52
+ // Arrange — REQUIRE_USER_VERIFICATION=false in tests, so the user is
53
+ // active immediately and tokens are issued.
32
54
  const hashedPassword = '$2a$12$hashedpassword';
33
55
  const createdUser = {
34
56
  ...testUsers.validUser,
@@ -40,11 +62,13 @@ describe('Auth Service', () => {
40
62
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
41
63
 
42
64
  vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
65
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(null);
43
66
  vi.mocked(hashPassword).mockResolvedValue(hashedPassword);
44
67
  vi.mocked(authRepo.createUser).mockResolvedValue(createdUser);
45
68
  vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
46
69
  vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
47
70
  vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
71
+ vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
48
72
  vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
49
73
 
50
74
  // Act
@@ -58,15 +82,23 @@ describe('Auth Service', () => {
58
82
  password: hashedPassword,
59
83
  firstName: validRegisterInput.firstName,
60
84
  lastName: validRegisterInput.lastName,
85
+ isActive: true,
61
86
  });
62
87
  expect(authLib.signAccessToken).toHaveBeenCalledWith({
63
88
  userId: createdUser.id,
64
89
  role: createdUser.role,
65
90
  });
66
91
  expect(authLib.generateRefreshToken).toHaveBeenCalled();
92
+ expect(sessionRepository.createSession).toHaveBeenCalledWith({
93
+ userId: createdUser.id,
94
+ deviceInfo: undefined,
95
+ ipAddress: undefined,
96
+ expiresAt,
97
+ });
67
98
  expect(authRepo.createRefreshToken).toHaveBeenCalledWith({
68
99
  token: refreshToken,
69
100
  userId: createdUser.id,
101
+ sessionId: testSession.id,
70
102
  expiresAt,
71
103
  });
72
104
  expect(result).toEqual({
@@ -79,6 +111,7 @@ describe('Auth Service', () => {
79
111
  }),
80
112
  accessToken,
81
113
  refreshToken,
114
+ requiresVerification: false,
82
115
  });
83
116
  expect(result.user).not.toHaveProperty('password');
84
117
  });
@@ -93,6 +126,19 @@ describe('Auth Service', () => {
93
126
  expect(hashPassword).not.toHaveBeenCalled();
94
127
  expect(authRepo.createUser).not.toHaveBeenCalled();
95
128
  });
129
+
130
+ it('should throw ConflictError if a soft-deleted account exists with this email', async () => {
131
+ // Arrange
132
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
133
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue({
134
+ ...testUsers.validUser,
135
+ deletedAt: new Date(),
136
+ });
137
+
138
+ // Act & Assert
139
+ await expect(authService.register(validRegisterInput)).rejects.toThrow(ConflictError);
140
+ expect(authRepo.createUser).not.toHaveBeenCalled();
141
+ });
96
142
  });
97
143
 
98
144
  describe('login', () => {
@@ -112,6 +158,7 @@ describe('Auth Service', () => {
112
158
  vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
113
159
  vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
114
160
  vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
161
+ vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
115
162
  vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
116
163
 
117
164
  // Act
@@ -120,6 +167,18 @@ describe('Auth Service', () => {
120
167
  // Assert
121
168
  expect(authRepo.findUserByEmail).toHaveBeenCalledWith(validLoginInput.email);
122
169
  expect(verifyPassword).toHaveBeenCalledWith(validLoginInput.password, testUsers.validUser.password);
170
+ expect(sessionRepository.createSession).toHaveBeenCalledWith({
171
+ userId: testUsers.validUser.id,
172
+ deviceInfo: undefined,
173
+ ipAddress: undefined,
174
+ expiresAt,
175
+ });
176
+ expect(authRepo.createRefreshToken).toHaveBeenCalledWith({
177
+ token: refreshToken,
178
+ userId: testUsers.validUser.id,
179
+ sessionId: testSession.id,
180
+ expiresAt,
181
+ });
123
182
  expect(result).toEqual({
124
183
  user: expect.objectContaining({
125
184
  id: testUsers.validUser.id,
@@ -134,6 +193,7 @@ describe('Auth Service', () => {
134
193
  it('should throw UnauthorizedError if user not found', async () => {
135
194
  // Arrange
136
195
  vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
196
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(null);
137
197
 
138
198
  // Act & Assert
139
199
  await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
@@ -141,13 +201,15 @@ describe('Auth Service', () => {
141
201
  expect(verifyPassword).not.toHaveBeenCalled();
142
202
  });
143
203
 
144
- it('should throw UnauthorizedError if account is disabled', async () => {
204
+ it('should throw ForbiddenError if account is not activated', async () => {
145
205
  // Arrange
146
206
  vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.inactiveUser);
147
207
 
148
208
  // Act & Assert
149
- await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
150
- await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
209
+ await expect(authService.login(validLoginInput)).rejects.toThrow(ForbiddenError);
210
+ await expect(authService.login(validLoginInput)).rejects.toThrow(
211
+ 'Account is not activated. Please verify your account.',
212
+ );
151
213
  expect(verifyPassword).not.toHaveBeenCalled();
152
214
  });
153
215
 
@@ -162,6 +224,147 @@ describe('Auth Service', () => {
162
224
  });
163
225
  });
164
226
 
227
+ describe('login — account lockout (email+IP scoped)', () => {
228
+ const loginInput = { email: 'test@example.com', password: 'WrongPassword!' };
229
+ const attackerIp = '203.0.113.7';
230
+
231
+ it('should reject login when the email+IP pair is locked, without verifying the password', async () => {
232
+ // Arrange
233
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
234
+ mockRedis.exists.mockResolvedValue(1); // lock key present for this pair
235
+
236
+ // Act & Assert
237
+ await expect(
238
+ authService.login(loginInput, 'test-agent', attackerIp),
239
+ ).rejects.toThrow('Invalid email or password');
240
+ expect(mockRedis.exists).toHaveBeenCalledWith(`login-lock:test@example.com:${attackerIp}`);
241
+ expect(verifyPassword).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it('should NOT lock out the same email from a different IP (no remote lockout DoS)', async () => {
245
+ // Arrange — lock exists only for the attacker's IP; victim logs in from
246
+ // their own IP and Redis reports no lock for that pair.
247
+ const victimIp = '198.51.100.42';
248
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
249
+
250
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
251
+ mockRedis.exists.mockResolvedValue(0);
252
+ vi.mocked(verifyPassword).mockResolvedValue(true);
253
+ vi.mocked(authLib.signAccessToken).mockReturnValue('access');
254
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
255
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
256
+ vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
257
+ vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
258
+
259
+ // Act
260
+ const result = await authService.login(
261
+ { ...loginInput, password: 'Password123!' },
262
+ 'test-agent',
263
+ victimIp,
264
+ );
265
+
266
+ // Assert — lock was checked for the VICTIM's pair only, and login succeeded
267
+ expect(mockRedis.exists).toHaveBeenCalledWith(`login-lock:test@example.com:${victimIp}`);
268
+ expect(result.accessToken).toBe('access');
269
+ });
270
+
271
+ it('should record a failed attempt against the email+IP pair on bad password', async () => {
272
+ // Arrange
273
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
274
+ vi.mocked(verifyPassword).mockResolvedValue(false);
275
+ mockRedis.incr.mockResolvedValue(3); // below the first lockout threshold
276
+
277
+ // Act & Assert
278
+ await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
279
+ UnauthorizedError,
280
+ );
281
+ expect(mockRedis.incr).toHaveBeenCalledWith(`login-fail:test@example.com:${attackerIp}`);
282
+ expect(mockRedis.set).not.toHaveBeenCalled(); // no lock yet
283
+ });
284
+
285
+ it('should set a lock for the email+IP pair after 5 failures (15 min)', async () => {
286
+ // Arrange
287
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
288
+ vi.mocked(verifyPassword).mockResolvedValue(false);
289
+ mockRedis.incr.mockResolvedValue(5); // hits the first threshold
290
+
291
+ // Act & Assert
292
+ await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
293
+ UnauthorizedError,
294
+ );
295
+ expect(mockRedis.set).toHaveBeenCalledWith(
296
+ `login-lock:test@example.com:${attackerIp}`,
297
+ '1',
298
+ 'EX',
299
+ 15 * 60,
300
+ );
301
+ });
302
+
303
+ it('should fail open and allow login when Redis is unavailable', async () => {
304
+ // Arrange — Redis down: lockout checks must not block all logins
305
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
306
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
307
+ mockRedis.exists.mockRejectedValue(new Error('Redis down'));
308
+ mockRedis.del.mockRejectedValue(new Error('Redis down'));
309
+ vi.mocked(verifyPassword).mockResolvedValue(true);
310
+ vi.mocked(authLib.signAccessToken).mockReturnValue('access');
311
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
312
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
313
+ vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
314
+ vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
315
+
316
+ // Act
317
+ const result = await authService.login(
318
+ { ...loginInput, password: 'Password123!' },
319
+ 'test-agent',
320
+ attackerIp,
321
+ );
322
+
323
+ // Assert
324
+ expect(result.accessToken).toBe('access');
325
+ });
326
+ });
327
+
328
+ describe('login — soft-deleted account restore path (lockout)', () => {
329
+ const loginInput = { email: 'test@example.com', password: 'WrongPassword!' };
330
+ const attackerIp = '203.0.113.7';
331
+ const softDeletedUser = {
332
+ ...testUsers.validUser,
333
+ deletedAt: new Date('2024-02-01T00:00:00Z'),
334
+ isActive: false,
335
+ };
336
+
337
+ it('should record a failed attempt against the email+IP pair on wrong password for a soft-deleted account', async () => {
338
+ // Arrange
339
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
340
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(softDeletedUser);
341
+ vi.mocked(verifyPassword).mockResolvedValue(false);
342
+ mockRedis.incr.mockResolvedValue(3); // below the first lockout threshold
343
+
344
+ // Act & Assert
345
+ await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
346
+ 'Invalid email or password',
347
+ );
348
+ expect(mockRedis.incr).toHaveBeenCalledWith(`login-fail:test@example.com:${attackerIp}`);
349
+ expect(authRepo.restoreUser).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it('should reject a locked email+IP pair on the soft-delete path before verifying the password', async () => {
353
+ // Arrange
354
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
355
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(softDeletedUser);
356
+ mockRedis.exists.mockResolvedValue(1); // lock key present for this pair
357
+
358
+ // Act & Assert
359
+ await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
360
+ 'Invalid email or password',
361
+ );
362
+ expect(mockRedis.exists).toHaveBeenCalledWith(`login-lock:test@example.com:${attackerIp}`);
363
+ expect(verifyPassword).not.toHaveBeenCalled();
364
+ expect(authRepo.restoreUser).not.toHaveBeenCalled();
365
+ });
366
+ });
367
+
165
368
  describe('refresh', () => {
166
369
  const validRefreshToken = 'valid-refresh-token';
167
370
 
@@ -196,6 +399,7 @@ describe('Auth Service', () => {
196
399
  expect(authRepo.rotateRefreshToken).toHaveBeenCalledWith(validRefreshToken, {
197
400
  token: newRefreshToken,
198
401
  userId: testUsers.validUser.id,
402
+ sessionId: undefined, // fixture sessionId is null → normalized to undefined
199
403
  expiresAt,
200
404
  });
201
405
  expect(result).toEqual({
@@ -242,7 +446,7 @@ describe('Auth Service', () => {
242
446
  await expect(authService.refresh(validRefreshToken)).rejects.toThrow('User not found or disabled');
243
447
  });
244
448
 
245
- it('should throw UnauthorizedError if user is disabled', async () => {
449
+ it('should throw ForbiddenError if user is not activated', async () => {
246
450
  // Arrange
247
451
  const storedToken = {
248
452
  ...testRefreshToken,
@@ -251,35 +455,73 @@ describe('Auth Service', () => {
251
455
  vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
252
456
  vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.inactiveUser);
253
457
 
458
+ // Act & Assert
459
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow(ForbiddenError);
460
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow(
461
+ 'Account is not activated. Please verify your account.',
462
+ );
463
+ });
464
+
465
+ it('should revoke ALL refresh tokens and sessions when token reuse is detected', async () => {
466
+ // Regression test for refresh-token-reuse family revocation:
467
+ // the token was found, but the atomic rotation reports it was already
468
+ // consumed by a concurrent request — two parties presented the same
469
+ // single-use token. The whole family must be revoked, because whichever
470
+ // party redeemed it first (possibly an attacker) holds a valid token.
471
+ const storedToken = {
472
+ ...testRefreshToken,
473
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
474
+ };
475
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
476
+ vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
477
+ vi.mocked(authLib.signAccessToken).mockReturnValue('access');
478
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
479
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(new Date());
480
+ vi.mocked(authRepo.rotateRefreshToken).mockResolvedValue(false); // already consumed
481
+ vi.mocked(authRepo.deleteRefreshTokensByUserId).mockResolvedValue(undefined);
482
+ vi.mocked(sessionRepository.deleteAllUserSessions).mockResolvedValue(1);
483
+
254
484
  // Act & Assert
255
485
  await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
256
- await expect(authService.refresh(validRefreshToken)).rejects.toThrow('User not found or disabled');
486
+ expect(authRepo.deleteRefreshTokensByUserId).toHaveBeenCalledWith(testUsers.validUser.id);
487
+ expect(sessionRepository.deleteAllUserSessions).toHaveBeenCalledWith(testUsers.validUser.id);
488
+ });
489
+
490
+ it('should NOT revoke the token family on a successful rotation', async () => {
491
+ // Companion to the reuse test — the revocation path must not fire on
492
+ // the happy path.
493
+ const storedToken = {
494
+ ...testRefreshToken,
495
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
496
+ };
497
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
498
+ vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
499
+ vi.mocked(authLib.signAccessToken).mockReturnValue('access');
500
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
501
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(new Date());
502
+ vi.mocked(authRepo.rotateRefreshToken).mockResolvedValue(true);
503
+
504
+ // Act
505
+ await authService.refresh(validRefreshToken);
506
+
507
+ // Assert
508
+ expect(authRepo.deleteRefreshTokensByUserId).not.toHaveBeenCalled();
509
+ expect(sessionRepository.deleteAllUserSessions).not.toHaveBeenCalled();
257
510
  });
258
511
  });
259
512
 
260
513
  describe('logout', () => {
261
- it('should delete refresh token and matching session', async () => {
514
+ it('should delete refresh token and its linked session', async () => {
262
515
  // Arrange
263
516
  const refreshToken = 'valid-refresh-token';
264
- const tokenCreatedAt = new Date('2024-06-01T12:00:00Z');
265
517
  const storedToken = {
266
518
  ...testRefreshToken,
267
519
  token: refreshToken,
268
- createdAt: tokenCreatedAt,
269
- };
270
- const matchingSession = {
271
- id: 'session-1',
272
- userId: testUsers.validUser.id,
273
- deviceInfo: 'test-agent',
274
- ipAddress: '127.0.0.1',
275
- lastActiveAt: new Date(),
276
- expiresAt: new Date(Date.now() + 86400000),
277
- createdAt: new Date('2024-06-01T12:00:00.100Z'), // within 5s of token
520
+ sessionId: 'session-1',
278
521
  };
279
522
 
280
523
  vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
281
524
  vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
282
- vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([matchingSession]);
283
525
  vi.mocked(sessionRepository.deleteSession).mockResolvedValue(undefined as never);
284
526
 
285
527
  // Act
@@ -288,46 +530,34 @@ describe('Auth Service', () => {
288
530
  // Assert
289
531
  expect(authRepo.findRefreshToken).toHaveBeenCalledWith(refreshToken);
290
532
  expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(refreshToken);
291
- expect(sessionRepository.getUserSessions).toHaveBeenCalledWith(storedToken.userId);
292
- expect(sessionRepository.deleteSession).toHaveBeenCalledWith(matchingSession.id);
533
+ expect(sessionRepository.deleteSession).toHaveBeenCalledWith('session-1');
293
534
  });
294
535
 
295
- it('should not delete session if no matching createdAt found', async () => {
296
- // Arrange
536
+ it('should not delete a session when the token has no sessionId', async () => {
537
+ // Arrange — testRefreshToken fixture has sessionId: null
297
538
  const refreshToken = 'valid-refresh-token';
298
- const storedToken = {
539
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue({
299
540
  ...testRefreshToken,
300
541
  token: refreshToken,
301
- createdAt: new Date('2024-06-01T12:00:00Z'),
302
- };
303
- const unmatchedSession = {
304
- id: 'session-2',
305
- userId: testUsers.validUser.id,
306
- deviceInfo: null,
307
- ipAddress: null,
308
- lastActiveAt: new Date(),
309
- expiresAt: new Date(Date.now() + 86400000),
310
- createdAt: new Date('2024-05-01T00:00:00Z'), // way off — no match
311
- };
312
-
313
- vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
542
+ });
314
543
  vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
315
- vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([unmatchedSession]);
316
544
 
317
545
  // Act
318
546
  await authService.logout(refreshToken);
319
547
 
320
548
  // Assert
549
+ expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(refreshToken);
321
550
  expect(sessionRepository.deleteSession).not.toHaveBeenCalled();
322
551
  });
323
552
 
324
- it('should not throw error if token deletion fails', async () => {
325
- // Arrange
326
- const refreshToken = 'invalid-refresh-token';
553
+ it('should propagate unexpected repository errors', async () => {
554
+ // "Token already deleted" is handled gracefully inside the repo (P2025);
555
+ // anything else is a real failure and must reach the global error handler.
556
+ const refreshToken = 'valid-refresh-token';
327
557
  vi.mocked(authRepo.findRefreshToken).mockRejectedValue(new Error('DB error'));
328
558
 
329
559
  // Act & Assert
330
- await expect(authService.logout(refreshToken)).resolves.not.toThrow();
560
+ await expect(authService.logout(refreshToken)).rejects.toThrow('DB error');
331
561
  });
332
562
  });
333
563