create-tigra 3.0.0 → 3.0.3

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 (44) hide show
  1. package/package.json +1 -1
  2. package/template/client/.env.example +19 -0
  3. package/template/client/Dockerfile +3 -3
  4. package/template/client/next.config.ts +7 -42
  5. package/template/client/package-lock.json +1896 -146
  6. package/template/client/package.json +2 -0
  7. package/template/client/src/app/layout.tsx +10 -3
  8. package/template/client/src/app/providers.tsx +15 -2
  9. package/template/client/src/instrumentation-client.ts +30 -0
  10. package/template/client/src/instrumentation.ts +38 -0
  11. package/template/client/src/lib/env.ts +13 -2
  12. package/template/client/src/middleware.ts +105 -18
  13. package/template/server/.env.example +269 -236
  14. package/template/server/.env.example.production +236 -208
  15. package/template/server/Dockerfile +7 -5
  16. package/template/server/docker-compose.yml +17 -0
  17. package/template/server/package-lock.json +2136 -86
  18. package/template/server/package.json +6 -0
  19. package/template/server/src/app.ts +335 -303
  20. package/template/server/src/config/env.ts +171 -143
  21. package/template/server/src/config/rate-limit.config.ts +6 -0
  22. package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
  23. package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
  24. package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
  25. package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
  26. package/template/server/src/libs/auth-path.ts +14 -0
  27. package/template/server/src/libs/client-ip.ts +77 -0
  28. package/template/server/src/libs/ip-block.ts +220 -212
  29. package/template/server/src/libs/logger.ts +15 -0
  30. package/template/server/src/libs/observability/sentry.ts +42 -0
  31. package/template/server/src/libs/query-counter.ts +59 -0
  32. package/template/server/src/libs/requestLogger.ts +8 -2
  33. package/template/server/src/libs/storage/file-storage.service.ts +144 -16
  34. package/template/server/src/libs/url-safety.ts +121 -0
  35. package/template/server/src/modules/admin/__tests__/admin.integration.test.ts +128 -0
  36. package/template/server/src/modules/auth/__tests__/auth.integration.test.ts +138 -0
  37. package/template/server/src/modules/auth/auth.controller.ts +128 -127
  38. package/template/server/src/modules/files/__tests__/files.integration.test.ts +157 -0
  39. package/template/server/src/modules/files/files.controller.ts +180 -0
  40. package/template/server/src/modules/files/files.routes.ts +46 -0
  41. package/template/server/src/server.ts +6 -0
  42. package/template/server/src/test/integration.setup.ts +170 -0
  43. package/template/server/vitest.config.ts +10 -1
  44. package/template/server/vitest.integration.config.ts +50 -0
@@ -1,5 +1,6 @@
1
1
  import type { FastifyRequest, FastifyReply } from 'fastify';
2
2
  import { logger } from '@libs/logger.js';
3
+ import { getClientIp } from '@libs/client-ip.js';
3
4
 
4
5
  const colors = {
5
6
  // Methods: blue=read, green=create, yellow=update, red=destroy, gray=meta
@@ -44,17 +45,22 @@ export function markRequestStart(request: FastifyRequest): void {
44
45
  /**
45
46
  * Log a single-line request/response summary (called in onResponse)
46
47
  *
47
- * Format: GET /api/v1/auth/me 200 OK (12ms)
48
+ * Format: GET /api/v1/auth/me 200 OK (12ms) [ip=203.0.113.7 reqId=<uuid>]
49
+ *
50
+ * The IP is the Cloudflare-/proxy-aware resolved client IP (getClientIp), so
51
+ * behind Cloudflare we log the CF-Connecting-IP, not the shared edge IP. reqId
52
+ * is request.id (inbound X-Request-Id or the minted UUID) for log↔Sentry tie-up.
48
53
  */
49
54
  export function logRequestLine(request: FastifyRequest, reply: FastifyReply): void {
50
55
  const duration = request.startTime ? Date.now() - request.startTime : 0;
51
56
  const statusCode = reply.statusCode;
52
57
  const statusMessage = reply.raw.statusMessage || '';
58
+ const clientIp = getClientIp(request);
53
59
 
54
60
  const methodColor = colors[request.method as keyof typeof colors] || colors.reset;
55
61
  const statusColor = getStatusColor(statusCode);
56
62
 
57
- const line = `${methodColor}${request.method.padEnd(7)}${colors.reset}${request.url} ${statusColor}${statusCode} ${statusMessage}${colors.reset} ${colors.dim}(${formatDuration(duration)})${colors.reset}`;
63
+ const line = `${methodColor}${request.method.padEnd(7)}${colors.reset}${request.url} ${statusColor}${statusCode} ${statusMessage}${colors.reset} ${colors.dim}(${formatDuration(duration)}) [ip=${clientIp} reqId=${request.id}]${colors.reset}`;
58
64
 
59
65
  if (statusCode >= 500) {
60
66
  logger.error(line);
@@ -2,11 +2,25 @@
2
2
  * File Storage Service
3
3
  *
4
4
  * Handles local file system operations for user-uploaded files.
5
- * Directory structure: uploads/users/{userId}/<media-type>/
5
+ *
6
+ * ── Two-tier storage (security: closes the @fastify/static over-exposure) ──────
7
+ * PUBLIC tier (UPLOAD_PUBLIC_DIR, default <cwd>/uploads/public):
8
+ * uploads/public/users/{userId}/<media-type>/ — avatars and other assets meant
9
+ * to be world-readable. THIS is the only directory @fastify/static serves at
10
+ * /uploads/, so a private file can never leak through the static mount.
11
+ * PRIVATE tier (UPLOAD_PRIVATE_DIR, default <cwd>/uploads/private):
12
+ * uploads/private/users/{userId}/ — sensitive files. Lives OUTSIDE the static
13
+ * root and is reachable ONLY through the auth-gated, owner-scoped streaming
14
+ * route GET /api/v1/files/:filename (see src/modules/files/).
15
+ *
16
+ * The public avatar URL shape is UNCHANGED — `/uploads/users/{id}/avatar/x.webp`
17
+ * — because the static mount serves UPLOAD_PUBLIC_DIR at prefix `/uploads/`, so
18
+ * the file just moves one level down on disk (under public/) with no URL churn.
6
19
  */
7
20
 
8
21
  import fs from 'fs/promises';
9
22
  import path from 'path';
23
+ import { env } from '@config/env.js';
10
24
  import { logger } from '@libs/logger.js';
11
25
  import { InternalError } from '@shared/errors/errors.js';
12
26
  import { generateAvatarFilename } from './filename-sanitizer.js';
@@ -15,28 +29,53 @@ import { generateAvatarFilename } from './filename-sanitizer.js';
15
29
  * File Storage Service
16
30
  *
17
31
  * Manages local file storage with per-user directories and SEO-friendly naming.
18
- * All user media lives under uploads/users/{userId}/ for easy per-user cleanup.
32
+ * All user media lives under {tier}/users/{userId}/ for easy per-user cleanup.
19
33
  */
20
34
  class FileStorageService {
21
- private readonly uploadDir: string;
22
- private readonly usersDir: string;
35
+ /** PUBLIC tier root — served as static files at /uploads/ (world-readable). */
36
+ private readonly publicDir: string;
37
+ /** PRIVATE tier root — NEVER served statically; auth-gated route only. */
38
+ private readonly privateDir: string;
39
+ /** {publicDir}/users — per-user public media. */
40
+ private readonly publicUsersDir: string;
41
+ /** {privateDir}/users — per-user private media. */
42
+ private readonly privateUsersDir: string;
23
43
 
24
44
  constructor() {
25
- // Base upload directory: server/uploads
26
- this.uploadDir = path.join(process.cwd(), 'uploads');
27
- // Users media directory: server/uploads/users
28
- this.usersDir = path.join(this.uploadDir, 'users');
45
+ // Env-driven base paths, optional with <cwd>/uploads/{public,private}
46
+ // defaults so a clean env still boots (see src/config/env.ts).
47
+ this.publicDir = env.UPLOAD_PUBLIC_DIR ?? path.join(process.cwd(), 'uploads', 'public');
48
+ this.privateDir = env.UPLOAD_PRIVATE_DIR ?? path.join(process.cwd(), 'uploads', 'private');
49
+ this.publicUsersDir = path.join(this.publicDir, 'users');
50
+ this.privateUsersDir = path.join(this.privateDir, 'users');
51
+ }
52
+
53
+ /** PUBLIC tier root — read by app.ts to scope the @fastify/static mount. */
54
+ getPublicDir(): string {
55
+ return this.publicDir;
56
+ }
57
+
58
+ /** PRIVATE tier root — read by the files route for path-containment checks. */
59
+ getPrivateDir(): string {
60
+ return this.privateDir;
29
61
  }
30
62
 
31
63
  /**
32
- * Gets the base directory for a user's media
64
+ * Gets the base PUBLIC directory for a user's media
33
65
  */
34
66
  private getUserDir(userId: string): string {
35
- return path.join(this.usersDir, userId);
67
+ return path.join(this.publicUsersDir, userId);
36
68
  }
37
69
 
38
70
  /**
39
- * Gets the avatar directory for a user
71
+ * Gets the PRIVATE per-user directory
72
+ */
73
+ private getUserPrivateDir(userId: string): string {
74
+ return path.join(this.privateUsersDir, userId);
75
+ }
76
+
77
+ /**
78
+ * Gets the avatar directory for a user (PUBLIC tier)
40
79
  */
41
80
  private getUserAvatarDir(userId: string): string {
42
81
  return path.join(this.getUserDir(userId), 'avatar');
@@ -189,6 +228,88 @@ class FileStorageService {
189
228
  return `/uploads/users/${userId}/avatar/${filename}`;
190
229
  }
191
230
 
231
+ /**
232
+ * Sanitizes a single-segment filename for safe disk storage.
233
+ *
234
+ * Strips every path component (returns only the basename) and rejects any
235
+ * remaining traversal/separator/null-byte chars, so the result can never
236
+ * escape the user's private directory. This is a storage-side guard; the
237
+ * files route applies its OWN validation + path-containment check on read.
238
+ *
239
+ * @throws InternalError on an empty or unusable name
240
+ */
241
+ private sanitizePrivateFilename(filename: string): string {
242
+ // Reject obvious traversal/separator/null-byte input up front.
243
+ if (!filename || filename.includes('\0') || filename.includes('/') || filename.includes('\\')) {
244
+ throw new InternalError('Invalid private filename', 'INVALID_FILENAME');
245
+ }
246
+ // Collapse to the basename and drop any leading dots so `..`/`.` can't slip
247
+ // through; keep a conservative allowlist (letters, digits, . _ -).
248
+ const base = path.basename(filename).replace(/^\.+/, '');
249
+ const cleaned = base.replace(/[^a-zA-Z0-9._-]/g, '');
250
+ if (!cleaned) {
251
+ throw new InternalError('Invalid private filename', 'INVALID_FILENAME');
252
+ }
253
+ return cleaned;
254
+ }
255
+
256
+ /**
257
+ * Saves a PRIVATE file for a user (PRIVATE tier — never served statically).
258
+ *
259
+ * Writes to UPLOAD_PRIVATE_DIR/users/{userId}/<sanitized-filename>. The file
260
+ * is reachable ONLY through the auth-gated, owner-scoped route
261
+ * GET /api/v1/files/:filename — there is no static mount over this tier.
262
+ *
263
+ * @param userId - User's unique ID (the owner; scopes the directory)
264
+ * @param filename - Desired filename (sanitized to a safe single segment)
265
+ * @param buffer - File contents
266
+ * @returns The sanitized filename and the owner-scoped absolute path
267
+ */
268
+ async savePrivateFile(
269
+ userId: string,
270
+ filename: string,
271
+ buffer: Buffer
272
+ ): Promise<{ filename: string; path: string }> {
273
+ try {
274
+ const safeName = this.sanitizePrivateFilename(filename);
275
+ const userDir = this.getUserPrivateDir(userId);
276
+ await this.ensureDirectoryExists(userDir);
277
+
278
+ const filePath = path.join(userDir, safeName);
279
+ await fs.writeFile(filePath, buffer);
280
+
281
+ logger.info({
282
+ msg: 'Private file saved successfully',
283
+ userId,
284
+ filename: safeName,
285
+ fileSize: buffer.length,
286
+ });
287
+
288
+ return { filename: safeName, path: filePath };
289
+ } catch (error) {
290
+ if (error instanceof InternalError) throw error;
291
+ logger.error({ err: error, msg: 'Failed to save private file', userId });
292
+ throw new InternalError('Failed to save private file', 'FILE_SAVE_FAILED');
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Resolves the absolute path of a PRIVATE file for a user.
298
+ *
299
+ * Owner-scoped by construction: the path is derived from the supplied userId
300
+ * (the AUTHENTICATED user at the call site) — never a client-supplied owner.
301
+ * Returns the path WITHOUT touching disk; the caller checks existence and
302
+ * re-asserts path containment before streaming.
303
+ *
304
+ * @param userId - User's unique ID (the owner)
305
+ * @param filename - File name within the user's private directory
306
+ * @returns Absolute file path under UPLOAD_PRIVATE_DIR/users/{userId}/
307
+ */
308
+ getPrivateFilePath(userId: string, filename: string): string {
309
+ const safeName = this.sanitizePrivateFilename(filename);
310
+ return path.join(this.getUserPrivateDir(userId), safeName);
311
+ }
312
+
192
313
  /**
193
314
  * Checks if a file exists
194
315
  *
@@ -239,14 +360,21 @@ class FileStorageService {
239
360
  /**
240
361
  * Initializes the upload directory structure
241
362
  *
242
- * Creates base uploads directory and users subdirectory if they don't exist.
243
- * Should be called at application startup.
363
+ * Creates BOTH tiers (public + private) and their per-user subdirectories if
364
+ * they don't exist. Should be called at application startup. Creating the
365
+ * public dir up front matters because @fastify/static is mounted on it.
244
366
  */
245
367
  async initialize(): Promise<void> {
246
368
  try {
247
- await this.ensureDirectoryExists(this.uploadDir);
248
- await this.ensureDirectoryExists(this.usersDir);
249
- logger.info({ msg: 'File storage initialized', uploadDir: this.uploadDir });
369
+ await this.ensureDirectoryExists(this.publicDir);
370
+ await this.ensureDirectoryExists(this.publicUsersDir);
371
+ await this.ensureDirectoryExists(this.privateDir);
372
+ await this.ensureDirectoryExists(this.privateUsersDir);
373
+ logger.info({
374
+ msg: 'File storage initialized',
375
+ publicDir: this.publicDir,
376
+ privateDir: this.privateDir,
377
+ });
250
378
  } catch (error) {
251
379
  logger.error({ err: error, msg: 'Failed to initialize file storage' });
252
380
  throw new InternalError('Failed to initialize file storage', 'STORAGE_INIT_FAILED');
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Admin role-gate integration test against REAL containers.
3
+ *
4
+ * Exercises the admin-gated route (`GET /api/v1/admin/stats`, behind
5
+ * `authenticate` + `authorize('ADMIN')`) at all three gates:
6
+ * - 401 unauthenticated
7
+ * - 403 as a normal logged-in USER
8
+ * - 200 as an ADMIN
9
+ *
10
+ * The register endpoint cannot mint admins, so we SEED an ADMIN User row
11
+ * directly via prisma against the container DB, hashing the password with the
12
+ * server's own `@libs/password` lib so the real login path verifies it.
13
+ *
14
+ * Env-before-import: `@/app`, `@/libs/prisma`, `@/libs/password` are all
15
+ * imported LAZILY inside `beforeAll`, after integration.setup.ts has populated
16
+ * process.env with the container URLs.
17
+ */
18
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
19
+ import type { FastifyInstance } from 'fastify';
20
+
21
+ let app: FastifyInstance;
22
+ let prisma: typeof import('@/libs/prisma.js')['prisma'];
23
+
24
+ function extractCookie(setCookie: string[] | string | undefined, name: string): string | undefined {
25
+ const headers = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
26
+ for (const header of headers) {
27
+ const first = header.split(';')[0];
28
+ const eq = first.indexOf('=');
29
+ if (eq === -1) continue;
30
+ if (first.slice(0, eq).trim() === name) {
31
+ return first.slice(eq + 1).trim();
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ async function loginAndGetAccessCookie(email: string, password: string): Promise<string> {
38
+ const res = await app.inject({
39
+ method: 'POST',
40
+ url: '/api/v1/auth/login',
41
+ payload: { email, password },
42
+ });
43
+ expect(res.statusCode, `login for ${email} should succeed`).toBe(200);
44
+ const cookie = extractCookie(res.headers['set-cookie'], 'access_token');
45
+ expect(cookie, `login for ${email} must set an access_token cookie`).toBeTruthy();
46
+ return cookie!;
47
+ }
48
+
49
+ const adminCreds = { email: 'admin-it@example.com', password: 'AdminPass123!' };
50
+ const userCreds = {
51
+ email: 'normal-it@example.com',
52
+ password: 'UserPass123!',
53
+ firstName: 'Normal',
54
+ lastName: 'User',
55
+ };
56
+
57
+ let adminCookie: string;
58
+ let userCookie: string;
59
+
60
+ beforeAll(async () => {
61
+ const [{ buildApp }, prismaMod, { hashPassword }] = await Promise.all([
62
+ import('@/app.js'),
63
+ import('@/libs/prisma.js'),
64
+ import('@/libs/password.js'),
65
+ ]);
66
+ prisma = prismaMod.prisma;
67
+
68
+ app = await buildApp();
69
+ await app.ready();
70
+
71
+ // Seed an ADMIN directly — the register endpoint only mints USER role.
72
+ await prisma.user.create({
73
+ data: {
74
+ email: adminCreds.email,
75
+ password: await hashPassword(adminCreds.password),
76
+ firstName: 'Admin',
77
+ lastName: 'It',
78
+ role: 'ADMIN',
79
+ isActive: true,
80
+ },
81
+ });
82
+
83
+ // Create the normal user via the real register endpoint.
84
+ const reg = await app.inject({
85
+ method: 'POST',
86
+ url: '/api/v1/auth/register',
87
+ payload: userCreds,
88
+ });
89
+ expect(reg.statusCode).toBe(201);
90
+
91
+ adminCookie = await loginAndGetAccessCookie(adminCreds.email, adminCreds.password);
92
+ userCookie = await loginAndGetAccessCookie(userCreds.email, userCreds.password);
93
+ });
94
+
95
+ afterAll(async () => {
96
+ await app?.close();
97
+ });
98
+
99
+ describe('admin role gate (real containers)', () => {
100
+ const ADMIN_ROUTE = '/api/v1/admin/stats';
101
+
102
+ it('rejects an UNAUTHENTICATED request (401)', async () => {
103
+ const res = await app.inject({ method: 'GET', url: ADMIN_ROUTE });
104
+ expect(res.statusCode).toBe(401);
105
+ });
106
+
107
+ it('rejects a normal logged-in USER (403)', async () => {
108
+ const res = await app.inject({
109
+ method: 'GET',
110
+ url: ADMIN_ROUTE,
111
+ cookies: { access_token: userCookie },
112
+ });
113
+ expect(res.statusCode).toBe(403);
114
+ });
115
+
116
+ it('allows an ADMIN (200)', async () => {
117
+ const res = await app.inject({
118
+ method: 'GET',
119
+ url: ADMIN_ROUTE,
120
+ cookies: { access_token: adminCookie },
121
+ });
122
+ expect(res.statusCode).toBe(200);
123
+ const body = res.json();
124
+ expect(body.success).toBe(true);
125
+ // Dashboard stats shape (see admin.controller.getDashboardStats).
126
+ expect(body.data).toHaveProperty('totalUsers');
127
+ });
128
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Money-path integration test — auth flow against REAL containers.
3
+ *
4
+ * No payments module exists in the scaffold, so the auth flow (register → login →
5
+ * authenticated read) is the money-adjacent critical path. This drives the REAL
6
+ * Fastify app via `app.inject()` against a real MySQL 8 + Redis booted by
7
+ * `src/test/integration.setup.ts`.
8
+ *
9
+ * Env-before-import: `@/app` (which pulls `env.ts` + prisma) is imported LAZILY
10
+ * inside `beforeAll`, AFTER the setup file has populated process.env with the
11
+ * container URLs. See integration.setup.ts for the full rationale.
12
+ */
13
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
14
+ import type { FastifyInstance } from 'fastify';
15
+
16
+ let app: FastifyInstance;
17
+
18
+ /**
19
+ * Pull the `access_token` cookie value out of a set-cookie header array.
20
+ * The login/register controllers set `access_token`, `refresh_token`, and a
21
+ * non-httpOnly `auth_session` indicator (see src/libs/cookies.ts).
22
+ */
23
+ function extractCookie(setCookie: string[] | string | undefined, name: string): string | undefined {
24
+ const headers = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
25
+ for (const header of headers) {
26
+ const first = header.split(';')[0];
27
+ const eq = first.indexOf('=');
28
+ if (eq === -1) continue;
29
+ if (first.slice(0, eq).trim() === name) {
30
+ return first.slice(eq + 1).trim();
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ const validUser = {
37
+ email: 'moneypath@example.com',
38
+ password: 'Password123!',
39
+ firstName: 'Money',
40
+ lastName: 'Path',
41
+ };
42
+
43
+ beforeAll(async () => {
44
+ // LAZY import — env is now populated by integration.setup.ts.
45
+ const { buildApp } = await import('@/app.js');
46
+ app = await buildApp();
47
+ await app.ready();
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await app?.close();
52
+ });
53
+
54
+ describe('auth money-path (real containers)', () => {
55
+ let accessCookie: string | undefined;
56
+
57
+ it('POST /api/v1/auth/register with a valid body succeeds (201) and returns the user', async () => {
58
+ const res = await app.inject({
59
+ method: 'POST',
60
+ url: '/api/v1/auth/register',
61
+ payload: validUser,
62
+ });
63
+
64
+ expect(res.statusCode).toBe(201);
65
+ const body = res.json();
66
+ expect(body.success).toBe(true);
67
+ expect(body.data.user.email).toBe(validUser.email);
68
+ expect(body.data.user.role).toBe('USER');
69
+ // Password must never be echoed back.
70
+ expect(body.data.user.password).toBeUndefined();
71
+ });
72
+
73
+ it('POST /api/v1/auth/register with an INVALID body is rejected (400, Zod validation)', async () => {
74
+ const res = await app.inject({
75
+ method: 'POST',
76
+ url: '/api/v1/auth/register',
77
+ payload: { email: 'not-an-email', password: 'short', firstName: 'A', lastName: '' },
78
+ });
79
+
80
+ // fastify-type-provider-zod surfaces body validation as a Fastify
81
+ // validation error → the app's error handler maps it to 400 BAD_REQUEST.
82
+ expect(res.statusCode).toBe(400);
83
+ });
84
+
85
+ it('POST /api/v1/auth/login with valid credentials succeeds and sets an access_token cookie', async () => {
86
+ const res = await app.inject({
87
+ method: 'POST',
88
+ url: '/api/v1/auth/login',
89
+ payload: { email: validUser.email, password: validUser.password },
90
+ });
91
+
92
+ expect(res.statusCode).toBe(200);
93
+ const body = res.json();
94
+ expect(body.success).toBe(true);
95
+ expect(body.data.user.email).toBe(validUser.email);
96
+
97
+ const setCookie = res.headers['set-cookie'];
98
+ accessCookie = extractCookie(setCookie, 'access_token');
99
+ expect(accessCookie, 'login must set an access_token cookie').toBeTruthy();
100
+ // The refresh cookie is also set (path-scoped to /api/v1/auth).
101
+ expect(extractCookie(setCookie, 'refresh_token')).toBeTruthy();
102
+ });
103
+
104
+ it('POST /api/v1/auth/login with a wrong password fails (401)', async () => {
105
+ const res = await app.inject({
106
+ method: 'POST',
107
+ url: '/api/v1/auth/login',
108
+ payload: { email: validUser.email, password: 'WrongPassword123!' },
109
+ });
110
+
111
+ expect(res.statusCode).toBe(401);
112
+ });
113
+
114
+ it('GET /api/v1/auth/me WITHOUT the cookie is rejected (401)', async () => {
115
+ const res = await app.inject({
116
+ method: 'GET',
117
+ url: '/api/v1/auth/me',
118
+ });
119
+
120
+ expect(res.statusCode).toBe(401);
121
+ });
122
+
123
+ it('GET /api/v1/auth/me WITH the access_token cookie returns 200 and the right user', async () => {
124
+ expect(accessCookie, 'login test must have captured the cookie first').toBeTruthy();
125
+
126
+ const res = await app.inject({
127
+ method: 'GET',
128
+ url: '/api/v1/auth/me',
129
+ cookies: { access_token: accessCookie! },
130
+ });
131
+
132
+ expect(res.statusCode).toBe(200);
133
+ const body = res.json();
134
+ expect(body.success).toBe(true);
135
+ expect(body.data.email).toBe(validUser.email);
136
+ expect(body.data.role).toBe('USER');
137
+ });
138
+ });