@nathapp/nestjs-csrf 3.3.0

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 (72) hide show
  1. package/.eslintrc.js +8 -0
  2. package/.nax/features/nestjs-csrf/.nax-acceptance.test.ts +785 -0
  3. package/.turbo/turbo-build.log +4 -0
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +29 -0
  6. package/dist/constant/csrf.constants.d.ts +2 -0
  7. package/dist/constant/csrf.constants.js +6 -0
  8. package/dist/constant/csrf.constants.js.map +1 -0
  9. package/dist/constant/index.d.ts +1 -0
  10. package/dist/constant/index.js +7 -0
  11. package/dist/constant/index.js.map +1 -0
  12. package/dist/decorator/index.d.ts +1 -0
  13. package/dist/decorator/index.js +6 -0
  14. package/dist/decorator/index.js.map +1 -0
  15. package/dist/decorator/skip-csrf.decorator.d.ts +1 -0
  16. package/dist/decorator/skip-csrf.decorator.js +9 -0
  17. package/dist/decorator/skip-csrf.decorator.js.map +1 -0
  18. package/dist/guard/csrf.guard.d.ts +13 -0
  19. package/dist/guard/csrf.guard.js +85 -0
  20. package/dist/guard/csrf.guard.js.map +1 -0
  21. package/dist/guard/index.d.ts +1 -0
  22. package/dist/guard/index.js +6 -0
  23. package/dist/guard/index.js.map +1 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.js +16 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/interface/csrf-options.interface.d.ts +27 -0
  28. package/dist/interface/csrf-options.interface.js +3 -0
  29. package/dist/interface/csrf-options.interface.js.map +1 -0
  30. package/dist/interface/index.d.ts +1 -0
  31. package/dist/interface/index.js +3 -0
  32. package/dist/interface/index.js.map +1 -0
  33. package/dist/module/csrf.module.d.ts +15 -0
  34. package/dist/module/csrf.module.js +64 -0
  35. package/dist/module/csrf.module.js.map +1 -0
  36. package/dist/module/index.d.ts +1 -0
  37. package/dist/module/index.js +6 -0
  38. package/dist/module/index.js.map +1 -0
  39. package/dist/service/csrf-token.service.d.ts +4 -0
  40. package/dist/service/csrf-token.service.js +47 -0
  41. package/dist/service/csrf-token.service.js.map +1 -0
  42. package/dist/service/index.d.ts +2 -0
  43. package/dist/service/index.js +8 -0
  44. package/dist/service/index.js.map +1 -0
  45. package/dist/service/resolve-options.d.ts +2 -0
  46. package/dist/service/resolve-options.js +37 -0
  47. package/dist/service/resolve-options.js.map +1 -0
  48. package/lib/constant/csrf.constants.ts +3 -0
  49. package/lib/constant/index.ts +1 -0
  50. package/lib/decorator/index.ts +1 -0
  51. package/lib/decorator/skip-csrf.decorator.ts +6 -0
  52. package/lib/guard/csrf.guard.ts +98 -0
  53. package/lib/guard/index.ts +1 -0
  54. package/lib/index.ts +12 -0
  55. package/lib/interface/csrf-options.interface.ts +31 -0
  56. package/lib/interface/index.ts +7 -0
  57. package/lib/module/csrf.module.ts +74 -0
  58. package/lib/module/index.ts +1 -0
  59. package/lib/service/csrf-token.service.ts +34 -0
  60. package/lib/service/index.ts +2 -0
  61. package/lib/service/resolve-options.ts +47 -0
  62. package/package.json +55 -0
  63. package/test/csrf-token.service.behavior.spec.ts +110 -0
  64. package/test/csrf-token.service.spec.ts +18 -0
  65. package/test/csrf.constants.spec.ts +11 -0
  66. package/test/csrf.module.spec.ts +267 -0
  67. package/test/guard/csrf.guard.spec.ts +347 -0
  68. package/test/jest-nax.json +27 -0
  69. package/test/jest-test.json +28 -0
  70. package/test/resolve-csrf-options.spec.ts +128 -0
  71. package/test/skip-csrf.decorator.spec.ts +77 -0
  72. package/tsconfig.json +9 -0
@@ -0,0 +1,785 @@
1
+ import { describe, it, expect, beforeAll, afterAll, jest } from '@jest/globals';
2
+ import { Test } from '@nestjs/testing';
3
+ import {
4
+ Controller,
5
+ Get,
6
+ Post,
7
+ INestApplication,
8
+ HttpException,
9
+ } from '@nestjs/common';
10
+ import { Reflector } from '@nestjs/core';
11
+
12
+ import {
13
+ CsrfTokenService,
14
+ CsrfModule,
15
+ DefaultCsrfGuard,
16
+ CSRF_MODULE_OPTIONS,
17
+ SKIP_CSRF_METADATA_KEY,
18
+ SkipCsrf,
19
+ resolveCsrfOptions,
20
+ } from '../../../lib';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Shared helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function makeContext(
27
+ req: Record<string, unknown>,
28
+ res: Record<string, unknown>,
29
+ handler: object = {},
30
+ cls: object = {},
31
+ ) {
32
+ return {
33
+ switchToHttp: () => ({
34
+ getRequest: () => req,
35
+ getResponse: () => res,
36
+ }),
37
+ getHandler: () => handler,
38
+ getClass: () => cls,
39
+ } as any;
40
+ }
41
+
42
+ function buildGuard(
43
+ optionOverrides: Record<string, unknown> = {},
44
+ tokensOverride: Record<string, unknown> | null = null,
45
+ reflectorResultOverride: Record<string, unknown> | null = null,
46
+ ) {
47
+ const rawOptions = { secret: 'test-secret', ...optionOverrides };
48
+ const mockTokens = tokensOverride ?? {
49
+ mint: jest.fn().mockReturnValue('csrf-minted-value.fakesig1234'),
50
+ verify: jest.fn().mockReturnValue(false),
51
+ };
52
+ const mockReflector = reflectorResultOverride ?? {
53
+ getAllAndOverride: jest.fn().mockReturnValue(false),
54
+ };
55
+ return {
56
+ guard: new (DefaultCsrfGuard as any)(rawOptions, mockTokens, mockReflector),
57
+ tokens: mockTokens as any,
58
+ reflector: mockReflector as any,
59
+ };
60
+ }
61
+
62
+ // Integration test controller
63
+ @Controller()
64
+ class AppController {
65
+ @Get('page')
66
+ getPage() { return { ok: true }; }
67
+
68
+ @Post('action')
69
+ postAction() { return { ok: true }; }
70
+
71
+ @Post('skip')
72
+ @SkipCsrf()
73
+ skipAction() { return { ok: true }; }
74
+ }
75
+
76
+ function extractCsrfTokenFromSetCookie(setCookieHeader: string | string[] | undefined): string {
77
+ const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
78
+ if (!raw) return '';
79
+ const part = raw.split(';')[0]; // e.g. "csrf-token=abc.xyz"
80
+ const eqIdx = part.indexOf('=');
81
+ return eqIdx >= 0 ? part.slice(eqIdx + 1) : '';
82
+ }
83
+
84
+ function thrown403(err: unknown): boolean {
85
+ if (!err) return false;
86
+ if (typeof (err as HttpException).getStatus === 'function') {
87
+ return (err as HttpException).getStatus() === 403;
88
+ }
89
+ return (err as any)?.status === 403 || (err as any)?.statusCode === 403;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // AC-1: CsrfTokenService injectable from Test.createTestingModule
94
+ // ---------------------------------------------------------------------------
95
+ describe('AC-1: CsrfTokenService injectable from Test.createTestingModule', () => {
96
+ it('compiles module and resolves CsrfTokenService without throwing', async () => {
97
+ const moduleRef = await Test.createTestingModule({
98
+ providers: [CsrfTokenService],
99
+ }).compile();
100
+ const svc = moduleRef.get(CsrfTokenService);
101
+ expect(svc).toBeDefined();
102
+ expect(svc).toBeInstanceOf(CsrfTokenService);
103
+ });
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // AC-2: mint then verify(secret, token, token) returns true
108
+ // ---------------------------------------------------------------------------
109
+ describe('AC-2: CsrfTokenService.mint then verify(secret, token, token) returns true', () => {
110
+ it('verify returns true when the same token is used as both cookie and header', () => {
111
+ const svc = new CsrfTokenService();
112
+ const token = svc.mint('test-secret');
113
+ expect(typeof token).toBe('string');
114
+ expect(token.length).toBeGreaterThan(0);
115
+ const result = svc.verify('test-secret', token, token);
116
+ expect(result).toBe(true);
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // AC-3: verify rejects double-submit mismatch
122
+ // ---------------------------------------------------------------------------
123
+ describe('AC-3: verify(secret, token1, differentToken) returns false', () => {
124
+ it('returns false when header does not match cookie', () => {
125
+ const svc = new CsrfTokenService();
126
+ const token1 = svc.mint('test-secret');
127
+ const result = svc.verify('test-secret', token1, 'different-token');
128
+ expect(result).toBe(false);
129
+ });
130
+ });
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // AC-4: verify rejects tampered signature
134
+ // ---------------------------------------------------------------------------
135
+ describe('AC-4: verify(secret, tampered, tampered) returns false for altered signature', () => {
136
+ it('returns false when value is intact but signature is replaced', () => {
137
+ const svc = new CsrfTokenService();
138
+ const token = svc.mint('test-secret');
139
+ const [value] = token.split('.');
140
+ const tampered = `${value}.0000000000000000000000000000000000000000000000000000000000`;
141
+ const result = svc.verify('test-secret', tampered, tampered);
142
+ expect(result).toBe(false);
143
+ });
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // AC-5: verify rejects token minted with wrong secret
148
+ // ---------------------------------------------------------------------------
149
+ describe('AC-5: verify(secret-two, token-from-secret-one, same-token) returns false', () => {
150
+ it('returns false when the verifying secret differs from the minting secret', () => {
151
+ const svc = new CsrfTokenService();
152
+ const token = svc.mint('secret-one');
153
+ const result = svc.verify('secret-two', token, token);
154
+ expect(result).toBe(false);
155
+ });
156
+ });
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // AC-6: verify does not throw on empty strings
160
+ // ---------------------------------------------------------------------------
161
+ describe('AC-6: verify(secret, "", "") returns false without throwing', () => {
162
+ it('returns false for empty cookie and header values', () => {
163
+ const svc = new CsrfTokenService();
164
+ let result: boolean | undefined;
165
+ let threw = false;
166
+ try {
167
+ result = svc.verify('test-secret', '', '');
168
+ } catch {
169
+ threw = true;
170
+ }
171
+ expect(threw).toBe(false);
172
+ expect(result).toBe(false);
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // AC-7: verify does not throw on missing dot separator
178
+ // ---------------------------------------------------------------------------
179
+ describe('AC-7: verify(secret, "no-separator", "no-separator") returns false without throwing', () => {
180
+ it('returns false for a malformed token with no dot separator', () => {
181
+ const svc = new CsrfTokenService();
182
+ let result: boolean | undefined;
183
+ let threw = false;
184
+ try {
185
+ result = svc.verify('test-secret', 'no-separator', 'no-separator');
186
+ } catch {
187
+ threw = true;
188
+ }
189
+ expect(threw).toBe(false);
190
+ expect(result).toBe(false);
191
+ });
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // AC-8: resolveCsrfOptions default cookie.name
196
+ // ---------------------------------------------------------------------------
197
+ describe('AC-8: resolveCsrfOptions({ secret: "s" }).cookie.name === "csrf-token"', () => {
198
+ it('default cookie name is csrf-token', () => {
199
+ const result = resolveCsrfOptions({ secret: 's' });
200
+ expect(result.cookie.name).toBe('csrf-token');
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // AC-9: resolveCsrfOptions default headerName
206
+ // ---------------------------------------------------------------------------
207
+ describe('AC-9: resolveCsrfOptions({ secret: "s" }).headerName === "x-csrf-token"', () => {
208
+ it('default header name is x-csrf-token', () => {
209
+ const result = resolveCsrfOptions({ secret: 's' });
210
+ expect(result.headerName).toBe('x-csrf-token');
211
+ });
212
+ });
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // AC-10: resolveCsrfOptions default ignoreMethods
216
+ // ---------------------------------------------------------------------------
217
+ describe('AC-10: resolveCsrfOptions({ secret: "s" }).ignoreMethods deep-equals ["GET","HEAD","OPTIONS"]', () => {
218
+ it('default ignoreMethods is GET, HEAD, OPTIONS in that order', () => {
219
+ const result = resolveCsrfOptions({ secret: 's' });
220
+ expect(JSON.stringify(result.ignoreMethods)).toBe(JSON.stringify(['GET', 'HEAD', 'OPTIONS']));
221
+ });
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // AC-11: resolveCsrfOptions default cookie.sameSite
226
+ // ---------------------------------------------------------------------------
227
+ describe('AC-11: resolveCsrfOptions({ secret: "s" }).cookie.sameSite === "lax"', () => {
228
+ it('default sameSite is lax', () => {
229
+ const result = resolveCsrfOptions({ secret: 's' });
230
+ expect(result.cookie.sameSite).toBe('lax');
231
+ });
232
+ });
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // AC-12: resolveCsrfOptions default skipWhenBearer
236
+ // ---------------------------------------------------------------------------
237
+ describe('AC-12: resolveCsrfOptions({ secret: "s" }).skipWhenBearer === true', () => {
238
+ it('default skipWhenBearer is true', () => {
239
+ const result = resolveCsrfOptions({ secret: 's' });
240
+ expect(result.skipWhenBearer).toBe(true);
241
+ });
242
+ });
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // AC-13: resolveCsrfOptions default enabled
246
+ // ---------------------------------------------------------------------------
247
+ describe('AC-13: resolveCsrfOptions({ secret: "s" }).enabled === true', () => {
248
+ it('default enabled is true', () => {
249
+ const result = resolveCsrfOptions({ secret: 's' });
250
+ expect(result.enabled).toBe(true);
251
+ });
252
+ });
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // AC-14: CSRF_MODULE_OPTIONS and SKIP_CSRF_METADATA_KEY are distinct
256
+ // ---------------------------------------------------------------------------
257
+ describe('AC-14: CSRF_MODULE_OPTIONS !== SKIP_CSRF_METADATA_KEY', () => {
258
+ it('both tokens are truthy and not equal to each other', () => {
259
+ expect(CSRF_MODULE_OPTIONS).toBeTruthy();
260
+ expect(SKIP_CSRF_METADATA_KEY).toBeTruthy();
261
+ expect(CSRF_MODULE_OPTIONS).not.toBe(SKIP_CSRF_METADATA_KEY);
262
+ });
263
+ });
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // AC-15: @SkipCsrf() sets SKIP_CSRF_METADATA_KEY to true on the decorated method
267
+ // ---------------------------------------------------------------------------
268
+ describe('AC-15: @SkipCsrf() exposes SKIP_CSRF_METADATA_KEY === true via Reflector.get', () => {
269
+ it('Reflector.get returns true for the decorated handler', () => {
270
+ class TestService {
271
+ @SkipCsrf()
272
+ skipMethod() {}
273
+ }
274
+ const reflector = new Reflector();
275
+ const result = reflector.get(SKIP_CSRF_METADATA_KEY, TestService.prototype.skipMethod);
276
+ expect(result).toBe(true);
277
+ });
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // AC-16: DefaultCsrfGuard injectable with canActivate of arity 1
282
+ // ---------------------------------------------------------------------------
283
+ describe('AC-16: DefaultCsrfGuard resolvable from Test module with canActivate(context)', () => {
284
+ it('module compiles without throwing and guard.canActivate has length 1', async () => {
285
+ const moduleRef = await Test.createTestingModule({
286
+ providers: [
287
+ DefaultCsrfGuard,
288
+ CsrfTokenService,
289
+ { provide: CSRF_MODULE_OPTIONS, useValue: { secret: 'test-secret' } },
290
+ { provide: Reflector, useValue: new Reflector() },
291
+ ],
292
+ }).compile();
293
+ const guard = moduleRef.get(DefaultCsrfGuard);
294
+ expect(guard).toBeDefined();
295
+ expect(typeof guard.canActivate).toBe('function');
296
+ expect(guard.canActivate.length).toBe(1);
297
+ });
298
+ });
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // AC-17: GET with Express adapter (no req.raw) calls res.cookie with name and token
302
+ // ---------------------------------------------------------------------------
303
+ describe('AC-17: canActivate on safe GET (Express) calls res.cookie with cookie name and minted token', () => {
304
+ it('returns true and res.cookie called with csrf-token name and non-empty string value', () => {
305
+ const resCookie = jest.fn();
306
+ const req = { method: 'GET', raw: undefined, cookies: {}, headers: {} };
307
+ const res = { cookie: resCookie, setCookie: undefined };
308
+ const mintedValue = 'csrf-minted-express-token.validhmacsig';
309
+ const { guard } = buildGuard({}, {
310
+ mint: jest.fn().mockReturnValue(mintedValue),
311
+ verify: jest.fn().mockReturnValue(true),
312
+ });
313
+
314
+ const ctx = makeContext(req, res);
315
+ const result = guard.canActivate(ctx);
316
+
317
+ const resolvedOpts = resolveCsrfOptions({ secret: 'test-secret' });
318
+ expect(result).toBe(true);
319
+ expect((resCookie as any).mock.calls.length).toBeGreaterThanOrEqual(1);
320
+ expect((resCookie as any).mock.calls[0][0]).toBe(resolvedOpts.cookie.name);
321
+ expect(typeof (resCookie as any).mock.calls[0][1]).toBe('string');
322
+ expect(((resCookie as any).mock.calls[0][1] as string).length).toBeGreaterThan(0);
323
+ });
324
+ });
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // AC-18: GET with Fastify adapter (req.raw defined) calls res.setCookie with name and token
328
+ // ---------------------------------------------------------------------------
329
+ describe('AC-18: canActivate on safe GET (Fastify) calls res.setCookie with cookie name and minted token', () => {
330
+ it('returns true and res.setCookie called with csrf-token name and non-empty string value', () => {
331
+ const resSetCookie = jest.fn();
332
+ const req = { method: 'GET', raw: {}, cookies: {}, headers: {} };
333
+ const res = { setCookie: resSetCookie, cookie: undefined };
334
+ const mintedValue = 'csrf-minted-fastify-token.validhmacsig';
335
+ const { guard } = buildGuard({}, {
336
+ mint: jest.fn().mockReturnValue(mintedValue),
337
+ verify: jest.fn().mockReturnValue(true),
338
+ });
339
+
340
+ const ctx = makeContext(req, res);
341
+ const result = guard.canActivate(ctx);
342
+
343
+ const resolvedOpts = resolveCsrfOptions({ secret: 'test-secret' });
344
+ expect(result).toBe(true);
345
+ expect((resSetCookie as any).mock.calls.length).toBeGreaterThanOrEqual(1);
346
+ expect((resSetCookie as any).mock.calls[0][0]).toBe(resolvedOpts.cookie.name);
347
+ expect(typeof (resSetCookie as any).mock.calls[0][1]).toBe('string');
348
+ expect(((resSetCookie as any).mock.calls[0][1] as string).length).toBeGreaterThan(0);
349
+ });
350
+ });
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // AC-19: POST with SKIP_CSRF_METADATA_KEY=true returns true without calling verify or issuing cookie
354
+ // ---------------------------------------------------------------------------
355
+ describe('AC-19: canActivate returns true when reflector returns true for SKIP_CSRF_METADATA_KEY on handler', () => {
356
+ it('returns true without calling tokens.verify or res.cookie', () => {
357
+ const verifyMock = jest.fn().mockReturnValue(false);
358
+ const mintMock = jest.fn().mockReturnValue('any-token.sig');
359
+ const resCookie = jest.fn();
360
+ const { guard } = buildGuard(
361
+ {},
362
+ { mint: mintMock, verify: verifyMock },
363
+ { getAllAndOverride: jest.fn().mockReturnValue(true) },
364
+ );
365
+
366
+ const req = { method: 'POST', cookies: {}, headers: {}, raw: undefined };
367
+ const res = { cookie: resCookie };
368
+ const ctx = makeContext(req, res);
369
+ const result = guard.canActivate(ctx);
370
+
371
+ expect(result).toBe(true);
372
+ expect((verifyMock as any).mock.calls.length).toBe(0);
373
+ expect((resCookie as any).mock.calls.length).toBe(0);
374
+ });
375
+ });
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // AC-20: POST with Bearer auth and skipWhenBearer=true returns true without calling verify
379
+ // ---------------------------------------------------------------------------
380
+ describe('AC-20: canActivate returns true for Bearer authorization when skipWhenBearer is true', () => {
381
+ it('returns true and tokens.verify is not called', () => {
382
+ const verifyMock = jest.fn().mockReturnValue(false);
383
+ const { guard } = buildGuard(
384
+ { skipWhenBearer: true },
385
+ {
386
+ mint: jest.fn().mockReturnValue('any.token'),
387
+ verify: verifyMock,
388
+ },
389
+ );
390
+
391
+ const req = {
392
+ method: 'POST',
393
+ headers: { authorization: 'Bearer abc' },
394
+ cookies: {},
395
+ raw: undefined,
396
+ };
397
+ const res = { cookie: jest.fn() };
398
+ const ctx = makeContext(req, res);
399
+ const result = guard.canActivate(ctx);
400
+
401
+ expect(result).toBe(true);
402
+ expect((verifyMock as any).mock.calls.length).toBe(0);
403
+ });
404
+ });
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // AC-21: POST with matching cookie+header and verify returning true → canActivate returns true
408
+ // ---------------------------------------------------------------------------
409
+ describe('AC-21: canActivate returns true when tokens.verify returns true for POST with matching tokens', () => {
410
+ it('returns true when cookie and header match and verify returns true', () => {
411
+ const verifyMock = jest.fn().mockReturnValue(true);
412
+ const { guard } = buildGuard({}, {
413
+ mint: jest.fn().mockReturnValue('any.token'),
414
+ verify: verifyMock,
415
+ });
416
+
417
+ const req = {
418
+ method: 'POST',
419
+ cookies: { 'csrf-token': 'validToken123' },
420
+ headers: { 'x-csrf-token': 'validToken123' },
421
+ raw: undefined,
422
+ };
423
+ const res = { cookie: jest.fn() };
424
+ const ctx = makeContext(req, res);
425
+ const result = guard.canActivate(ctx);
426
+
427
+ expect(result).toBe(true);
428
+ });
429
+ });
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // AC-22: verify is invoked with the cookie value and header value from the request
433
+ // ---------------------------------------------------------------------------
434
+ describe('AC-22: tokens.verify mock is called with the cookie and header values on POST', () => {
435
+ it('verify.mock.calls contains both cookie value and header value', () => {
436
+ const verifyMock = jest.fn().mockReturnValue(true);
437
+ const { guard } = buildGuard({}, {
438
+ mint: jest.fn().mockReturnValue('any.token'),
439
+ verify: verifyMock,
440
+ });
441
+
442
+ const cookieName = resolveCsrfOptions({ secret: 'test-secret' }).cookie.name;
443
+ const req = {
444
+ method: 'POST',
445
+ cookies: { [cookieName]: 'validToken123' },
446
+ headers: { 'x-csrf-token': 'validToken123' },
447
+ raw: undefined,
448
+ };
449
+ const res = { cookie: jest.fn() };
450
+ const ctx = makeContext(req, res);
451
+ guard.canActivate(ctx);
452
+
453
+ expect((verifyMock as any).mock.calls.length).toBeGreaterThanOrEqual(1);
454
+ const callArgs = (verifyMock as any).mock.calls[0] as string[];
455
+ // Both the cookie value and the header value must appear in the call arguments
456
+ const tokenOccurrences = callArgs.filter((a) => a === 'validToken123');
457
+ expect(tokenOccurrences.length).toBeGreaterThanOrEqual(2);
458
+ });
459
+ });
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // AC-23: POST with cookie present but no x-csrf-token header → throws 403
463
+ // ---------------------------------------------------------------------------
464
+ describe('AC-23: canActivate throws 403 when cookie is present but x-csrf-token header is absent', () => {
465
+ it('throws an exception with statusCode 403', () => {
466
+ const { guard } = buildGuard({}, {
467
+ mint: jest.fn().mockReturnValue('any.token'),
468
+ verify: jest.fn().mockReturnValue(false),
469
+ });
470
+
471
+ const req = {
472
+ method: 'POST',
473
+ cookies: { 'csrf-token': 'someToken' },
474
+ headers: {},
475
+ raw: undefined,
476
+ };
477
+ const res = { cookie: jest.fn() };
478
+ const ctx = makeContext(req, res);
479
+
480
+ let thrown: unknown;
481
+ try {
482
+ guard.canActivate(ctx);
483
+ } catch (err) {
484
+ thrown = err;
485
+ }
486
+ expect(thrown).toBeDefined();
487
+ expect(thrown403(thrown)).toBe(true);
488
+ });
489
+ });
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // AC-24: POST with mismatched cookie and x-csrf-token → throws 403
493
+ // ---------------------------------------------------------------------------
494
+ describe('AC-24: canActivate throws 403 when cookie value and x-csrf-token header value differ', () => {
495
+ it('throws an exception with statusCode 403', () => {
496
+ const { guard } = buildGuard({}, {
497
+ mint: jest.fn().mockReturnValue('any.token'),
498
+ verify: jest.fn().mockReturnValue(false),
499
+ });
500
+
501
+ const req = {
502
+ method: 'POST',
503
+ cookies: { 'csrf-token': 'tokenA' },
504
+ headers: { 'x-csrf-token': 'tokenB' },
505
+ raw: undefined,
506
+ };
507
+ const res = { cookie: jest.fn() };
508
+ const ctx = makeContext(req, res);
509
+
510
+ let thrown: unknown;
511
+ try {
512
+ guard.canActivate(ctx);
513
+ } catch (err) {
514
+ thrown = err;
515
+ }
516
+ expect(thrown).toBeDefined();
517
+ expect(thrown403(thrown)).toBe(true);
518
+ });
519
+ });
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // AC-25: POST with no cookie but x-csrf-token header present → throws 403
523
+ // ---------------------------------------------------------------------------
524
+ describe('AC-25: canActivate throws 403 when cookie is absent but x-csrf-token header is present', () => {
525
+ it('throws an exception with statusCode 403', () => {
526
+ const { guard } = buildGuard({}, {
527
+ mint: jest.fn().mockReturnValue('any.token'),
528
+ verify: jest.fn().mockReturnValue(false),
529
+ });
530
+
531
+ const req = {
532
+ method: 'POST',
533
+ cookies: {},
534
+ headers: { 'x-csrf-token': 'someToken' },
535
+ raw: undefined,
536
+ };
537
+ const res = { cookie: jest.fn() };
538
+ const ctx = makeContext(req, res);
539
+
540
+ let thrown: unknown;
541
+ try {
542
+ guard.canActivate(ctx);
543
+ } catch (err) {
544
+ thrown = err;
545
+ }
546
+ expect(thrown).toBeDefined();
547
+ expect(thrown403(thrown)).toBe(true);
548
+ });
549
+ });
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // AC-26: POST with neither cookie nor x-csrf-token header → throws 403
553
+ // ---------------------------------------------------------------------------
554
+ describe('AC-26: canActivate throws 403 when both cookie and x-csrf-token header are absent', () => {
555
+ it('throws an exception with statusCode 403', () => {
556
+ const { guard } = buildGuard({}, {
557
+ mint: jest.fn().mockReturnValue('any.token'),
558
+ verify: jest.fn().mockReturnValue(false),
559
+ });
560
+
561
+ const req = {
562
+ method: 'POST',
563
+ cookies: undefined,
564
+ headers: {},
565
+ raw: undefined,
566
+ };
567
+ const res = { cookie: jest.fn() };
568
+ const ctx = makeContext(req, res);
569
+
570
+ let thrown: unknown;
571
+ try {
572
+ guard.canActivate(ctx);
573
+ } catch (err) {
574
+ thrown = err;
575
+ }
576
+ expect(thrown).toBeDefined();
577
+ expect(thrown403(thrown)).toBe(true);
578
+ });
579
+ });
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // AC-27: CsrfModule.register returns global DynamicModule
583
+ // ---------------------------------------------------------------------------
584
+ describe('AC-27: CsrfModule.register({ secret: "s" }) returns object with global === true', () => {
585
+ it('returned dynamic module has global: true', () => {
586
+ const result = CsrfModule.register({ secret: 's' });
587
+ expect((result as any).global).toBe(true);
588
+ });
589
+ });
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // AC-28: Integration — POST without CSRF tokens returns 403
593
+ // ---------------------------------------------------------------------------
594
+ describe('AC-28: CsrfModule.register app returns 403 for POST without CSRF cookie or header', () => {
595
+ let app: INestApplication;
596
+
597
+ beforeAll(async () => {
598
+ const moduleRef = await Test.createTestingModule({
599
+ imports: [CsrfModule.register({ secret: 's' })],
600
+ controllers: [AppController],
601
+ }).compile();
602
+ app = moduleRef.createNestApplication();
603
+ await app.init();
604
+ });
605
+
606
+ afterAll(async () => {
607
+ await app?.close();
608
+ });
609
+
610
+ it('POST /action with no CSRF tokens returns 403', async () => {
611
+ const supertest = require('supertest');
612
+ const res = await supertest(app.getHttpServer()).post('/action');
613
+ expect(res.statusCode).toBe(403);
614
+ });
615
+ });
616
+
617
+ // ---------------------------------------------------------------------------
618
+ // AC-29: Integration — GET returns 2xx and Set-Cookie with CSRF token
619
+ // ---------------------------------------------------------------------------
620
+ describe('AC-29: CsrfModule.register app returns 2xx and Set-Cookie on GET', () => {
621
+ let app: INestApplication;
622
+
623
+ beforeAll(async () => {
624
+ const moduleRef = await Test.createTestingModule({
625
+ imports: [CsrfModule.register({ secret: 's' })],
626
+ controllers: [AppController],
627
+ }).compile();
628
+ app = moduleRef.createNestApplication();
629
+ await app.init();
630
+ });
631
+
632
+ afterAll(async () => {
633
+ await app?.close();
634
+ });
635
+
636
+ it('GET /page returns 2xx and Set-Cookie header containing a CSRF token value', async () => {
637
+ const supertest = require('supertest');
638
+ const res = await supertest(app.getHttpServer()).get('/page');
639
+ expect(res.statusCode).toBeGreaterThanOrEqual(200);
640
+ expect(res.statusCode).toBeLessThan(300);
641
+ const setCookie: string | string[] | undefined = res.headers['set-cookie'];
642
+ const cookieStr = Array.isArray(setCookie) ? setCookie.join(', ') : (setCookie ?? '');
643
+ expect(cookieStr).toContain('csrf-token');
644
+ const tokenValue = extractCsrfTokenFromSetCookie(setCookie);
645
+ expect(tokenValue.length).toBeGreaterThan(0);
646
+ });
647
+ });
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // AC-30: Integration — full double-submit round-trip returns 2xx
651
+ // ---------------------------------------------------------------------------
652
+ describe('AC-30: CsrfModule.register app full round-trip: GET cookie, POST with cookie+header returns 2xx', () => {
653
+ let app: INestApplication;
654
+
655
+ beforeAll(async () => {
656
+ const moduleRef = await Test.createTestingModule({
657
+ imports: [CsrfModule.register({ secret: 's' })],
658
+ controllers: [AppController],
659
+ }).compile();
660
+ app = moduleRef.createNestApplication();
661
+ // Enable cookie parsing so the guard can read req.cookies on POST
662
+ const cookieParser = require('cookie-parser');
663
+ app.use(cookieParser());
664
+ await app.init();
665
+ });
666
+
667
+ afterAll(async () => {
668
+ await app?.close();
669
+ });
670
+
671
+ it('POST with valid CSRF cookie and matching x-csrf-token header returns 2xx', async () => {
672
+ const supertest = require('supertest');
673
+
674
+ // 1. GET to receive the CSRF cookie
675
+ const getRes = await supertest(app.getHttpServer()).get('/page');
676
+ expect(getRes.statusCode).toBeGreaterThanOrEqual(200);
677
+ const tokenValue = extractCsrfTokenFromSetCookie(getRes.headers['set-cookie']);
678
+ expect(tokenValue.length).toBeGreaterThan(0);
679
+
680
+ // 2. POST echoing back the same token in cookie + header
681
+ const postRes = await supertest(app.getHttpServer())
682
+ .post('/action')
683
+ .set('Cookie', `csrf-token=${tokenValue}`)
684
+ .set('x-csrf-token', tokenValue);
685
+ expect(postRes.statusCode).toBeGreaterThanOrEqual(200);
686
+ expect(postRes.statusCode).toBeLessThan(300);
687
+ });
688
+ });
689
+
690
+ // ---------------------------------------------------------------------------
691
+ // AC-31: Integration — enabled:false allows POST without CSRF tokens
692
+ // ---------------------------------------------------------------------------
693
+ describe('AC-31: CsrfModule.register({ enabled: false }) allows POST without CSRF tokens', () => {
694
+ let app: INestApplication;
695
+
696
+ beforeAll(async () => {
697
+ const moduleRef = await Test.createTestingModule({
698
+ imports: [CsrfModule.register({ secret: 's', enabled: false })],
699
+ controllers: [AppController],
700
+ }).compile();
701
+ app = moduleRef.createNestApplication();
702
+ await app.init();
703
+ });
704
+
705
+ afterAll(async () => {
706
+ await app?.close();
707
+ });
708
+
709
+ it('POST /action with no CSRF tokens returns 2xx when enabled is false', async () => {
710
+ const supertest = require('supertest');
711
+ const res = await supertest(app.getHttpServer()).post('/action');
712
+ expect(res.statusCode).toBeGreaterThanOrEqual(200);
713
+ expect(res.statusCode).toBeLessThan(300);
714
+ });
715
+ });
716
+
717
+ // ---------------------------------------------------------------------------
718
+ // AC-32: Integration — @SkipCsrf() decorated handler allows POST without CSRF
719
+ // ---------------------------------------------------------------------------
720
+ describe('AC-32: POST to @SkipCsrf() decorated handler returns 2xx without CSRF token', () => {
721
+ let app: INestApplication;
722
+
723
+ beforeAll(async () => {
724
+ const moduleRef = await Test.createTestingModule({
725
+ imports: [CsrfModule.register({ secret: 's' })],
726
+ controllers: [AppController],
727
+ }).compile();
728
+ app = moduleRef.createNestApplication();
729
+ await app.init();
730
+ });
731
+
732
+ afterAll(async () => {
733
+ await app?.close();
734
+ });
735
+
736
+ it('POST /skip with no CSRF tokens returns 2xx because handler is decorated with @SkipCsrf()', async () => {
737
+ const supertest = require('supertest');
738
+ const res = await supertest(app.getHttpServer()).post('/skip');
739
+ expect(res.statusCode).toBeGreaterThanOrEqual(200);
740
+ expect(res.statusCode).toBeLessThan(300);
741
+ });
742
+ });
743
+
744
+ // ---------------------------------------------------------------------------
745
+ // AC-33: Integration — registerAsync applies guard and returns 403 for tokenless POST
746
+ // ---------------------------------------------------------------------------
747
+ describe('AC-33: CsrfModule.registerAsync({ useFactory }) returns 403 for POST without CSRF tokens', () => {
748
+ let app: INestApplication;
749
+
750
+ beforeAll(async () => {
751
+ const moduleRef = await Test.createTestingModule({
752
+ imports: [CsrfModule.registerAsync({ useFactory: () => ({ secret: 's' }) })],
753
+ controllers: [AppController],
754
+ }).compile();
755
+ app = moduleRef.createNestApplication();
756
+ await app.init();
757
+ });
758
+
759
+ afterAll(async () => {
760
+ await app?.close();
761
+ });
762
+
763
+ it('POST /action without CSRF tokens returns 403', async () => {
764
+ const supertest = require('supertest');
765
+ const res = await supertest(app.getHttpServer()).post('/action');
766
+ expect(res.statusCode).toBe(403);
767
+ });
768
+ });
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // AC-34: CsrfModule.register without secret throws Error containing "secret"
772
+ // ---------------------------------------------------------------------------
773
+ describe('AC-34: CsrfModule.register({} as any) throws an Error with message containing "secret"', () => {
774
+ it('throws at registration time with a message containing "secret"', () => {
775
+ let thrownError: Error | undefined;
776
+ try {
777
+ CsrfModule.register({} as any);
778
+ } catch (err: any) {
779
+ thrownError = err;
780
+ }
781
+ expect(thrownError).toBeDefined();
782
+ expect(thrownError).toBeInstanceOf(Error);
783
+ expect(thrownError!.message).toMatch(/secret/i);
784
+ });
785
+ });