create-tigra 3.0.2 → 3.0.5
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/README.md +4 -0
- package/package.json +1 -1
- package/template/client/.env.example +19 -0
- package/template/client/Dockerfile +14 -4
- package/template/client/next.config.ts +7 -42
- package/template/client/package-lock.json +1896 -146
- package/template/client/package.json +3 -1
- package/template/client/scripts/next-dev.cjs +47 -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 +38 -0
- package/template/client/src/instrumentation.ts +42 -0
- package/template/client/src/lib/env.ts +13 -2
- package/template/client/src/middleware.ts +105 -18
- package/template/server/.env.example +22 -1
- package/template/server/.env.example.production +16 -1
- package/template/server/Dockerfile +16 -8
- package/template/server/package-lock.json +2136 -86
- package/template/server/package.json +6 -0
- package/template/server/src/app.ts +30 -11
- package/template/server/src/config/env.ts +23 -2
- package/template/server/src/config/rate-limit.config.ts +6 -0
- package/template/server/src/libs/logger.ts +15 -0
- package/template/server/src/libs/observability/sentry.ts +42 -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/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/__tests__/auth.service.test.ts +25 -41
- package/template/server/src/modules/auth/auth.repo.ts +0 -12
- package/template/server/src/modules/auth/auth.service.ts +29 -57
- 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
|
@@ -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
|
+
});
|
|
@@ -137,6 +137,9 @@ describe('Auth Service', () => {
|
|
|
137
137
|
|
|
138
138
|
// Act & Assert
|
|
139
139
|
await expect(authService.register(validRegisterInput)).rejects.toThrow(ConflictError);
|
|
140
|
+
await expect(authService.register(validRegisterInput)).rejects.toThrow(
|
|
141
|
+
'An account with this email was recently deleted and cannot be registered yet.',
|
|
142
|
+
);
|
|
140
143
|
expect(authRepo.createUser).not.toHaveBeenCalled();
|
|
141
144
|
});
|
|
142
145
|
});
|
|
@@ -193,12 +196,33 @@ describe('Auth Service', () => {
|
|
|
193
196
|
it('should throw UnauthorizedError if user not found', async () => {
|
|
194
197
|
// Arrange
|
|
195
198
|
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
196
|
-
vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(null);
|
|
197
199
|
|
|
198
200
|
// Act & Assert
|
|
199
201
|
await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
|
|
200
202
|
await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
|
|
203
|
+
expect(authRepo.findDeletedUserByEmail).not.toHaveBeenCalled();
|
|
204
|
+
expect(verifyPassword).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should not restore or authenticate a soft-deleted account with correct credentials', async () => {
|
|
208
|
+
// findUserByEmail excludes soft-deleted records, so login must stop before
|
|
209
|
+
// password verification, token creation, or session creation.
|
|
210
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
211
|
+
vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue({
|
|
212
|
+
...testUsers.validUser,
|
|
213
|
+
deletedAt: new Date('2024-02-01T00:00:00Z'),
|
|
214
|
+
isActive: false,
|
|
215
|
+
});
|
|
216
|
+
vi.mocked(verifyPassword).mockResolvedValue(true);
|
|
217
|
+
|
|
218
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
|
|
219
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
|
|
220
|
+
expect(authRepo.findDeletedUserByEmail).not.toHaveBeenCalled();
|
|
201
221
|
expect(verifyPassword).not.toHaveBeenCalled();
|
|
222
|
+
expect(authLib.signAccessToken).not.toHaveBeenCalled();
|
|
223
|
+
expect(authLib.generateRefreshToken).not.toHaveBeenCalled();
|
|
224
|
+
expect(sessionRepository.createSession).not.toHaveBeenCalled();
|
|
225
|
+
expect(authRepo.createRefreshToken).not.toHaveBeenCalled();
|
|
202
226
|
});
|
|
203
227
|
|
|
204
228
|
it('should throw ForbiddenError if account is not activated', async () => {
|
|
@@ -325,46 +349,6 @@ describe('Auth Service', () => {
|
|
|
325
349
|
});
|
|
326
350
|
});
|
|
327
351
|
|
|
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
|
-
|
|
368
352
|
describe('refresh', () => {
|
|
369
353
|
const validRefreshToken = 'valid-refresh-token';
|
|
370
354
|
|
|
@@ -13,18 +13,6 @@ export async function findDeletedUserByEmail(email: string): Promise<User | null
|
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export async function restoreUser(userId: string): Promise<void> {
|
|
17
|
-
await prisma.user.update({
|
|
18
|
-
where: { id: userId },
|
|
19
|
-
data: {
|
|
20
|
-
deletedAt: null,
|
|
21
|
-
isActive: true,
|
|
22
|
-
failedLoginAttempts: 0,
|
|
23
|
-
lockedUntil: null,
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
16
|
export async function findUserById(id: string): Promise<User | null> {
|
|
29
17
|
return prisma.user.findUnique({
|
|
30
18
|
where: { id, deletedAt: null },
|
|
@@ -157,11 +157,11 @@ export async function register(
|
|
|
157
157
|
throw new ConflictError('Email already registered', 'EMAIL_ALREADY_EXISTS');
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
//
|
|
160
|
+
// Keep the email reserved while the soft-deleted account is retained.
|
|
161
161
|
const deletedUser = await authRepo.findDeletedUserByEmail(input.email);
|
|
162
162
|
if (deletedUser) {
|
|
163
163
|
throw new ConflictError(
|
|
164
|
-
'An account with this email was recently deleted
|
|
164
|
+
'An account with this email was recently deleted and cannot be registered yet.',
|
|
165
165
|
'EMAIL_ALREADY_EXISTS',
|
|
166
166
|
);
|
|
167
167
|
}
|
|
@@ -219,68 +219,40 @@ export async function login(
|
|
|
219
219
|
deviceInfo?: string,
|
|
220
220
|
ipAddress?: string,
|
|
221
221
|
): Promise<AuthResult> {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
// If no active user found, check for a soft-deleted account that can be restored
|
|
222
|
+
const user = await authRepo.findUserByEmail(input.email);
|
|
225
223
|
if (!user) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
229
|
-
}
|
|
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
|
-
|
|
237
|
-
// Verify password before restoring — don't restore on wrong password
|
|
238
|
-
const validPassword = await verifyPassword(input.password, deletedUser.password);
|
|
239
|
-
if (!validPassword) {
|
|
240
|
-
await recordFailedLogin(input.email, ipAddress);
|
|
241
|
-
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Successful restore-login — clear the email+IP failure counter, same as
|
|
245
|
-
// the normal path.
|
|
246
|
-
await clearFailedLogins(input.email, ipAddress);
|
|
247
|
-
|
|
248
|
-
// Restore account: clears deletedAt, sets isActive = true, resets lockout
|
|
249
|
-
await authRepo.restoreUser(deletedUser.id);
|
|
250
|
-
user = { ...deletedUser, deletedAt: null, isActive: true, failedLoginAttempts: 0, lockedUntil: null };
|
|
251
|
-
} else {
|
|
252
|
-
// Normal login flow for active accounts
|
|
224
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
225
|
+
}
|
|
253
226
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
227
|
+
// Distinct error for inactive accounts so the client can show proper messaging
|
|
228
|
+
if (!user.isActive) {
|
|
229
|
+
throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');
|
|
230
|
+
}
|
|
258
231
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
232
|
+
// Check account lockout — scoped to this email+IP pair (Redis), so an
|
|
233
|
+
// attacker spamming bad passwords only locks out their OWN address and
|
|
234
|
+
// cannot remotely lock the real user out (lockout DoS).
|
|
235
|
+
if (await isLoginLocked(input.email, ipAddress)) {
|
|
236
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
237
|
+
}
|
|
265
238
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
239
|
+
// DB-level lock (legacy data or manual admin lock) is still honored.
|
|
240
|
+
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
|
241
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
242
|
+
}
|
|
270
243
|
|
|
271
|
-
|
|
244
|
+
const valid = await verifyPassword(input.password, user.password);
|
|
272
245
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
246
|
+
if (!valid) {
|
|
247
|
+
await recordFailedLogin(input.email, ipAddress);
|
|
248
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
249
|
+
}
|
|
277
250
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
251
|
+
// Successful login — clear the email+IP failure counter and any stale
|
|
252
|
+
// DB-level lockout state from before lockout moved to Redis.
|
|
253
|
+
await clearFailedLogins(input.email, ipAddress);
|
|
254
|
+
if (user.failedLoginAttempts > 0 || user.lockedUntil) {
|
|
255
|
+
await authRepo.resetFailedAttempts(user.id);
|
|
284
256
|
}
|
|
285
257
|
|
|
286
258
|
const accessToken = signAccessToken({
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files-route integration test — the load-bearing proof that the two-tier
|
|
3
|
+
* upload split (P0-2) closes the @fastify/static over-exposure.
|
|
4
|
+
*
|
|
5
|
+
* Drives the REAL Fastify app via `app.inject()` against the MySQL 8 + Redis
|
|
6
|
+
* booted by `src/test/integration.setup.ts`. The setup file points
|
|
7
|
+
* UPLOAD_PUBLIC_DIR / UPLOAD_PRIVATE_DIR at isolated temp dirs (exported via
|
|
8
|
+
* `containerInfo`) BEFORE env.ts is evaluated, so this test controls the files
|
|
9
|
+
* on disk and asserts exactly what is and isn't reachable.
|
|
10
|
+
*
|
|
11
|
+
* What this proves:
|
|
12
|
+
* 1. PUBLIC static still works WITHOUT auth (avatar URL shape unchanged).
|
|
13
|
+
* 2. EXPOSURE CLOSED — a file in the PRIVATE tier is NOT reachable via the
|
|
14
|
+
* static /uploads/... path (404).
|
|
15
|
+
* 3. The auth-gated route: 401 without a cookie, 200 + bytes with one, 206 +
|
|
16
|
+
* Content-Range for a Range request, 404 for a traversal attempt.
|
|
17
|
+
*
|
|
18
|
+
* Env-before-import: `@/app` is imported LAZILY inside `beforeAll`, after the
|
|
19
|
+
* setup file has populated process.env. See integration.setup.ts.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
22
|
+
import fs from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import type { FastifyInstance } from 'fastify';
|
|
25
|
+
import { containerInfo } from '@/test/integration.setup.js';
|
|
26
|
+
|
|
27
|
+
let app: FastifyInstance;
|
|
28
|
+
|
|
29
|
+
/** Pull a named cookie value out of a set-cookie header array. */
|
|
30
|
+
function extractCookie(setCookie: string[] | string | undefined, name: string): string | undefined {
|
|
31
|
+
const headers = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
|
32
|
+
for (const header of headers) {
|
|
33
|
+
const first = header.split(';')[0];
|
|
34
|
+
const eq = first.indexOf('=');
|
|
35
|
+
if (eq === -1) continue;
|
|
36
|
+
if (first.slice(0, eq).trim() === name) {
|
|
37
|
+
return first.slice(eq + 1).trim();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const filesUser = {
|
|
44
|
+
email: 'files-route@example.com',
|
|
45
|
+
password: 'Password123!',
|
|
46
|
+
firstName: 'Files',
|
|
47
|
+
lastName: 'Route',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let accessCookie: string;
|
|
51
|
+
let userId: string;
|
|
52
|
+
|
|
53
|
+
const PRIVATE_BODY = 'the-quick-brown-fox-private-bytes';
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
const { buildApp } = await import('@/app.js');
|
|
57
|
+
app = await buildApp();
|
|
58
|
+
await app.ready();
|
|
59
|
+
|
|
60
|
+
// Register + login to mint an authed cookie and learn the user id.
|
|
61
|
+
const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: filesUser });
|
|
62
|
+
expect(reg.statusCode).toBe(201);
|
|
63
|
+
userId = reg.json().data.user.id as string;
|
|
64
|
+
expect(userId).toBeTruthy();
|
|
65
|
+
|
|
66
|
+
const login = await app.inject({
|
|
67
|
+
method: 'POST',
|
|
68
|
+
url: '/api/v1/auth/login',
|
|
69
|
+
payload: { email: filesUser.email, password: filesUser.password },
|
|
70
|
+
});
|
|
71
|
+
expect(login.statusCode).toBe(200);
|
|
72
|
+
const cookie = extractCookie(login.headers['set-cookie'], 'access_token');
|
|
73
|
+
expect(cookie, 'login must set an access_token cookie').toBeTruthy();
|
|
74
|
+
accessCookie = cookie!;
|
|
75
|
+
|
|
76
|
+
// --- Seed fixtures directly on disk, in the temp tiers the app reads. ---
|
|
77
|
+
// PUBLIC avatar (served statically, no auth).
|
|
78
|
+
const publicAvatarDir = path.join(containerInfo.uploadPublicDir, 'users', userId, 'avatar');
|
|
79
|
+
await fs.mkdir(publicAvatarDir, { recursive: true });
|
|
80
|
+
await fs.writeFile(path.join(publicAvatarDir, 'x.webp'), Buffer.from('PUBLIC-AVATAR-BYTES'));
|
|
81
|
+
|
|
82
|
+
// PRIVATE file (auth-gated route only).
|
|
83
|
+
const privateUserDir = path.join(containerInfo.uploadPrivateDir, 'users', userId);
|
|
84
|
+
await fs.mkdir(privateUserDir, { recursive: true });
|
|
85
|
+
await fs.writeFile(path.join(privateUserDir, 'doc.txt'), Buffer.from(PRIVATE_BODY));
|
|
86
|
+
|
|
87
|
+
// A SECRET file in the private tier, addressable only via traversal from the
|
|
88
|
+
// static root — used to prove the static mount cannot reach the private tier.
|
|
89
|
+
await fs.writeFile(path.join(containerInfo.uploadPrivateDir, 'secret.txt'), Buffer.from('TOP-SECRET'));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterAll(async () => {
|
|
93
|
+
await app?.close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('files two-tier uploads (real containers)', () => {
|
|
97
|
+
it('PUBLIC: GET /uploads/users/{id}/avatar/x.webp WITHOUT auth → 200 (static still served)', async () => {
|
|
98
|
+
const res = await app.inject({ method: 'GET', url: `/uploads/users/${userId}/avatar/x.webp` });
|
|
99
|
+
expect(res.statusCode).toBe(200);
|
|
100
|
+
expect(res.body).toContain('PUBLIC-AVATAR-BYTES');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('EXPOSURE CLOSED: a PRIVATE-tier file is NOT reachable via /uploads/... (404)', async () => {
|
|
104
|
+
// Direct static path into the private tier does not exist under the public
|
|
105
|
+
// root, and traversal out of the public root is blocked by @fastify/static.
|
|
106
|
+
const direct = await app.inject({ method: 'GET', url: '/uploads/secret.txt' });
|
|
107
|
+
expect(direct.statusCode).toBe(404);
|
|
108
|
+
|
|
109
|
+
const traversal = await app.inject({
|
|
110
|
+
method: 'GET',
|
|
111
|
+
url: '/uploads/..%2f..%2fprivate%2fsecret.txt',
|
|
112
|
+
});
|
|
113
|
+
// @fastify/static rejects traversal (404/403); the one thing that must
|
|
114
|
+
// never happen is a 200 leaking the private bytes.
|
|
115
|
+
expect(traversal.statusCode).not.toBe(200);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('PRIVATE route WITHOUT auth → 401', async () => {
|
|
119
|
+
const res = await app.inject({ method: 'GET', url: '/api/v1/files/doc.txt' });
|
|
120
|
+
expect(res.statusCode).toBe(401);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('PRIVATE route WITH auth → 200 and the file bytes', async () => {
|
|
124
|
+
const res = await app.inject({
|
|
125
|
+
method: 'GET',
|
|
126
|
+
url: '/api/v1/files/doc.txt',
|
|
127
|
+
cookies: { access_token: accessCookie },
|
|
128
|
+
});
|
|
129
|
+
expect(res.statusCode).toBe(200);
|
|
130
|
+
expect(res.body).toBe(PRIVATE_BODY);
|
|
131
|
+
expect(res.headers['accept-ranges']).toBe('bytes');
|
|
132
|
+
expect(res.headers['cache-control']).toBe('private');
|
|
133
|
+
expect(res.headers['content-type']).toContain('text/plain');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('PRIVATE route with a Range header → 206 + correct Content-Range', async () => {
|
|
137
|
+
const res = await app.inject({
|
|
138
|
+
method: 'GET',
|
|
139
|
+
url: '/api/v1/files/doc.txt',
|
|
140
|
+
cookies: { access_token: accessCookie },
|
|
141
|
+
headers: { range: 'bytes=0-3' },
|
|
142
|
+
});
|
|
143
|
+
expect(res.statusCode).toBe(206);
|
|
144
|
+
expect(res.headers['content-range']).toBe(`bytes 0-3/${PRIVATE_BODY.length}`);
|
|
145
|
+
expect(res.headers['content-length']).toBe('4');
|
|
146
|
+
expect(res.body).toBe(PRIVATE_BODY.slice(0, 4));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('PRIVATE route traversal attempt → 404 (no existence leak, not 403)', async () => {
|
|
150
|
+
const res = await app.inject({
|
|
151
|
+
method: 'GET',
|
|
152
|
+
url: '/api/v1/files/..%2f..%2fsecret',
|
|
153
|
+
cookies: { access_token: accessCookie },
|
|
154
|
+
});
|
|
155
|
+
expect(res.statusCode).toBe(404);
|
|
156
|
+
});
|
|
157
|
+
});
|