diff --git a/README.md b/README.md index 662dd94..450a9c4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,31 @@ npm start | `npm test` | Run Jest tests | | `npm run lint` | Run ESLint | +## Request Validation Framework + +The API now includes a schema-based request validation framework for: + +- Route `params` +- URL `query` +- JSON request `body` + +Validation is strict by default: + +- Unknown fields are rejected. +- Required fields are enforced. +- Type and range/length constraints are validated. + +Validation middleware returns HTTP `400` with the shape: + +```json +{ + "error": "Validation failed", + "details": ["query.admin is not allowed"] +} +``` + +See `docs/backend/request-validation-framework.md` for implementation details and security notes. + ## Contributing 1. Fork the repo and create a branch from `main`. diff --git a/docs/backend/request-validation-framework.md b/docs/backend/request-validation-framework.md new file mode 100644 index 0000000..b73f694 --- /dev/null +++ b/docs/backend/request-validation-framework.md @@ -0,0 +1,58 @@ +# Request Validation Framework + +## Overview + +This backend uses a schema-based validation middleware to validate request segments before handler logic runs. + +Validated segments: + +- `req.params` +- `req.query` +- `req.body` + +## Implementation + +- Schema utilities: `src/validation/requestSchema.ts` +- Middleware factory: `src/middleware/requestValidation.ts` +- Route integration: `src/index.ts` + +### Structured doc comments + +Exported schema and middleware APIs include structured JSDoc comments describing behavior and constraints. + +## Validation behavior + +For each segment (`params`, `query`, `body`): + +1. Reject non-object inputs. +2. Reject unknown keys (strict allow-list). +3. Enforce required fields. +4. Enforce primitive types (`string`, `number`, `boolean`). +5. Enforce optional constraints (`minLength`, `maxLength`, `min`, `max`, `enum`, `pattern`). + +On validation failure, middleware returns `400`: + +```json +{ + "error": "Validation failed", + "details": ["body.title is required"] +} +``` + +## Security assumptions and threat scenarios + +### Assumptions + +- API consumers send JSON bodies for protected endpoints. +- Incoming data is untrusted. + +### Threat scenarios addressed + +- **Unexpected field injection**: blocked by unknown-key rejection. +- **Type confusion**: blocked by strict primitive type checks. +- **Input boundary abuse**: constrained with range and length checks. + +### Explicit non-goals + +- No authentication/authorization changes. +- No business rule validation outside schema constraints. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6392ca6..99d55ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.9.0", + "@types/supertest": "^6.0.2", "jest": "^29.7.0", + "supertest": "^7.1.1", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" @@ -963,6 +965,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1084,6 +1109,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -1165,6 +1197,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1178,7 +1217,6 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1251,6 +1289,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1386,6 +1448,20 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1603,7 +1679,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1855,6 +1930,29 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1905,6 +2003,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -1983,6 +2088,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2012,6 +2127,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -2138,6 +2264,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2290,6 +2432,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2345,6 +2494,41 @@ "node": ">=8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2572,6 +2756,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2914,7 +3114,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -4623,6 +4822,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4788,7 +5071,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -4937,7 +5219,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 4522e40..d7bfd55 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.9.0", + "@types/supertest": "^6.0.2", "jest": "^29.7.0", + "supertest": "^7.1.1", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" diff --git a/src/health.test.ts b/src/health.test.ts index a9fa0e8..308623e 100644 --- a/src/health.test.ts +++ b/src/health.test.ts @@ -1,5 +1,14 @@ -describe('health', () => { - it('should pass', () => { - expect(true).toBe(true); +import request from 'supertest'; +import app from './index'; + +describe('health endpoint', () => { + it('returns service health payload', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + status: 'ok', + service: 'talenttrust-backend', + }); }); }); diff --git a/src/index.ts b/src/index.ts index dd2fd8f..989b2a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ import express, { Request, Response } from 'express'; +import { createRequestValidationMiddleware } from './middleware/requestValidation'; +import { ObjectSchema } from './validation/requestSchema'; const app = express(); const PORT = process.env.PORT || 3001; @@ -9,10 +11,59 @@ app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', service: 'talenttrust-backend' }); }); -app.get('/api/v1/contracts', (_req: Request, res: Response) => { - res.json({ contracts: [] }); -}); +const contractParamsSchema: ObjectSchema = { + contractId: { type: 'string', required: true, minLength: 3, maxLength: 64 }, +}; -app.listen(PORT, () => { - console.log(`TalentTrust API listening on http://localhost:${PORT}`); -}); +const contractListQuerySchema: ObjectSchema = { + status: { + type: 'string', + required: false, + enum: ['active', 'completed', 'disputed'], + }, +}; + +const contractMetadataBodySchema: ObjectSchema = { + title: { type: 'string', required: true, minLength: 1, maxLength: 120 }, + description: { type: 'string', required: false, maxLength: 5000 }, + budget: { type: 'number', required: false, min: 0 }, +}; + +app.get( + '/api/v1/contracts', + createRequestValidationMiddleware({ query: contractListQuerySchema }), + (req: Request, res: Response) => { + res.json({ contracts: [], filters: req.query }); + } +); + +app.get( + '/api/v1/contracts/:contractId', + createRequestValidationMiddleware({ params: contractParamsSchema }), + (req: Request, res: Response) => { + res.json({ contractId: req.params.contractId }); + } +); + +app.post( + '/api/v1/contracts/:contractId/metadata', + createRequestValidationMiddleware({ + params: contractParamsSchema, + body: contractMetadataBodySchema, + }), + (req: Request, res: Response) => { + res.status(201).json({ + contractId: req.params.contractId, + metadata: req.body, + }); + } +); + +/* istanbul ignore next */ +if (require.main === module) { + app.listen(PORT, () => { + console.log(`TalentTrust API listening on http://localhost:${PORT}`); + }); +} + +export default app; diff --git a/src/middleware/requestValidation.ts b/src/middleware/requestValidation.ts new file mode 100644 index 0000000..625e4a2 --- /dev/null +++ b/src/middleware/requestValidation.ts @@ -0,0 +1,50 @@ +import { NextFunction, Request, Response } from 'express'; +import { ObjectSchema, validateSegment } from '../validation/requestSchema'; + +/** + * Validation schema container for request segments. + */ +export interface RequestValidationSchema { + params?: ObjectSchema; + query?: ObjectSchema; + body?: ObjectSchema; +} + +/** + * Middleware factory for strict request validation. + */ +export function createRequestValidationMiddleware(schema: RequestValidationSchema) { + return (req: Request, res: Response, next: NextFunction): void => { + const paramsValidation = schema.params + ? validateSegment(req.params, schema.params, 'params') + : { value: req.params as Record, errors: [] as string[] }; + + const queryValidation = schema.query + ? validateSegment(req.query, schema.query, 'query') + : { value: req.query as Record, errors: [] as string[] }; + + const bodyValidation = schema.body + ? validateSegment(req.body, schema.body, 'body') + : { value: req.body as Record, errors: [] as string[] }; + + const errors = [ + ...paramsValidation.errors, + ...queryValidation.errors, + ...bodyValidation.errors, + ]; + + if (errors.length > 0) { + res.status(400).json({ + error: 'Validation failed', + details: errors, + }); + return; + } + + req.params = paramsValidation.value as Request['params']; + req.query = queryValidation.value as Request['query']; + req.body = bodyValidation.value; + + next(); + }; +} diff --git a/src/requestValidation.integration.test.ts b/src/requestValidation.integration.test.ts new file mode 100644 index 0000000..281df9a --- /dev/null +++ b/src/requestValidation.integration.test.ts @@ -0,0 +1,91 @@ +import request from 'supertest'; +import app from './index'; + +describe('request validation middleware integration', () => { + describe('GET /api/v1/contracts (query validation)', () => { + it('accepts valid query', async () => { + const response = await request(app) + .get('/api/v1/contracts') + .query({ status: 'active' }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + contracts: [], + filters: { status: 'active' }, + }); + }); + + it('rejects unsupported query keys', async () => { + const response = await request(app) + .get('/api/v1/contracts') + .query({ status: 'active', admin: 'true' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Validation failed'); + expect(response.body.details).toContain('query.admin is not allowed'); + }); + + it('rejects invalid query enum values', async () => { + const response = await request(app) + .get('/api/v1/contracts') + .query({ status: 'pending' }); + + expect(response.status).toBe(400); + expect(response.body.details).toContain( + 'query.status must be one of: active, completed, disputed' + ); + }); + }); + + describe('GET /api/v1/contracts/:contractId (params validation)', () => { + it('accepts valid params', async () => { + const response = await request(app).get('/api/v1/contracts/contract-123'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ contractId: 'contract-123' }); + }); + + it('rejects short contractId values', async () => { + const response = await request(app).get('/api/v1/contracts/ab'); + + expect(response.status).toBe(400); + expect(response.body.details).toContain('params.contractId must have length >= 3'); + }); + }); + + describe('POST /api/v1/contracts/:contractId/metadata (body validation)', () => { + it('accepts valid body', async () => { + const response = await request(app) + .post('/api/v1/contracts/contract-123/metadata') + .send({ title: 'Milestone 1', description: 'Initial release', budget: 500 }); + + expect(response.status).toBe(201); + expect(response.body).toEqual({ + contractId: 'contract-123', + metadata: { + title: 'Milestone 1', + description: 'Initial release', + budget: 500, + }, + }); + }); + + it('rejects unsupported body keys', async () => { + const response = await request(app) + .post('/api/v1/contracts/contract-123/metadata') + .send({ title: 'Milestone 1', internalFlag: true }); + + expect(response.status).toBe(400); + expect(response.body.details).toContain('body.internalFlag is not allowed'); + }); + + it('rejects invalid body field types', async () => { + const response = await request(app) + .post('/api/v1/contracts/contract-123/metadata') + .send({ title: 'Milestone 1', budget: '1000' }); + + expect(response.status).toBe(400); + expect(response.body.details).toContain('body.budget must be of type number'); + }); + }); +}); diff --git a/src/validation/requestSchema.test.ts b/src/validation/requestSchema.test.ts new file mode 100644 index 0000000..d70c2b7 --- /dev/null +++ b/src/validation/requestSchema.test.ts @@ -0,0 +1,67 @@ +import { ObjectSchema, validateSegment } from './requestSchema'; + +describe('validateSegment', () => { + const schema: ObjectSchema = { + id: { type: 'string', required: true, minLength: 3 }, + score: { type: 'number', required: false, min: 0, max: 100 }, + verified: { type: 'boolean', required: false }, + }; + + it('accepts valid payload and returns validated value', () => { + const result = validateSegment( + { id: 'abc', score: 90, verified: true }, + schema, + 'body' + ); + + expect(result.errors).toEqual([]); + expect(result.value).toEqual({ id: 'abc', score: 90, verified: true }); + }); + + it('rejects unknown keys', () => { + const result = validateSegment( + { id: 'abcd', unknown: 'not-allowed' }, + schema, + 'body' + ); + + expect(result.errors).toContain('body.unknown is not allowed'); + }); + + it('rejects missing required field and invalid types', () => { + const result = validateSegment({ score: 'bad' }, schema, 'body'); + + expect(result.errors).toContain('body.id is required'); + expect(result.errors).toContain('body.score must be of type number'); + }); + + it('rejects out-of-range number values', () => { + const result = validateSegment({ id: 'user-1', score: 101 }, schema, 'body'); + + expect(result.errors).toContain('body.score must be <= 100'); + }); + + it('rejects below-min and non-finite number values', () => { + const belowMin = validateSegment({ id: 'user-1', score: -1 }, schema, 'body'); + const nonFinite = validateSegment({ id: 'user-1', score: Infinity }, schema, 'body'); + + expect(belowMin.errors).toContain('body.score must be >= 0'); + expect(nonFinite.errors).toContain('body.score must be a finite number'); + }); + + it('rejects invalid string pattern values', () => { + const patternedSchema: ObjectSchema = { + id: { type: 'string', required: true, pattern: /^usr_[a-z0-9]+$/ }, + }; + + const result = validateSegment({ id: 'INVALID' }, patternedSchema, 'params'); + + expect(result.errors).toContain('params.id has invalid format'); + }); + + it('rejects non-object segments', () => { + const result = validateSegment('invalid', schema, 'query'); + + expect(result.errors).toEqual(['query must be a JSON object']); + }); +}); diff --git a/src/validation/requestSchema.ts b/src/validation/requestSchema.ts new file mode 100644 index 0000000..34f2918 --- /dev/null +++ b/src/validation/requestSchema.ts @@ -0,0 +1,121 @@ +export type PrimitiveType = 'string' | 'number' | 'boolean'; + +/** + * Validation rules for a single request field. + */ +export interface FieldSchema { + type: PrimitiveType; + required?: boolean; + minLength?: number; + maxLength?: number; + min?: number; + max?: number; + enum?: ReadonlyArray; + pattern?: RegExp; +} + +/** + * Map of field names to validation rules. + */ +export type ObjectSchema = Record; + +export interface ValidationResult { + value: Record; + errors: string[]; +} + +/** + * Validates a request segment (params/query/body) using a strict schema. + * Unknown fields are rejected to reduce attack surface. + */ +export function validateSegment( + input: unknown, + schema: ObjectSchema, + segmentName: 'params' | 'query' | 'body' +): ValidationResult { + const errors: string[] = []; + const value: Record = {}; + + if (!isPlainObject(input)) { + return { + value, + errors: [`${segmentName} must be a JSON object`], + }; + } + + const inputObject = input as Record; + + for (const incomingKey of Object.keys(inputObject)) { + if (!(incomingKey in schema)) { + errors.push(`${segmentName}.${incomingKey} is not allowed`); + } + } + + for (const [fieldName, rules] of Object.entries(schema)) { + const rawValue = inputObject[fieldName]; + const fieldPath = `${segmentName}.${fieldName}`; + + if (rawValue === undefined || rawValue === null) { + if (rules.required) { + errors.push(`${fieldPath} is required`); + } + continue; + } + + if (!validatePrimitiveType(rawValue, rules.type)) { + errors.push(`${fieldPath} must be of type ${rules.type}`); + continue; + } + + if (rules.type === 'string') { + const asString = rawValue as string; + if (rules.minLength !== undefined && asString.length < rules.minLength) { + errors.push(`${fieldPath} must have length >= ${rules.minLength}`); + } + if (rules.maxLength !== undefined && asString.length > rules.maxLength) { + errors.push(`${fieldPath} must have length <= ${rules.maxLength}`); + } + if (rules.pattern && !rules.pattern.test(asString)) { + errors.push(`${fieldPath} has invalid format`); + } + } + + if (rules.type === 'number') { + const asNumber = rawValue as number; + if (!Number.isFinite(asNumber)) { + errors.push(`${fieldPath} must be a finite number`); + } + if (rules.min !== undefined && asNumber < rules.min) { + errors.push(`${fieldPath} must be >= ${rules.min}`); + } + if (rules.max !== undefined && asNumber > rules.max) { + errors.push(`${fieldPath} must be <= ${rules.max}`); + } + } + + if ( + rules.enum && + !rules.enum.includes(rawValue as string | number | boolean) + ) { + errors.push(`${fieldPath} must be one of: ${rules.enum.join(', ')}`); + } + + value[fieldName] = rawValue; + } + + return { value, errors }; +} + +function validatePrimitiveType(value: unknown, type: PrimitiveType): boolean { + if (type === 'string') { + return typeof value === 'string'; + } + if (type === 'number') { + return typeof value === 'number'; + } + return typeof value === 'boolean'; +} + +function isPlainObject(value: unknown): boolean { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}