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
@@ -1,127 +1,128 @@
1
- import type { FastifyRequest, FastifyReply } from 'fastify';
2
- import { successResponse } from '@shared/responses/successResponse.js';
3
- import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
4
- import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
5
- import type {
6
- RegisterInput,
7
- LoginInput,
8
- ForgotPasswordInput,
9
- ResetPasswordInput,
10
- } from './auth.schemas.js';
11
- import * as authService from './auth.service.js';
12
-
13
- export async function register(
14
- request: FastifyRequest<{ Body: RegisterInput }>,
15
- reply: FastifyReply,
16
- ): Promise<void> {
17
- const deviceInfo = request.headers['user-agent'];
18
- const ipAddress = request.ip;
19
-
20
- const result = await authService.register(request.body, deviceInfo, ipAddress);
21
-
22
- if (result.requiresVerification) {
23
- reply.status(201).send(
24
- successResponse('Registration successful. Please verify your account to continue.', { user: result.user }),
25
- );
26
- return;
27
- }
28
-
29
- setAuthCookies(reply, result.accessToken!, result.refreshToken!);
30
- reply.status(201).send(successResponse('User registered successfully', { user: result.user }));
31
- }
32
-
33
- export async function login(
34
- request: FastifyRequest<{ Body: LoginInput }>,
35
- reply: FastifyReply,
36
- ): Promise<void> {
37
- const deviceInfo = request.headers['user-agent'];
38
- const ipAddress = request.ip;
39
-
40
- const result = await authService.login(request.body, deviceInfo, ipAddress);
41
-
42
- setAuthCookies(reply, result.accessToken, result.refreshToken);
43
- reply.send(successResponse('Logged in successfully', { user: result.user }));
44
- }
45
-
46
- export async function refresh(
47
- request: FastifyRequest,
48
- reply: FastifyReply,
49
- ): Promise<void> {
50
- const refreshToken = request.cookies.refresh_token;
51
- if (!refreshToken) {
52
- clearAuthCookies(reply);
53
- throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
54
- }
55
-
56
- try {
57
- const tokens = await authService.refresh(refreshToken);
58
- setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
59
- reply.send(successResponse('Token refreshed successfully', null));
60
- } catch (error) {
61
- // Session is definitively dead (refresh token revoked, user gone, inactive).
62
- // Clear all auth cookies so the browser stops replaying them — otherwise the
63
- // client keeps retrying refresh and middleware keeps redirecting, producing
64
- // an infinite loop that trips the rate limiter.
65
- if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
66
- clearAuthCookies(reply);
67
- }
68
- throw error;
69
- }
70
- }
71
-
72
- export async function logout(
73
- request: FastifyRequest,
74
- reply: FastifyReply,
75
- ): Promise<void> {
76
- const refreshToken = request.cookies.refresh_token;
77
- if (refreshToken) {
78
- await authService.logout(refreshToken);
79
- }
80
-
81
- clearAuthCookies(reply);
82
- reply.send(successResponse('Logged out successfully', null));
83
- }
84
-
85
- export async function me(
86
- request: FastifyRequest,
87
- reply: FastifyReply,
88
- ): Promise<void> {
89
- const user = await authService.getCurrentUser(request.user.userId);
90
- reply.send(successResponse('Current user retrieved', user));
91
- }
92
-
93
- export async function getSessions(
94
- request: FastifyRequest,
95
- reply: FastifyReply,
96
- ): Promise<void> {
97
- const sessions = await authService.getUserSessions(request.user.userId);
98
- reply.send(successResponse('User sessions retrieved', sessions));
99
- }
100
-
101
- export async function logoutAllSessions(
102
- request: FastifyRequest,
103
- reply: FastifyReply,
104
- ): Promise<void> {
105
- const count = await authService.logoutAllSessions(request.user.userId);
106
- clearAuthCookies(reply);
107
- reply.send(
108
- successResponse(`Successfully logged out from ${count} session(s)`, { count }),
109
- );
110
- }
111
-
112
- export async function forgotPassword(
113
- request: FastifyRequest<{ Body: ForgotPasswordInput }>,
114
- reply: FastifyReply,
115
- ): Promise<void> {
116
- await authService.forgotPassword(request.body.email);
117
- // Always return success to prevent email enumeration
118
- reply.send(successResponse('If an account exists with that email, a reset link has been sent', null));
119
- }
120
-
121
- export async function resetPassword(
122
- request: FastifyRequest<{ Body: ResetPasswordInput }>,
123
- reply: FastifyReply,
124
- ): Promise<void> {
125
- await authService.resetPassword(request.body.token, request.body.newPassword);
126
- reply.send(successResponse('Password reset successfully', null));
127
- }
1
+ import type { FastifyRequest, FastifyReply } from 'fastify';
2
+ import { successResponse } from '@shared/responses/successResponse.js';
3
+ import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
4
+ import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
5
+ import { getClientIp } from '@libs/client-ip.js';
6
+ import type {
7
+ RegisterInput,
8
+ LoginInput,
9
+ ForgotPasswordInput,
10
+ ResetPasswordInput,
11
+ } from './auth.schemas.js';
12
+ import * as authService from './auth.service.js';
13
+
14
+ export async function register(
15
+ request: FastifyRequest<{ Body: RegisterInput }>,
16
+ reply: FastifyReply,
17
+ ): Promise<void> {
18
+ const deviceInfo = request.headers['user-agent'];
19
+ const ipAddress = getClientIp(request);
20
+
21
+ const result = await authService.register(request.body, deviceInfo, ipAddress);
22
+
23
+ if (result.requiresVerification) {
24
+ reply.status(201).send(
25
+ successResponse('Registration successful. Please verify your account to continue.', { user: result.user }),
26
+ );
27
+ return;
28
+ }
29
+
30
+ setAuthCookies(reply, result.accessToken!, result.refreshToken!);
31
+ reply.status(201).send(successResponse('User registered successfully', { user: result.user }));
32
+ }
33
+
34
+ export async function login(
35
+ request: FastifyRequest<{ Body: LoginInput }>,
36
+ reply: FastifyReply,
37
+ ): Promise<void> {
38
+ const deviceInfo = request.headers['user-agent'];
39
+ const ipAddress = getClientIp(request);
40
+
41
+ const result = await authService.login(request.body, deviceInfo, ipAddress);
42
+
43
+ setAuthCookies(reply, result.accessToken, result.refreshToken);
44
+ reply.send(successResponse('Logged in successfully', { user: result.user }));
45
+ }
46
+
47
+ export async function refresh(
48
+ request: FastifyRequest,
49
+ reply: FastifyReply,
50
+ ): Promise<void> {
51
+ const refreshToken = request.cookies.refresh_token;
52
+ if (!refreshToken) {
53
+ clearAuthCookies(reply);
54
+ throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
55
+ }
56
+
57
+ try {
58
+ const tokens = await authService.refresh(refreshToken);
59
+ setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
60
+ reply.send(successResponse('Token refreshed successfully', null));
61
+ } catch (error) {
62
+ // Session is definitively dead (refresh token revoked, user gone, inactive).
63
+ // Clear all auth cookies so the browser stops replaying them — otherwise the
64
+ // client keeps retrying refresh and middleware keeps redirecting, producing
65
+ // an infinite loop that trips the rate limiter.
66
+ if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
67
+ clearAuthCookies(reply);
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ export async function logout(
74
+ request: FastifyRequest,
75
+ reply: FastifyReply,
76
+ ): Promise<void> {
77
+ const refreshToken = request.cookies.refresh_token;
78
+ if (refreshToken) {
79
+ await authService.logout(refreshToken);
80
+ }
81
+
82
+ clearAuthCookies(reply);
83
+ reply.send(successResponse('Logged out successfully', null));
84
+ }
85
+
86
+ export async function me(
87
+ request: FastifyRequest,
88
+ reply: FastifyReply,
89
+ ): Promise<void> {
90
+ const user = await authService.getCurrentUser(request.user.userId);
91
+ reply.send(successResponse('Current user retrieved', user));
92
+ }
93
+
94
+ export async function getSessions(
95
+ request: FastifyRequest,
96
+ reply: FastifyReply,
97
+ ): Promise<void> {
98
+ const sessions = await authService.getUserSessions(request.user.userId);
99
+ reply.send(successResponse('User sessions retrieved', sessions));
100
+ }
101
+
102
+ export async function logoutAllSessions(
103
+ request: FastifyRequest,
104
+ reply: FastifyReply,
105
+ ): Promise<void> {
106
+ const count = await authService.logoutAllSessions(request.user.userId);
107
+ clearAuthCookies(reply);
108
+ reply.send(
109
+ successResponse(`Successfully logged out from ${count} session(s)`, { count }),
110
+ );
111
+ }
112
+
113
+ export async function forgotPassword(
114
+ request: FastifyRequest<{ Body: ForgotPasswordInput }>,
115
+ reply: FastifyReply,
116
+ ): Promise<void> {
117
+ await authService.forgotPassword(request.body.email);
118
+ // Always return success to prevent email enumeration
119
+ reply.send(successResponse('If an account exists with that email, a reset link has been sent', null));
120
+ }
121
+
122
+ export async function resetPassword(
123
+ request: FastifyRequest<{ Body: ResetPasswordInput }>,
124
+ reply: FastifyReply,
125
+ ): Promise<void> {
126
+ await authService.resetPassword(request.body.token, request.body.newPassword);
127
+ reply.send(successResponse('Password reset successfully', null));
128
+ }
@@ -93,6 +93,7 @@ export async function deleteRefreshTokensByUserId(userId: string): Promise<void>
93
93
  });
94
94
  }
95
95
 
96
+ /** @deprecated Lockout moved to Redis email+IP keys in auth.service.ts; kept for back-compat, slated for removal. */
96
97
  export async function incrementFailedAttempts(userId: string): Promise<void> {
97
98
  await prisma.user.update({
98
99
  where: { id: userId },
@@ -102,6 +103,7 @@ export async function incrementFailedAttempts(userId: string): Promise<void> {
102
103
  });
103
104
  }
104
105
 
106
+ /** @deprecated Lockout moved to Redis email+IP keys in auth.service.ts; kept for back-compat, slated for removal. */
105
107
  export async function setAccountLock(userId: string, lockedUntil: Date): Promise<void> {
106
108
  await prisma.user.update({
107
109
  where: { id: userId },
@@ -21,6 +21,18 @@ const PASSWORD_RESET_TTL = 3600; // 1 hour in seconds
21
21
  const PASSWORD_RESET_PREFIX = 'pw-reset:';
22
22
  const PASSWORD_RESET_USER_PREFIX = 'pw-reset-user:';
23
23
 
24
+ // --- Account lockout (scoped to email + IP) ---
25
+ // Failed-login attempts are tracked in Redis keyed by the (email, IP) PAIR.
26
+ // Keying by email alone let anyone lock a victim out remotely by spamming bad
27
+ // passwords at the victim's email (lockout DoS). Scoped to email+IP, an
28
+ // attacker only ever locks out their own address; distributed attempts are
29
+ // still throttled by the per-IP rate limit on the login route.
30
+ // Redis failures fail OPEN (no lockout) — consistent with ip-block.ts:
31
+ // availability over lockout, and rate limiting still applies.
32
+ const LOGIN_FAIL_PREFIX = 'login-fail:';
33
+ const LOGIN_LOCK_PREFIX = 'login-lock:';
34
+ const LOGIN_FAIL_WINDOW_SECONDS = 60 * 60; // failed attempts expire after 1 hour
35
+
24
36
  // Account lockout configuration
25
37
  const LOCKOUT_THRESHOLDS = [
26
38
  { attempts: 5, durationMs: 15 * 60 * 1000 }, // 5 failures → 15 min
@@ -37,6 +49,55 @@ function getLockoutDuration(failedAttempts: number): number | null {
37
49
  return null;
38
50
  }
39
51
 
52
+ function lockoutKey(prefix: string, email: string, ipAddress?: string): string {
53
+ return `${prefix}${email.toLowerCase()}:${ipAddress ?? 'unknown'}`;
54
+ }
55
+
56
+ async function isLoginLocked(email: string, ipAddress?: string): Promise<boolean> {
57
+ try {
58
+ const locked = await getRedis().exists(lockoutKey(LOGIN_LOCK_PREFIX, email, ipAddress));
59
+ return locked === 1;
60
+ } catch {
61
+ return false; // fail open — Redis down must not block all logins
62
+ }
63
+ }
64
+
65
+ async function recordFailedLogin(email: string, ipAddress?: string): Promise<void> {
66
+ try {
67
+ const redis = getRedis();
68
+ const failKey = lockoutKey(LOGIN_FAIL_PREFIX, email, ipAddress);
69
+
70
+ const attempts = await redis.incr(failKey);
71
+ if (attempts === 1) {
72
+ await redis.expire(failKey, LOGIN_FAIL_WINDOW_SECONDS);
73
+ }
74
+
75
+ const lockDurationMs = getLockoutDuration(attempts);
76
+ if (lockDurationMs) {
77
+ await redis.set(
78
+ lockoutKey(LOGIN_LOCK_PREFIX, email, ipAddress),
79
+ '1',
80
+ 'EX',
81
+ Math.ceil(lockDurationMs / 1000),
82
+ );
83
+ }
84
+ } catch {
85
+ // Non-critical: don't break the login error path if tracking fails
86
+ logger.warn('[AUTH] Failed to record failed login attempt');
87
+ }
88
+ }
89
+
90
+ async function clearFailedLogins(email: string, ipAddress?: string): Promise<void> {
91
+ try {
92
+ await getRedis().del(
93
+ lockoutKey(LOGIN_FAIL_PREFIX, email, ipAddress),
94
+ lockoutKey(LOGIN_LOCK_PREFIX, email, ipAddress),
95
+ );
96
+ } catch {
97
+ // Non-critical: keys expire on their own
98
+ }
99
+ }
100
+
40
101
  interface SanitizedUser {
41
102
  id: string;
42
103
  email: string;
@@ -167,12 +228,23 @@ export async function login(
167
228
  throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
168
229
  }
169
230
 
231
+ // Same email+IP lockout as the normal path — the restore flow must not be
232
+ // a brute-force side door around the lockout.
233
+ if (await isLoginLocked(input.email, ipAddress)) {
234
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
235
+ }
236
+
170
237
  // Verify password before restoring — don't restore on wrong password
171
238
  const validPassword = await verifyPassword(input.password, deletedUser.password);
172
239
  if (!validPassword) {
240
+ await recordFailedLogin(input.email, ipAddress);
173
241
  throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
174
242
  }
175
243
 
244
+ // Successful restore-login — clear the email+IP failure counter, same as
245
+ // the normal path.
246
+ await clearFailedLogins(input.email, ipAddress);
247
+
176
248
  // Restore account: clears deletedAt, sets isActive = true, resets lockout
177
249
  await authRepo.restoreUser(deletedUser.id);
178
250
  user = { ...deletedUser, deletedAt: null, isActive: true, failedLoginAttempts: 0, lockedUntil: null };
@@ -184,7 +256,14 @@ export async function login(
184
256
  throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');
185
257
  }
186
258
 
187
- // Check account lockout
259
+ // Check account lockout — scoped to this email+IP pair (Redis), so an
260
+ // attacker spamming bad passwords only locks out their OWN address and
261
+ // cannot remotely lock the real user out (lockout DoS).
262
+ if (await isLoginLocked(input.email, ipAddress)) {
263
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
264
+ }
265
+
266
+ // DB-level lock (legacy data or manual admin lock) is still honored.
188
267
  if (user.lockedUntil && user.lockedUntil > new Date()) {
189
268
  throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
190
269
  }
@@ -192,20 +271,13 @@ export async function login(
192
271
  const valid = await verifyPassword(input.password, user.password);
193
272
 
194
273
  if (!valid) {
195
- // Increment failed attempts
196
- const newAttempts = user.failedLoginAttempts + 1;
197
- await authRepo.incrementFailedAttempts(user.id);
198
-
199
- // Check if we need to lock the account
200
- const lockDuration = getLockoutDuration(newAttempts);
201
- if (lockDuration) {
202
- await authRepo.setAccountLock(user.id, new Date(Date.now() + lockDuration));
203
- }
204
-
274
+ await recordFailedLogin(input.email, ipAddress);
205
275
  throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
206
276
  }
207
277
 
208
- // Successful login — reset failed attempts
278
+ // Successful login — clear the email+IP failure counter and any stale
279
+ // DB-level lockout state from before lockout moved to Redis.
280
+ await clearFailedLogins(input.email, ipAddress);
209
281
  if (user.failedLoginAttempts > 0 || user.lockedUntil) {
210
282
  await authRepo.resetFailedAttempts(user.id);
211
283
  }
@@ -274,6 +346,25 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
274
346
  });
275
347
 
276
348
  if (!rotated) {
349
+ // REUSE DETECTED: the token existed when we looked it up but was consumed
350
+ // by a concurrent request before we could rotate it — two parties presented
351
+ // the same single-use token. We cannot tell which one is the attacker, and
352
+ // whichever redeemed it first already holds a freshly rotated valid token.
353
+ // Revoke the entire token family: every refresh token and session for this
354
+ // user. Both legit client and attacker must re-authenticate.
355
+ //
356
+ // NOTE: this covers the *detectable* reuse path. Refresh tokens are opaque
357
+ // random UUIDs (no JWT claims), so a token that is not found in the DB at
358
+ // all (deleted in an earlier rotation) cannot be attributed to a user, and
359
+ // the initial not-found lookup above can only reject it. Full replay
360
+ // attribution would require tombstoning consumed tokens (e.g. a revokedAt
361
+ // column) instead of deleting them — a schema change out of scope here.
362
+ logger.warn(
363
+ { userId: user.id },
364
+ '[AUTH] Refresh token reuse detected — revoking all sessions and refresh tokens for user',
365
+ );
366
+ await authRepo.deleteRefreshTokensByUserId(user.id);
367
+ await sessionRepository.deleteAllUserSessions(user.id);
277
368
  throw new UnauthorizedError('Refresh token already used', 'INVALID_REFRESH_TOKEN');
278
369
  }
279
370
 
@@ -1,6 +1,10 @@
1
1
  import { vi } from 'vitest';
2
2
 
3
- // Mock environment variables for testing
3
+ // Mock environment variables for testing.
4
+ // This setup file runs BEFORE any test file is imported (vitest setupFiles),
5
+ // so these must form a COMPLETE valid env: src/config/env.ts validates on
6
+ // import and calls process.exit(1) on failure, killing the whole suite before
7
+ // a single test runs.
4
8
  process.env.NODE_ENV = 'test';
5
9
  process.env.DATABASE_URL = 'mysql://test:test@localhost:3306/test_db';
6
10
  process.env.REDIS_URL = 'redis://localhost:6379';
@@ -9,6 +13,9 @@ process.env.JWT_ACCESS_EXPIRY = '15m';
9
13
  process.env.JWT_REFRESH_EXPIRY = '7d';
10
14
  process.env.PORT = '3000';
11
15
  process.env.HOST = '0.0.0.0';
16
+ // REQUIRE_USER_VERIFICATION defaults to 'true', and env.ts exits when it is
17
+ // enabled without RESEND_API_KEY. Tests don't send email — disable it.
18
+ process.env.REQUIRE_USER_VERIFICATION = 'false';
12
19
 
13
20
  // Test user data
14
21
  export const testUsers = {
@@ -59,15 +66,28 @@ export const testUsers = {
59
66
  },
60
67
  };
61
68
 
62
- // Test refresh token data
69
+ // Test refresh token data — fields must match the Prisma RefreshToken model
70
+ // exactly (id, token, userId, sessionId, expiresAt, createdAt)
63
71
  export const testRefreshToken = {
64
72
  id: 'test-token-id-1',
65
73
  token: 'test-refresh-token-uuid',
66
74
  userId: testUsers.validUser.id,
75
+ sessionId: null,
67
76
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
68
77
  createdAt: new Date(),
69
78
  };
70
79
 
80
+ // Test session data — fields must match the Prisma Session model exactly
81
+ export const testSession = {
82
+ id: 'test-session-id-1',
83
+ userId: testUsers.validUser.id,
84
+ deviceInfo: null,
85
+ ipAddress: null,
86
+ lastActiveAt: new Date('2024-01-01T00:00:00Z'),
87
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
88
+ createdAt: new Date('2024-01-01T00:00:00Z'),
89
+ };
90
+
71
91
  // Reset all mocks before each test
72
92
  export const resetMocks = (): void => {
73
93
  vi.clearAllMocks();
@@ -1,43 +1,43 @@
1
- import { defineConfig } from 'vitest/config';
2
- import { resolve } from 'path';
3
-
4
- export default defineConfig({
5
- test: {
6
- globals: true,
7
- environment: 'node',
8
- coverage: {
9
- provider: 'v8',
10
- reporter: ['text', 'json', 'html', 'lcov'],
11
- exclude: [
12
- 'node_modules/**',
13
- 'dist/**',
14
- '**/*.config.ts',
15
- '**/*.config.js',
16
- '**/test/**',
17
- '**/__tests__/**',
18
- 'prisma/**',
19
- 'scripts/**',
20
- ],
21
- thresholds: {
22
- lines: 80,
23
- functions: 80,
24
- branches: 80,
25
- statements: 80,
26
- },
27
- },
28
- setupFiles: ['./src/test/setup.ts'],
29
- include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
30
- exclude: ['node_modules', 'dist'],
31
- testTimeout: 10000,
32
- hookTimeout: 10000,
33
- },
34
- resolve: {
35
- alias: {
36
- '@': resolve(__dirname, './src'),
37
- '@modules': resolve(__dirname, './src/modules'),
38
- '@libs': resolve(__dirname, './src/libs'),
39
- '@config': resolve(__dirname, './src/config'),
40
- '@shared': resolve(__dirname, './src/shared'),
41
- },
42
- },
43
- });
1
+ import { defineConfig } from 'vitest/config';
2
+ import { resolve } from 'path';
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ globals: true,
7
+ environment: 'node',
8
+ coverage: {
9
+ provider: 'v8',
10
+ reporter: ['text', 'json', 'html', 'lcov'],
11
+ exclude: [
12
+ 'node_modules/**',
13
+ 'dist/**',
14
+ '**/*.config.ts',
15
+ '**/*.config.js',
16
+ '**/test/**',
17
+ '**/__tests__/**',
18
+ 'prisma/**',
19
+ 'scripts/**',
20
+ ],
21
+ thresholds: {
22
+ lines: 80,
23
+ functions: 80,
24
+ branches: 80,
25
+ statements: 80,
26
+ },
27
+ },
28
+ setupFiles: ['./src/test/setup.ts'],
29
+ include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
30
+ exclude: ['node_modules', 'dist'],
31
+ testTimeout: 10000,
32
+ hookTimeout: 10000,
33
+ },
34
+ resolve: {
35
+ alias: {
36
+ '@': resolve(__dirname, './src'),
37
+ '@modules': resolve(__dirname, './src/modules'),
38
+ '@libs': resolve(__dirname, './src/libs'),
39
+ '@config': resolve(__dirname, './src/config'),
40
+ '@shared': resolve(__dirname, './src/shared'),
41
+ },
42
+ },
43
+ });