@tumbaland/backend-core 1.44.0 → 1.45.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 (101) hide show
  1. package/package.json +5 -1
  2. package/.versionrc.json +0 -7
  3. package/__mocks__/uuid.js +0 -8
  4. package/jest.config.js +0 -24
  5. package/src/apiKeys/ApiKey.test.ts +0 -142
  6. package/src/apiKeys/ApiKey.ts +0 -102
  7. package/src/apiKeys/crypto.test.ts +0 -161
  8. package/src/apiKeys/crypto.ts +0 -163
  9. package/src/apiKeys/index.test.ts +0 -72
  10. package/src/apiKeys/index.ts +0 -35
  11. package/src/apiKeys/middleware.test.ts +0 -651
  12. package/src/apiKeys/middleware.ts +0 -341
  13. package/src/apiKeys/service.test.ts +0 -401
  14. package/src/apiKeys/service.ts +0 -168
  15. package/src/apiKeys/types.test.ts +0 -41
  16. package/src/apiKeys/types.ts +0 -124
  17. package/src/app/createBaseApp.test.ts +0 -109
  18. package/src/app/createBaseApp.ts +0 -102
  19. package/src/app/shutdown.test.ts +0 -129
  20. package/src/app/shutdown.ts +0 -81
  21. package/src/audit/AuditEvent.ts +0 -123
  22. package/src/audit/actor.test.ts +0 -95
  23. package/src/audit/actor.ts +0 -68
  24. package/src/audit/context.test.ts +0 -91
  25. package/src/audit/context.ts +0 -83
  26. package/src/audit/index.ts +0 -11
  27. package/src/audit/plugin.test.ts +0 -258
  28. package/src/audit/plugin.ts +0 -254
  29. package/src/audit/reads.test.ts +0 -164
  30. package/src/audit/reads.ts +0 -88
  31. package/src/audit/service.test.ts +0 -115
  32. package/src/audit/service.ts +0 -95
  33. package/src/auth/session.test.ts +0 -89
  34. package/src/auth/session.ts +0 -75
  35. package/src/config/env.test.ts +0 -28
  36. package/src/config/env.ts +0 -13
  37. package/src/database/connection.test.ts +0 -188
  38. package/src/database/connection.ts +0 -90
  39. package/src/entitlements/UsageMeter.ts +0 -49
  40. package/src/entitlements/client.test.ts +0 -200
  41. package/src/entitlements/client.ts +0 -179
  42. package/src/entitlements/definitions.test.ts +0 -161
  43. package/src/entitlements/definitions.ts +0 -268
  44. package/src/entitlements/index.ts +0 -42
  45. package/src/entitlements/middleware.test.ts +0 -196
  46. package/src/entitlements/middleware.ts +0 -150
  47. package/src/entitlements/reconcile.test.ts +0 -333
  48. package/src/entitlements/reconcile.ts +0 -384
  49. package/src/entitlements/types.ts +0 -21
  50. package/src/entitlements/usage.test.ts +0 -314
  51. package/src/entitlements/usage.ts +0 -223
  52. package/src/errors/HttpError.test.ts +0 -76
  53. package/src/errors/HttpError.ts +0 -91
  54. package/src/groups/client.test.ts +0 -215
  55. package/src/groups/client.ts +0 -182
  56. package/src/groups/index.ts +0 -7
  57. package/src/groups/membership.test.ts +0 -84
  58. package/src/groups/membership.ts +0 -133
  59. package/src/groups/subject.test.ts +0 -85
  60. package/src/groups/subject.ts +0 -50
  61. package/src/health/createHealthCheck.test.ts +0 -89
  62. package/src/health/createHealthCheck.ts +0 -67
  63. package/src/health/healthController.test.ts +0 -113
  64. package/src/health/healthController.ts +0 -56
  65. package/src/index.ts +0 -88
  66. package/src/logging/logger.test.ts +0 -91
  67. package/src/logging/logger.ts +0 -103
  68. package/src/metrics/index.test.ts +0 -116
  69. package/src/metrics/index.ts +0 -111
  70. package/src/middleware/authMiddleware.test.ts +0 -275
  71. package/src/middleware/authMiddleware.ts +0 -91
  72. package/src/middleware/corsMiddleware.test.ts +0 -135
  73. package/src/middleware/corsMiddleware.ts +0 -65
  74. package/src/middleware/errorHandler.test.ts +0 -188
  75. package/src/middleware/errorHandler.ts +0 -103
  76. package/src/middleware/internalServiceAuth.test.ts +0 -173
  77. package/src/middleware/internalServiceAuth.ts +0 -96
  78. package/src/middleware/requestLogger.test.ts +0 -81
  79. package/src/middleware/requestLogger.ts +0 -48
  80. package/src/middleware/security.test.ts +0 -45
  81. package/src/middleware/security.ts +0 -43
  82. package/src/middleware/validate.test.ts +0 -72
  83. package/src/middleware/validate.ts +0 -23
  84. package/src/oauth/index.ts +0 -29
  85. package/src/oauth/models.ts +0 -164
  86. package/src/oauth/service.test.ts +0 -432
  87. package/src/oauth/service.ts +0 -299
  88. package/src/oauth/tokens.test.ts +0 -146
  89. package/src/oauth/tokens.ts +0 -186
  90. package/src/testing/serviceTestSetup.ts +0 -17
  91. package/src/tracing/index.test.ts +0 -272
  92. package/src/tracing/index.ts +0 -110
  93. package/src/types/auth.ts +0 -45
  94. package/src/utils/correlation.test.ts +0 -47
  95. package/src/utils/correlation.ts +0 -22
  96. package/src/utils/permissionUtils.test.ts +0 -47
  97. package/src/utils/permissionUtils.ts +0 -68
  98. package/src/utils/response.test.ts +0 -64
  99. package/src/utils/response.ts +0 -60
  100. package/tsconfig.build.json +0 -7
  101. package/tsconfig.json +0 -23
@@ -1,173 +0,0 @@
1
- import { Request, Response } from 'express';
2
-
3
- jest.mock('../logging/logger', () => ({
4
- __esModule: true,
5
- default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
6
- }));
7
-
8
- import {
9
- allowUserOrInternalService,
10
- internalServiceTokenBypass,
11
- requireInternalServiceToken,
12
- verifyInternalToken
13
- } from './internalServiceAuth';
14
-
15
- function mockRes(): Response {
16
- const res: Partial<Response> = {};
17
- res.status = jest.fn().mockReturnValue(res);
18
- res.json = jest.fn().mockReturnValue(res);
19
- return res as Response;
20
- }
21
-
22
- function mockReq(headers: Record<string, string> = {}): Request {
23
- return { headers, originalUrl: '/internal/entitlements' } as unknown as Request;
24
- }
25
-
26
- const ORIGINAL_ENV = process.env;
27
-
28
- beforeEach(() => {
29
- process.env = { ...ORIGINAL_ENV, INTERNAL_SERVICE_TOKEN: 'test-internal-token' };
30
- });
31
-
32
- afterAll(() => {
33
- process.env = ORIGINAL_ENV;
34
- });
35
-
36
- describe('verifyInternalToken', () => {
37
- it('accepts only an exact match', () => {
38
- expect(verifyInternalToken('test-internal-token')).toBe(true);
39
- expect(verifyInternalToken('test-internal-tokeN')).toBe(false);
40
- });
41
-
42
- it('rejects a prefix or an extension without throwing on the length mismatch', () => {
43
- expect(verifyInternalToken('test')).toBe(false);
44
- expect(verifyInternalToken('test-internal-token-and-more')).toBe(false);
45
- });
46
-
47
- it('rejects anything that is not a non-empty string', () => {
48
- expect(verifyInternalToken(undefined)).toBe(false);
49
- expect(verifyInternalToken('')).toBe(false);
50
- expect(verifyInternalToken(['test-internal-token'])).toBe(false);
51
- });
52
-
53
- it('rejects everything when no token is configured', () => {
54
- delete process.env.INTERNAL_SERVICE_TOKEN;
55
-
56
- expect(verifyInternalToken('test-internal-token')).toBe(false);
57
- });
58
- });
59
-
60
- describe('requireInternalServiceToken', () => {
61
- it('marks the request internal and continues on a valid token', () => {
62
- const req = mockReq({ 'x-internal-token': 'test-internal-token', 'x-service-id': 'album-service' });
63
- const next = jest.fn();
64
-
65
- requireInternalServiceToken(req, mockRes(), next);
66
-
67
- expect(req.isInternalService).toBe(true);
68
- expect(req.serviceIdentifier).toBe('album-service');
69
- expect(next).toHaveBeenCalledTimes(1);
70
- });
71
-
72
- it('defaults the service identifier when the header is absent', () => {
73
- const req = mockReq({ 'x-internal-token': 'test-internal-token' });
74
-
75
- requireInternalServiceToken(req, mockRes(), jest.fn());
76
-
77
- expect(req.serviceIdentifier).toBe('unknown');
78
- });
79
-
80
- it('rejects a missing token with 401', () => {
81
- const res = mockRes();
82
- const next = jest.fn();
83
-
84
- requireInternalServiceToken(mockReq(), res, next);
85
-
86
- expect(res.status).toHaveBeenCalledWith(401);
87
- expect(next).not.toHaveBeenCalled();
88
- });
89
-
90
- it('rejects a wrong token with 401', () => {
91
- const res = mockRes();
92
- const next = jest.fn();
93
-
94
- requireInternalServiceToken(mockReq({ 'x-internal-token': 'wrong' }), res, next);
95
-
96
- expect(res.status).toHaveBeenCalledWith(401);
97
- expect(next).not.toHaveBeenCalled();
98
- });
99
-
100
- it('fails with 500 rather than open when the deployment has no token configured', () => {
101
- delete process.env.INTERNAL_SERVICE_TOKEN;
102
- const res = mockRes();
103
- const next = jest.fn();
104
-
105
- requireInternalServiceToken(mockReq({ 'x-internal-token': 'anything' }), res, next);
106
-
107
- expect(res.status).toHaveBeenCalledWith(500);
108
- expect(next).not.toHaveBeenCalled();
109
- });
110
- });
111
-
112
- describe('internalServiceTokenBypass', () => {
113
- it('passes a request with no internal header through untouched, for normal user auth', () => {
114
- const req = mockReq();
115
- const next = jest.fn();
116
-
117
- internalServiceTokenBypass(req, mockRes(), next);
118
-
119
- expect(next).toHaveBeenCalledTimes(1);
120
- expect(req.isInternalService).toBeUndefined();
121
- });
122
-
123
- it('rejects a bogus token instead of letting it stand in for authentication', () => {
124
- // The bug this replaces: the header's mere presence used to skip JWT
125
- // verification, so `x-internal-token: anything` reached the controller
126
- // unauthenticated on any route that did not mount a second middleware.
127
- const res = mockRes();
128
- const next = jest.fn();
129
-
130
- internalServiceTokenBypass(mockReq({ 'x-internal-token': 'anything' }), res, next);
131
-
132
- expect(res.status).toHaveBeenCalledWith(401);
133
- expect(next).not.toHaveBeenCalled();
134
- });
135
-
136
- it('marks a valid internal call and continues', () => {
137
- const req = mockReq({ 'x-internal-token': 'test-internal-token' });
138
- const next = jest.fn();
139
-
140
- internalServiceTokenBypass(req, mockRes(), next);
141
-
142
- expect(req.isInternalService).toBe(true);
143
- expect(next).toHaveBeenCalledTimes(1);
144
- });
145
- });
146
-
147
- describe('allowUserOrInternalService', () => {
148
- it('allows an internal service', () => {
149
- const next = jest.fn();
150
-
151
- allowUserOrInternalService({ isInternalService: true } as Request, mockRes(), next);
152
-
153
- expect(next).toHaveBeenCalledTimes(1);
154
- });
155
-
156
- it('allows an authenticated user', () => {
157
- const next = jest.fn();
158
-
159
- allowUserOrInternalService({ user: { email: 'u@example.com' } } as Request, mockRes(), next);
160
-
161
- expect(next).toHaveBeenCalledTimes(1);
162
- });
163
-
164
- it('rejects anyone else with 401', () => {
165
- const res = mockRes();
166
- const next = jest.fn();
167
-
168
- allowUserOrInternalService({} as Request, res, next);
169
-
170
- expect(res.status).toHaveBeenCalledWith(401);
171
- expect(next).not.toHaveBeenCalled();
172
- });
173
- });
@@ -1,96 +0,0 @@
1
- import { Request, RequestHandler } from 'express';
2
- import { timingSafeEqual } from 'crypto';
3
- import logger from '../logging/logger';
4
-
5
- /**
6
- * Fields set by the internal-service middlewares below. Declared globally so
7
- * routers and controllers read `req.isInternalService` off the same `Request`
8
- * type Express itself uses, with no cast at the call site.
9
- */
10
- declare global {
11
- namespace Express {
12
- interface Request {
13
- isInternalService?: boolean;
14
- serviceIdentifier?: string;
15
- }
16
- }
17
- }
18
-
19
- export const INTERNAL_TOKEN_HEADER = 'x-internal-token';
20
- export const SERVICE_ID_HEADER = 'x-service-id';
21
-
22
- /**
23
- * Constant-time comparison of the presented token against the configured one.
24
- *
25
- * `===` on secrets leaks their prefix through timing, and `timingSafeEqual`
26
- * throws on a length mismatch (which is itself a leak), so length is compared
27
- * first and both buffers are hashed to a fixed width — a token of the wrong
28
- * length takes the same path as one of the right length with wrong bytes.
29
- */
30
- export function verifyInternalToken(presented: unknown): boolean {
31
- const expected = process.env.INTERNAL_SERVICE_TOKEN;
32
- if (!expected) return false;
33
- if (typeof presented !== 'string' || presented.length === 0) return false;
34
-
35
- const a = Buffer.from(presented, 'utf8');
36
- const b = Buffer.from(expected, 'utf8');
37
- if (a.length !== b.length) return false;
38
- return timingSafeEqual(a, b);
39
- }
40
-
41
- function markInternal(req: Request): void {
42
- req.isInternalService = true;
43
- req.serviceIdentifier = (req.headers[SERVICE_ID_HEADER] as string) || 'unknown';
44
- }
45
-
46
- /**
47
- * Hard gate for endpoints that are only ever called service-to-service.
48
- * No user-JWT fallback: a missing or wrong token is always a 401.
49
- */
50
- export const requireInternalServiceToken: RequestHandler = (req, res, next) => {
51
- if (!process.env.INTERNAL_SERVICE_TOKEN) {
52
- logger.error('INTERNAL_SERVICE_TOKEN not configured');
53
- res.status(500).json({ success: false, message: 'Internal service authentication not configured' });
54
- return;
55
- }
56
-
57
- if (!verifyInternalToken(req.headers[INTERNAL_TOKEN_HEADER])) {
58
- logger.warn('Rejected internal service request', {
59
- serviceId: (req.headers[SERVICE_ID_HEADER] as string) || 'unknown',
60
- path: req.originalUrl
61
- });
62
- res.status(401).json({ success: false, message: 'Invalid or missing internal service token' });
63
- return;
64
- }
65
-
66
- markInternal(req);
67
- next();
68
- };
69
-
70
- /**
71
- * For routers that serve both browsers and services: when the internal-token
72
- * header is present it is *verified here*, and a bad token is rejected rather
73
- * than passed along.
74
- *
75
- * The bug this replaces: treating the mere presence of the header as grounds to
76
- * skip JWT verification, leaving the real check to a second middleware mounted
77
- * per-router — so any route that forgot to mount the second one was reachable
78
- * by sending `x-internal-token: anything`.
79
- */
80
- export const internalServiceTokenBypass: RequestHandler = (req, res, next) => {
81
- const presented = req.headers[INTERNAL_TOKEN_HEADER];
82
- if (presented === undefined) {
83
- next();
84
- return;
85
- }
86
- requireInternalServiceToken(req, res, next);
87
- };
88
-
89
- /** Allows a request that authenticated as either a user or an internal service. */
90
- export const allowUserOrInternalService: RequestHandler = (req, res, next) => {
91
- if (req.isInternalService || req.user) {
92
- next();
93
- return;
94
- }
95
- res.status(401).json({ success: false, message: 'Authentication required' });
96
- };
@@ -1,81 +0,0 @@
1
- jest.mock('../logging/logger', () => ({
2
- __esModule: true,
3
- default: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), http: jest.fn() }
4
- }));
5
-
6
- import logger from '../logging/logger';
7
- import { requestLogger, requestLoggerWithMetrics, simpleRequestLogger } from './requestLogger';
8
-
9
- function mockReqRes() {
10
- const req: any = {
11
- method: 'GET',
12
- url: '/albums/123',
13
- correlationId: 'corr-1',
14
- ip: '127.0.0.1',
15
- get: jest.fn().mockReturnValue('test-agent')
16
- };
17
- const res: any = { statusCode: 200, on: jest.fn() };
18
- return { req, res };
19
- }
20
-
21
- describe('simpleRequestLogger', () => {
22
- beforeEach(() => jest.clearAllMocks());
23
-
24
- it('logs an inbound request line with correlation id, ip, and user agent', () => {
25
- const { req, res } = mockReqRes();
26
- const next = jest.fn();
27
-
28
- simpleRequestLogger(req, res, next);
29
-
30
- expect(logger.info).toHaveBeenCalledWith('→ GET /albums/123', {
31
- correlationId: 'corr-1',
32
- ip: '127.0.0.1',
33
- userAgent: 'test-agent'
34
- });
35
- expect(next).toHaveBeenCalledTimes(1);
36
- });
37
-
38
- it('falls back to "unknown" correlation id when none was set on the request', () => {
39
- const { req, res } = mockReqRes();
40
- delete req.correlationId;
41
- const next = jest.fn();
42
-
43
- simpleRequestLogger(req, res, next);
44
-
45
- expect(logger.info).toHaveBeenCalledWith(
46
- expect.any(String),
47
- expect.objectContaining({ correlationId: 'unknown' })
48
- );
49
- });
50
-
51
- it('logs the outbound response line with status and duration once the response finishes', () => {
52
- const { req, res } = mockReqRes();
53
- const next = jest.fn();
54
-
55
- simpleRequestLogger(req, res, next);
56
-
57
- expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
58
- const finishHandler = res.on.mock.calls.find((c: any[]) => c[0] === 'finish')[1];
59
- res.statusCode = 200;
60
- finishHandler();
61
-
62
- expect(logger.info).toHaveBeenCalledWith(
63
- expect.stringMatching(/^← GET \/albums\/123 200 \d+ms$/),
64
- expect.objectContaining({ correlationId: 'corr-1', statusCode: 200 })
65
- );
66
- });
67
- });
68
-
69
- describe('requestLogger', () => {
70
- it('is a Morgan middleware function', () => {
71
- expect(typeof requestLogger).toBe('function');
72
- });
73
- });
74
-
75
- describe('requestLoggerWithMetrics', () => {
76
- it('bundles requestLogger and metricsMiddleware as a two-item array', () => {
77
- expect(requestLoggerWithMetrics).toHaveLength(2);
78
- expect(requestLoggerWithMetrics[0]).toBe(requestLogger);
79
- expect(typeof requestLoggerWithMetrics[1]).toBe('function');
80
- });
81
- });
@@ -1,48 +0,0 @@
1
- import morgan from 'morgan';
2
- import { Request, Response, NextFunction } from 'express';
3
- import logger from '../logging/logger';
4
- import { metricsMiddleware } from '../metrics';
5
-
6
- /**
7
- * Morgan middleware configured to use Winston logger
8
- * Logs HTTP requests in structured format
9
- */
10
- export const requestLogger = morgan('combined', {
11
- stream: {
12
- write: (message: string) => {
13
- logger.http(message.trim());
14
- }
15
- }
16
- });
17
-
18
- /**
19
- * Combined middleware that includes both logging and metrics
20
- * Use this instead of separate requestLogger and metricsMiddleware
21
- */
22
- export const requestLoggerWithMetrics = [requestLogger, metricsMiddleware];
23
-
24
- /**
25
- * Simple request logger for development
26
- * Logs basic request info with correlation ID
27
- */
28
- export const simpleRequestLogger = (req: Request, res: Response, next: NextFunction) => {
29
- const start = Date.now();
30
- const correlationId = req.correlationId || 'unknown';
31
-
32
- logger.info(`→ ${req.method} ${req.url}`, {
33
- correlationId,
34
- ip: req.ip,
35
- userAgent: req.get('User-Agent')
36
- });
37
-
38
- res.on('finish', () => {
39
- const duration = Date.now() - start;
40
- logger.info(`← ${req.method} ${req.url} ${res.statusCode} ${duration}ms`, {
41
- correlationId,
42
- statusCode: res.statusCode,
43
- duration
44
- });
45
- });
46
-
47
- next();
48
- };
@@ -1,45 +0,0 @@
1
- import express from 'express';
2
- import request from 'supertest';
3
- import { securityHeaders, createRateLimiter } from './security';
4
-
5
- describe('securityHeaders', () => {
6
- const app = express();
7
- app.use(securityHeaders);
8
- app.get('/ping', (_req, res) => res.json({ ok: true }));
9
-
10
- it('sets standard helmet protections', async () => {
11
- const res = await request(app).get('/ping');
12
-
13
- expect(res.headers['x-content-type-options']).toBe('nosniff');
14
- expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
15
- });
16
-
17
- it('relaxes cross-origin-resource-policy so other-origin frontends can read the response', async () => {
18
- const res = await request(app).get('/ping');
19
-
20
- expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin');
21
- });
22
-
23
- it('does not set a Content-Security-Policy header (JSON API, not HTML)', async () => {
24
- const res = await request(app).get('/ping');
25
-
26
- expect(res.headers['content-security-policy']).toBeUndefined();
27
- });
28
- });
29
-
30
- describe('createRateLimiter', () => {
31
- it('allows requests under the limit and blocks once the limit is exceeded', async () => {
32
- const app = express();
33
- app.use(createRateLimiter({ windowMs: 60_000, max: 2 }));
34
- app.get('/ping', (_req, res) => res.json({ ok: true }));
35
-
36
- const first = await request(app).get('/ping');
37
- const second = await request(app).get('/ping');
38
- const third = await request(app).get('/ping');
39
-
40
- expect(first.status).toBe(200);
41
- expect(second.status).toBe(200);
42
- expect(third.status).toBe(429);
43
- expect(third.body).toEqual({ success: false, message: 'Too many requests, please try again later' });
44
- });
45
- });
@@ -1,43 +0,0 @@
1
- import helmet from 'helmet';
2
- import rateLimit, { Options as RateLimitOptions } from 'express-rate-limit';
3
-
4
- /**
5
- * Standard security headers (helmet) for all services.
6
- *
7
- * - `contentSecurityPolicy` is off: these are JSON APIs, not HTML apps, and a
8
- * couple of services serve a static Swagger docs page under `/api/docs`
9
- * whose inline scripts a default CSP would break.
10
- * - `crossOriginResourcePolicy` is relaxed to `cross-origin`: helmet's default
11
- * (`same-origin`) makes browsers refuse to read the response from a
12
- * different origin — which every frontend is, since each front and backend
13
- * service runs on its own port. CORS (see `createCorsMiddleware`) already
14
- * does origin enforcement; this header must not fight it.
15
- */
16
- export const securityHeaders = helmet({
17
- contentSecurityPolicy: false,
18
- crossOriginResourcePolicy: { policy: 'cross-origin' },
19
- crossOriginEmbedderPolicy: false
20
- });
21
-
22
- /**
23
- * Build a rate limiter with sane JSON error output. Counts per client IP.
24
- */
25
- export function createRateLimiter(options: Partial<RateLimitOptions> = {}) {
26
- return rateLimit({
27
- windowMs: 15 * 60 * 1000,
28
- max: 300,
29
- standardHeaders: true,
30
- legacyHeaders: false,
31
- message: { success: false, message: 'Too many requests, please try again later' },
32
- ...options
33
- });
34
- }
35
-
36
- /** Baseline limiter for authenticated/general routes. */
37
- export const standardRateLimiter = createRateLimiter();
38
-
39
- /**
40
- * Tighter limiter for brute-force-prone unauthenticated routes (token
41
- * issuance, public endpoints with no auth in front of them).
42
- */
43
- export const strictRateLimiter = createRateLimiter({ max: 20 });
@@ -1,72 +0,0 @@
1
- import express from 'express';
2
- import request from 'supertest';
3
- import { z } from 'zod';
4
- import { validate } from './validate';
5
- import { errorHandler } from './errorHandler';
6
-
7
- function buildApp(schema: z.ZodType) {
8
- const app = express();
9
- app.use(express.json());
10
- app.post('/things', validate(schema), (req, res) => {
11
- res.json({ success: true, data: req.body });
12
- });
13
- app.use(errorHandler);
14
- return app;
15
- }
16
-
17
- describe('validate', () => {
18
- const schema = z.object({
19
- name: z.string().min(1),
20
- age: z.number().int().positive().optional()
21
- });
22
-
23
- it('calls the handler with the parsed body when validation passes', async () => {
24
- const app = buildApp(schema);
25
-
26
- const res = await request(app).post('/things').send({ name: 'Alice', age: 30 });
27
-
28
- expect(res.status).toBe(200);
29
- expect(res.body).toEqual({ success: true, data: { name: 'Alice', age: 30 } });
30
- });
31
-
32
- it('responds 400 via the shared errorHandler when a required field is missing', async () => {
33
- const app = buildApp(schema);
34
-
35
- const res = await request(app).post('/things').send({});
36
-
37
- expect(res.status).toBe(400);
38
- expect(res.body.success).toBe(false);
39
- expect(res.body.error).toBe('BadRequestError');
40
- expect(res.body.message).toContain('name');
41
- });
42
-
43
- it('responds 400 when a field has the wrong type', async () => {
44
- const app = buildApp(schema);
45
-
46
- const res = await request(app).post('/things').send({ name: 'Bob', age: 'not-a-number' });
47
-
48
- expect(res.status).toBe(400);
49
- expect(res.body.error).toBe('BadRequestError');
50
- });
51
-
52
- it('strips unknown fields not declared in the schema (zod default behavior)', async () => {
53
- const app = buildApp(schema);
54
-
55
- const res = await request(app).post('/things').send({ name: 'Carol', extra: 'nope' });
56
-
57
- expect(res.status).toBe(200);
58
- expect(res.body.data).toEqual({ name: 'Carol' });
59
- });
60
-
61
- it('omits the field prefix when the zod issue has no path (whole-body validation)', async () => {
62
- const arraySchema = z.array(z.string());
63
- const app = buildApp(arraySchema);
64
- const rawResult = arraySchema.safeParse({ not: 'an array' });
65
-
66
- const res = await request(app).post('/things').send({ not: 'an array' });
67
-
68
- expect(res.status).toBe(400);
69
- expect(rawResult.success).toBe(false);
70
- expect(res.body.message).toBe(!rawResult.success ? rawResult.error.issues[0].message : undefined);
71
- });
72
- });
@@ -1,23 +0,0 @@
1
- import { Request, Response, NextFunction, RequestHandler } from 'express';
2
- import { ZodType } from 'zod';
3
- import { BadRequestError } from '../errors/HttpError';
4
-
5
- /**
6
- * Validates `req.body` against a zod schema before the route handler runs.
7
- * On failure, throws a `BadRequestError` (caught by the shared errorHandler)
8
- * instead of letting controllers hand-roll their own field checks. On
9
- * success, `req.body` is replaced with the parsed result so handlers get
10
- * zod's inferred, coerced types rather than raw untyped input.
11
- */
12
- export function validate(schema: ZodType): RequestHandler {
13
- return (req: Request, _res: Response, next: NextFunction): void => {
14
- const result = schema.safeParse(req.body);
15
- if (!result.success) {
16
- const issue = result.error.issues[0];
17
- const field = issue.path.length ? `${issue.path.join('.')}: ` : '';
18
- throw new BadRequestError(`${field}${issue.message}`);
19
- }
20
- req.body = result.data;
21
- next();
22
- };
23
- }
@@ -1,29 +0,0 @@
1
- export { OAuthClient, AuthorizationCode, RefreshToken } from './models';
2
- export type { IOAuthClient, IAuthorizationCode, IRefreshToken } from './models';
3
- export {
4
- registerClient,
5
- findClient,
6
- isRegisteredRedirect,
7
- issueAuthorizationCode,
8
- redeemAuthorizationCode,
9
- issueRefreshToken,
10
- redeemRefreshToken,
11
- revokeRefreshTokensForUser,
12
- listConnections
13
- } from './service';
14
- export type {
15
- RegisterClientInput,
16
- RegisteredClient,
17
- IssueCodeInput,
18
- CodeRedemption,
19
- CodeRejection,
20
- RefreshRedemption
21
- } from './service';
22
- export {
23
- mintAccessToken,
24
- verifyAccessToken,
25
- parseScopes,
26
- isAccessTokenClaims,
27
- ACCESS_TOKEN_TYPE
28
- } from './tokens';
29
- export type { AccessTokenClaims, MintAccessTokenInput, MintedAccessToken, AccessTokenVerification } from './tokens';