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.
- package/package.json +1 -1
- package/template/client/.env.example +19 -0
- package/template/client/Dockerfile +3 -3
- package/template/client/next.config.ts +7 -42
- package/template/client/package-lock.json +1896 -146
- package/template/client/package.json +2 -0
- package/template/client/src/app/layout.tsx +10 -3
- package/template/client/src/app/providers.tsx +15 -2
- package/template/client/src/instrumentation-client.ts +30 -0
- package/template/client/src/instrumentation.ts +38 -0
- package/template/client/src/lib/env.ts +13 -2
- package/template/client/src/middleware.ts +105 -18
- package/template/server/.env.example +269 -236
- package/template/server/.env.example.production +236 -208
- package/template/server/Dockerfile +7 -5
- package/template/server/docker-compose.yml +17 -0
- package/template/server/package-lock.json +2136 -86
- package/template/server/package.json +6 -0
- package/template/server/src/app.ts +335 -303
- package/template/server/src/config/env.ts +171 -143
- package/template/server/src/config/rate-limit.config.ts +6 -0
- package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
- package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
- package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
- package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
- package/template/server/src/libs/auth-path.ts +14 -0
- package/template/server/src/libs/client-ip.ts +77 -0
- package/template/server/src/libs/ip-block.ts +220 -212
- package/template/server/src/libs/logger.ts +15 -0
- package/template/server/src/libs/observability/sentry.ts +42 -0
- package/template/server/src/libs/query-counter.ts +59 -0
- package/template/server/src/libs/requestLogger.ts +8 -2
- package/template/server/src/libs/storage/file-storage.service.ts +144 -16
- package/template/server/src/libs/url-safety.ts +121 -0
- package/template/server/src/modules/admin/__tests__/admin.integration.test.ts +128 -0
- package/template/server/src/modules/auth/__tests__/auth.integration.test.ts +138 -0
- package/template/server/src/modules/auth/auth.controller.ts +128 -127
- package/template/server/src/modules/files/__tests__/files.integration.test.ts +157 -0
- package/template/server/src/modules/files/files.controller.ts +180 -0
- package/template/server/src/modules/files/files.routes.ts +46 -0
- package/template/server/src/server.ts +6 -0
- package/template/server/src/test/integration.setup.ts +170 -0
- package/template/server/vitest.config.ts +10 -1
- 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
|
-
*
|
|
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
|
|
32
|
+
* All user media lives under {tier}/users/{userId}/ for easy per-user cleanup.
|
|
19
33
|
*/
|
|
20
34
|
class FileStorageService {
|
|
21
|
-
|
|
22
|
-
private readonly
|
|
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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
this.
|
|
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.
|
|
67
|
+
return path.join(this.publicUsersDir, userId);
|
|
36
68
|
}
|
|
37
69
|
|
|
38
70
|
/**
|
|
39
|
-
* Gets the
|
|
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
|
|
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.
|
|
248
|
-
await this.ensureDirectoryExists(this.
|
|
249
|
-
|
|
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
|
+
});
|