From 3797e2b8c12fcc33abaa018713ceb36502664cb6 Mon Sep 17 00:00:00 2001 From: Mikolaj Miotk Date: Mon, 16 Mar 2026 13:44:42 +0100 Subject: [PATCH 1/4] Add mock draft --- mock-service/README.md | 129 +++++++++++++ mock-service/jest.config.ts | 14 ++ mock-service/package.json | 37 ++++ mock-service/scripts/build.ts | 50 ++++++ mock-service/scripts/package.ts | 45 +++++ mock-service/src/handlers/health.ts | 12 ++ mock-service/src/handlers/jwks.test.ts | 56 ++++++ mock-service/src/handlers/jwks.ts | 78 ++++++++ mock-service/src/handlers/oauth-token.test.ts | 81 +++++++++ mock-service/src/handlers/oauth-token.ts | 78 ++++++++ mock-service/src/handlers/order.ts | 169 ++++++++++++++++++ mock-service/src/handlers/postcode.ts | 65 +++++++ mock-service/src/handlers/results.ts | 117 ++++++++++++ mock-service/src/index.ts | 33 ++++ mock-service/src/router.test.ts | 76 ++++++++ mock-service/src/router.ts | 55 ++++++ mock-service/src/test-utils/mock-event.ts | 49 +++++ mock-service/src/utils/response.ts | 25 +++ mock-service/tsconfig.json | 26 +++ package.json | 7 +- .../post-apply-update-supplier-url.sh | 23 +++ 21 files changed, 1223 insertions(+), 2 deletions(-) create mode 100644 mock-service/README.md create mode 100644 mock-service/jest.config.ts create mode 100644 mock-service/package.json create mode 100644 mock-service/scripts/build.ts create mode 100644 mock-service/scripts/package.ts create mode 100644 mock-service/src/handlers/health.ts create mode 100644 mock-service/src/handlers/jwks.test.ts create mode 100644 mock-service/src/handlers/jwks.ts create mode 100644 mock-service/src/handlers/oauth-token.test.ts create mode 100644 mock-service/src/handlers/oauth-token.ts create mode 100644 mock-service/src/handlers/order.ts create mode 100644 mock-service/src/handlers/postcode.ts create mode 100644 mock-service/src/handlers/results.ts create mode 100644 mock-service/src/index.ts create mode 100644 mock-service/src/router.test.ts create mode 100644 mock-service/src/router.ts create mode 100644 mock-service/src/test-utils/mock-event.ts create mode 100644 mock-service/src/utils/response.ts create mode 100644 mock-service/tsconfig.json create mode 100755 scripts/terraform/post-apply-update-supplier-url.sh diff --git a/mock-service/README.md b/mock-service/README.md new file mode 100644 index 00000000..98561021 --- /dev/null +++ b/mock-service/README.md @@ -0,0 +1,129 @@ +# Mock Service + +Lambda-hosted mock API for dev/test environments. Replaces WireMock Docker with a serverless deployment that can be shared across dev environments on AWS. + +## What it mocks + +| Route | Purpose | Replaces | +|---|---|---| +| `GET /mock/health` | Health check | — | +| `POST /mock/supplier/oauth/token` | OAuth2 client_credentials grant | WireMock `oauth-token.json` | +| `POST /mock/supplier/order` | Create FHIR ServiceRequest order | WireMock `order-success.json` | +| `GET /mock/supplier/order` | Get order status | WireMock `order-confirmed.json` etc. | +| `GET /mock/supplier/results` | Get test results (FHIR Observation) | WireMock `results-success.json` | +| `GET /mock/cognito/.well-known/jwks.json` | JWKS public key set | Not previously mocked | +| `GET /mock/postcode/{postcode}` | Postcode → local authority | Not previously mocked | + +## Controlling responses + +Send the `X-Mock-Status` header to force specific error scenarios: + +```bash +# OAuth: force 401 (invalid credentials) +curl -X POST .../mock/supplier/oauth/token -H "X-Mock-Status: 401" ... + +# Order: force 404 (not found) or 422 (unprocessable) +curl .../mock/supplier/order?order_id=xxx -H "X-Mock-Status: 404" + +# Order: force status variant (dispatched, confirmed, complete) +curl .../mock/supplier/order?order_id=xxx -H "X-Mock-Status: dispatched" + +# Results: force 404 (not found) or 400 (invalid) +curl .../mock/supplier/results?order_uid=xxx -H "X-Mock-Status: 404" +``` + +## JWKS / JWT signing + +The `/mock/cognito/.well-known/jwks.json` endpoint returns an RSA public key. + +- **Auto-generated**: On cold start, a fresh RSA key pair is generated. The public key is served at the JWKS endpoint, and the private key stays in memory. +- **Shared key**: Set `MOCK_JWKS_PRIVATE_KEY` (PEM-encoded RSA private key) as a Lambda env var so all dev services use the same signing key. + +The `signMockJwt()` function (exported from `src/handlers/jwks.ts`) signs payloads with the private key for use in tests. + +## Local development + +```bash +cd mock-service +npm install +npm test # run unit tests +npm run build # esbuild → dist/mock-service-lambda/index.js +npm run package # zip → dist/mock-service-lambda.zip +``` + +### Running locally (via LocalStack) + +The mock-service is deployed to LocalStack alongside the other Lambdas as part of `npm run local:deploy` from the repo root. It replaces the WireMock Docker container. + +The local flow: + +1. `npm run build:mock-service && npm run package:mock-service` — builds the zip +2. `npm run local:terraform:apply` — deploys it as a Lambda + API Gateway on LocalStack +3. `npm run local:update-supplier-url` — updates the DB supplier `service_url` to point at the mock API Gateway + +All three steps run automatically as part of `npm run local:deploy`. + +## Deploying to AWS + +The mock service is deployed as a nested Terragrunt module under `hometest-app` (same pattern as `lambda-goose-migrator`): + +```bash +cd hometest-mgmt-terraform/infrastructure/environments/poc/hometest-app/dev/mock-service +terragrunt apply +``` + +After deployment, the outputs provide URLs to plug into `hometest-app`: + +```bash +supplier_api_base_url = https://xxx.execute-api.eu-west-2.amazonaws.com/v1/mock/supplier +cognito_jwks_url = https://xxx.execute-api.eu-west-2.amazonaws.com/v1/mock/cognito/.well-known/jwks.json +postcode_api_base_url = https://xxx.execute-api.eu-west-2.amazonaws.com/v1/mock/postcode +``` + +Set these as environment variables in the `hometest-app` dev environment's `terragrunt.hcl`: + +```hcl +inputs = { + lambdas = { + "order-service-lambda" = { + environment = { + SUPPLIER_API_BASE_URL = dependency.mock_service.outputs.supplier_api_base_url + } + } + "custom-authorizer" = { + environment = { + COGNITO_JWKS_URL = dependency.mock_service.outputs.cognito_jwks_url + } + } + } +} +``` + +## Project structure + +```bash +mock-service/ +├── package.json +├── tsconfig.json +├── jest.config.ts +├── scripts/ +│ ├── build.ts # esbuild bundler +│ └── package.ts # zip creator +└── src/ + ├── index.ts # Lambda entry point (Middy) + ├── router.ts # Path-based request router + ├── router.test.ts + ├── utils/ + │ └── response.ts # JSON / FHIR response helpers + ├── handlers/ + │ ├── health.ts + │ ├── jwks.ts # JWKS + JWT signing utility + │ ├── jwks.test.ts + │ ├── oauth-token.ts # OAuth2 client_credentials + │ ├── oauth-token.test.ts + │ ├── order.ts # FHIR ServiceRequest mock + │ ├── postcode.ts # Postcode lookup mock + │ └── results.ts # FHIR Observation results + └── test-utils/ + └── mock-event.ts # APIGatewayProxyEvent factory +``` diff --git a/mock-service/jest.config.ts b/mock-service/jest.config.ts new file mode 100644 index 00000000..7d4b8e34 --- /dev/null +++ b/mock-service/jest.config.ts @@ -0,0 +1,14 @@ +import type { Config } from "jest"; + +const config: Config = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/src"], + testMatch: ["**/*.test.ts"], + moduleFileExtensions: ["ts", "js", "json"], + transform: { + "^.+\\.ts$": ["ts-jest", { useESM: false }], + }, +}; + +export default config; diff --git a/mock-service/package.json b/mock-service/package.json new file mode 100644 index 00000000..fb39c79c --- /dev/null +++ b/mock-service/package.json @@ -0,0 +1,37 @@ +{ + "name": "@hometest-service/mock-service", + "version": "1.0.0", + "description": "Lambda-hosted mock service for dev environments — supplier APIs, Cognito JWKS, postcode lookup", + "private": true, + "type": "module", + "scripts": { + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "build": "npx tsx scripts/build.ts", + "package": "npx tsx scripts/package.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "@middy/core": "^7.1.3", + "@middy/http-cors": "^7.1.3", + "@middy/http-error-handler": "^7.1.3", + "@middy/http-security-headers": "^7.1.3", + "jsonwebtoken": "^9.0.3", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/aws-lambda": "^8.10.161", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.9", + "@types/node": "^24.12.0", + "esbuild": "^0.27.3", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", + "tsx": "4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/mock-service/scripts/build.ts b/mock-service/scripts/build.ts new file mode 100644 index 00000000..f26e9588 --- /dev/null +++ b/mock-service/scripts/build.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { build } from "esbuild"; +import { existsSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { join } from "path"; + +const ROOT_DIR = process.cwd(); +const SRC_DIR = join(ROOT_DIR, "src"); +const DIST_DIR = join(ROOT_DIR, "dist"); + +async function buildMockService(): Promise { + console.log("Building mock-service lambda..."); + + const entryPoint = join(SRC_DIR, "index.ts"); + const outDir = join(DIST_DIR, "mock-service-lambda"); + const outFile = join(outDir, "index.js"); + + if (!existsSync(entryPoint)) { + throw new Error(`Entry point not found: ${entryPoint}`); + } + + if (existsSync(outDir)) { + rmSync(outDir, { recursive: true }); + } + + mkdirSync(outDir, { recursive: true }); + + const result = await build({ + entryPoints: [entryPoint], + bundle: true, + outfile: outFile, + platform: "node", + target: "node24", + format: "cjs", + external: ["aws-sdk", "@aws-sdk/client-*", "@aws-sdk/lib-*"], + packages: "bundle", + minify: false, + sourcemap: false, + logLevel: "info", + metafile: true, + }); + + writeFileSync(join(outDir, "meta.json"), JSON.stringify(result.metafile, null, 2)); + console.log("Build complete."); +} + +buildMockService().catch((err) => { + console.error("Build failed:", err); + process.exit(1); +}); diff --git a/mock-service/scripts/package.ts b/mock-service/scripts/package.ts new file mode 100644 index 00000000..20e99579 --- /dev/null +++ b/mock-service/scripts/package.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import { createWriteStream, existsSync, rmSync } from "fs"; +import { join } from "path"; +import archiver from "archiver"; + +const ROOT_DIR = process.cwd(); +const DIST_DIR = join(ROOT_DIR, "dist"); + +async function createMockServiceZip(): Promise { + console.log("Creating deployment zip for mock-service..."); + + const lambdaPath = join(DIST_DIR, "mock-service-lambda"); + const indexPath = join(lambdaPath, "index.js"); + const zipPath = join(DIST_DIR, "mock-service-lambda.zip"); + + if (!existsSync(lambdaPath)) { + throw new Error(`No dist directory found — run 'npm run build' first`); + } + + if (existsSync(zipPath)) { + rmSync(zipPath); + } + + return new Promise((resolve, reject) => { + const output = createWriteStream(zipPath); + const archive = archiver("zip", { zlib: { level: 9 } }); + + output.on("close", () => { + console.log(`Created ${zipPath} (${archive.pointer()} bytes)`); + resolve(); + }); + + archive.on("error", reject); + archive.pipe(output); + + archive.file(indexPath, { name: "index.js" }); + archive.finalize(); + }); +} + +createMockServiceZip().catch((err) => { + console.error("Package failed:", err); + process.exit(1); +}); diff --git a/mock-service/src/handlers/health.ts b/mock-service/src/handlers/health.ts new file mode 100644 index 00000000..3fd71a05 --- /dev/null +++ b/mock-service/src/handlers/health.ts @@ -0,0 +1,12 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { jsonResponse } from "../utils/response"; + +export const handleHealth = async ( + _event: APIGatewayProxyEvent, +): Promise => { + return jsonResponse(200, { + status: "ok", + service: "mock-service", + timestamp: new Date().toISOString(), + }); +}; diff --git a/mock-service/src/handlers/jwks.test.ts b/mock-service/src/handlers/jwks.test.ts new file mode 100644 index 00000000..c6039657 --- /dev/null +++ b/mock-service/src/handlers/jwks.test.ts @@ -0,0 +1,56 @@ +import { handleJwks, signMockJwt } from "./jwks"; +import { mockEvent } from "../test-utils/mock-event"; +import * as crypto from "crypto"; + +describe("handleJwks", () => { + it("returns a JWKS with one RSA key", async () => { + const result = await handleJwks(mockEvent({ httpMethod: "GET", path: "/mock/cognito/.well-known/jwks.json" })); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.keys).toHaveLength(1); + + const key = body.keys[0]; + expect(key.kty).toBe("RSA"); + expect(key.use).toBe("sig"); + expect(key.alg).toBe("RS256"); + expect(key.kid).toBe("mock-key-1"); + expect(key.n).toBeTruthy(); + expect(key.e).toBeTruthy(); + }); + + it("returns a Cache-Control header", async () => { + const result = await handleJwks(mockEvent({ httpMethod: "GET", path: "/mock/cognito/.well-known/jwks.json" })); + expect(result.headers?.["Cache-Control"]).toBe("public, max-age=3600"); + }); +}); + +describe("signMockJwt", () => { + it("produces a JWT that can be verified against the JWKS public key", async () => { + const payload = { sub: "test-user", iss: "mock-cognito", aud: "hometest", exp: Math.floor(Date.now() / 1000) + 3600 }; + const token = signMockJwt(payload); + + // Parse the JWT + const [headerB64, payloadB64, signatureB64] = token.split("."); + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); + const decoded = JSON.parse(Buffer.from(payloadB64, "base64url").toString()); + + expect(header.alg).toBe("RS256"); + expect(header.kid).toBe("mock-key-1"); + expect(decoded.sub).toBe("test-user"); + + // Verify the signature using the JWKS public key + const jwksResult = await handleJwks(mockEvent({ httpMethod: "GET", path: "/mock/cognito/.well-known/jwks.json" })); + const jwks = JSON.parse(jwksResult.body); + const publicKey = crypto.createPublicKey({ key: jwks.keys[0], format: "jwk" }); + + const isValid = crypto.verify( + "sha256", + Buffer.from(`${headerB64}.${payloadB64}`), + publicKey, + Buffer.from(signatureB64, "base64url"), + ); + + expect(isValid).toBe(true); + }); +}); diff --git a/mock-service/src/handlers/jwks.ts b/mock-service/src/handlers/jwks.ts new file mode 100644 index 00000000..10daab31 --- /dev/null +++ b/mock-service/src/handlers/jwks.ts @@ -0,0 +1,78 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { jsonResponse } from "../utils/response"; +import * as crypto from "crypto"; + +/** + * Mock Cognito JWKS endpoint. + * + * GET /mock/cognito/.well-known/jwks.json + * + * Returns a JSON Web Key Set containing a single RSA public key. + * The matching private key (in MOCK_JWKS_PRIVATE_KEY env var) can be used + * to sign test JWTs that will validate against this JWKS. + * + * On cold start, generates a fresh RSA key pair unless MOCK_JWKS_PRIVATE_KEY + * is provided — this lets all dev services share the same signing key. + */ + +let cachedKeyPair: { publicKey: crypto.KeyObject; privateKey: crypto.KeyObject } | null = null; + +const getKeyPair = (): { publicKey: crypto.KeyObject; privateKey: crypto.KeyObject } => { + if (cachedKeyPair) return cachedKeyPair; + + const envPrivateKey = process.env.MOCK_JWKS_PRIVATE_KEY; + + if (envPrivateKey) { + const privateKey = crypto.createPrivateKey(envPrivateKey); + const publicKey = crypto.createPublicKey(privateKey); + cachedKeyPair = { publicKey, privateKey }; + } else { + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + cachedKeyPair = { publicKey, privateKey }; + } + + return cachedKeyPair; +}; + +const KID = "mock-key-1"; + +export const handleJwks = async ( + _event: APIGatewayProxyEvent, +): Promise => { + const { publicKey } = getKeyPair(); + + const jwk = publicKey.export({ format: "jwk" }); + + return jsonResponse( + 200, + { + keys: [ + { + ...jwk, + kid: KID, + use: "sig", + alg: "RS256", + }, + ], + }, + { "Cache-Control": "public, max-age=3600" }, + ); +}; + +/** + * Utility: sign a JWT payload using the mock private key. + * Used by tests or companion scripts to generate valid tokens. + */ +export const signMockJwt = (payload: Record): string => { + const { privateKey } = getKeyPair(); + + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT", kid: KID })).toString( + "base64url", + ); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signature = crypto.sign("sha256", Buffer.from(`${header}.${body}`), privateKey); + + return `${header}.${body}.${signature.toString("base64url")}`; +}; diff --git a/mock-service/src/handlers/oauth-token.test.ts b/mock-service/src/handlers/oauth-token.test.ts new file mode 100644 index 00000000..a10d98c7 --- /dev/null +++ b/mock-service/src/handlers/oauth-token.test.ts @@ -0,0 +1,81 @@ +import { handleOAuthToken } from "./oauth-token"; +import { mockEvent } from "../test-utils/mock-event"; + +describe("handleOAuthToken", () => { + it("returns a valid Bearer token for correct client_credentials grant", async () => { + const result = await handleOAuthToken( + mockEvent({ + httpMethod: "POST", + path: "/mock/supplier/oauth/token", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "grant_type=client_credentials&client_id=supplier1&client_secret=s3cret", + }), + ); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.token_type).toBe("Bearer"); + expect(body.expires_in).toBe(3600); + expect(body.scope).toBe("orders results"); + expect(body.access_token).toBeTruthy(); + }); + + it("returns 400 for wrong grant_type", async () => { + const result = await handleOAuthToken( + mockEvent({ + httpMethod: "POST", + path: "/mock/supplier/oauth/token", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "grant_type=authorization_code&client_id=x&client_secret=y", + }), + ); + + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body).error).toBe("unsupported_grant_type"); + }); + + it("returns 400 for missing client_id/client_secret", async () => { + const result = await handleOAuthToken( + mockEvent({ + httpMethod: "POST", + path: "/mock/supplier/oauth/token", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "grant_type=client_credentials", + }), + ); + + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body).error).toBe("invalid_request"); + }); + + it("returns 400 for wrong Content-Type", async () => { + const result = await handleOAuthToken( + mockEvent({ + httpMethod: "POST", + path: "/mock/supplier/oauth/token", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ); + + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body).error).toBe("invalid_request"); + }); + + it("returns 401 when X-Mock-Status is 401", async () => { + const result = await handleOAuthToken( + mockEvent({ + httpMethod: "POST", + path: "/mock/supplier/oauth/token", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Mock-Status": "401", + }, + body: "grant_type=client_credentials&client_id=x&client_secret=y", + }), + ); + + expect(result.statusCode).toBe(401); + expect(JSON.parse(result.body).error).toBe("invalid_client"); + }); +}); diff --git a/mock-service/src/handlers/oauth-token.ts b/mock-service/src/handlers/oauth-token.ts new file mode 100644 index 00000000..48343021 --- /dev/null +++ b/mock-service/src/handlers/oauth-token.ts @@ -0,0 +1,78 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { jsonResponse } from "../utils/response"; + +/** + * Mock OAuth2 Client Credentials token endpoint. + * + * Mirrors the WireMock stub: POST /oauth/token + * Accepts any valid client_credentials grant and returns a static Bearer token. + * + * Supports X-Mock-Status header to force error scenarios: + * - "401" → invalid credentials + * - "400" → invalid grant type + */ +export const handleOAuthToken = async ( + event: APIGatewayProxyEvent, +): Promise => { + const mockStatus = event.headers?.["X-Mock-Status"] ?? event.headers?.["x-mock-status"]; + + if (mockStatus === "401") { + return jsonResponse( + 401, + { + error: "invalid_client", + error_description: "Client authentication failed", + }, + { "Cache-Control": "no-store", Pragma: "no-cache" }, + ); + } + + const body = event.body ?? ""; + const contentType = event.headers?.["Content-Type"] ?? event.headers?.["content-type"] ?? ""; + + if (!contentType.includes("application/x-www-form-urlencoded")) { + return jsonResponse(400, { + error: "invalid_request", + error_description: "Content-Type must be application/x-www-form-urlencoded", + }); + } + + const params = new URLSearchParams(body); + const grantType = params.get("grant_type"); + const clientId = params.get("client_id"); + const clientSecret = params.get("client_secret"); + + if (grantType !== "client_credentials") { + return jsonResponse( + 400, + { + error: "unsupported_grant_type", + error_description: `Grant type '${grantType ?? ""}' is not supported. Use 'client_credentials'.`, + }, + { "Cache-Control": "no-store", Pragma: "no-cache" }, + ); + } + + if (!clientId || !clientSecret) { + return jsonResponse( + 400, + { + error: "invalid_request", + error_description: "Missing required parameters: client_id, client_secret", + }, + { "Cache-Control": "no-store", Pragma: "no-cache" }, + ); + } + + return jsonResponse( + 200, + { + access_token: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdXBwbGllcl9jbGllbnQiLCJzY29wZSI6Im9yZGVycyByZXN1bHRzIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjk5OTk5OTk5OTl9.mock-signature", + token_type: "Bearer", + expires_in: 3600, + scope: "orders results", + }, + { "Cache-Control": "no-store", Pragma: "no-cache" }, + ); +}; diff --git a/mock-service/src/handlers/order.ts b/mock-service/src/handlers/order.ts new file mode 100644 index 00000000..b189b89e --- /dev/null +++ b/mock-service/src/handlers/order.ts @@ -0,0 +1,169 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { fhirResponse, jsonResponse } from "../utils/response"; +import { v4 as uuidv4 } from "uuid"; + +/** + * Mock Supplier Order endpoint. + * + * POST /mock/supplier/order → create order (FHIR ServiceRequest) + * GET /mock/supplier/order → get order status + * + * Supports X-Mock-Status header to force error scenarios: + * - "404" → order not found + * - "422" → unprocessable entity + * - "dispatched" / "confirmed" / "complete" → various order statuses + */ +export const handleOrder = async ( + event: APIGatewayProxyEvent, +): Promise => { + const mockStatus = event.headers?.["X-Mock-Status"] ?? event.headers?.["x-mock-status"]; + + if (event.httpMethod === "POST") { + return handleCreateOrder(event, mockStatus); + } + return handleGetOrder(event, mockStatus); +}; + +const handleCreateOrder = async ( + event: APIGatewayProxyEvent, + mockStatus: string | undefined, +): Promise => { + if (mockStatus === "422") { + return fhirResponse(422, { + resourceType: "OperationOutcome", + issue: [ + { + severity: "error", + code: "processing", + details: { text: "Unprocessable Entity" }, + diagnostics: "The order could not be processed due to validation errors", + }, + ], + }); + } + + // Validate FHIR content type + const contentType = event.headers?.["Content-Type"] ?? event.headers?.["content-type"] ?? ""; + if (!contentType.includes("application/fhir+json")) { + return jsonResponse(415, { + error: "Unsupported Media Type", + message: "Content-Type must be application/fhir+json", + }); + } + + // Parse request body to echo back patient details if present + let requestBody: Record = {}; + try { + requestBody = JSON.parse(event.body ?? "{}"); + } catch { + // use defaults + } + + const orderId = uuidv4(); + const contained = (requestBody.contained as Record[]) ?? [ + { + resourceType: "Patient", + id: "patient-1", + name: [{ use: "official", family: "Doe", given: ["John"], text: "John Doe" }], + telecom: [ + { system: "phone", value: "+447700900000", use: "mobile" }, + { system: "email", value: "john.doe@example.com", use: "home" }, + ], + address: [ + { + use: "home", + type: "both", + line: ["123 Main Street", "Flat 4B"], + city: "London", + postalCode: "SW1A 1AA", + country: "United Kingdom", + }, + ], + birthDate: "1990-01-01", + }, + ]; + + return fhirResponse(201, { + resourceType: "ServiceRequest", + id: orderId, + status: "active", + intent: "order", + code: { + coding: [ + { + system: "http://snomed.info/sct", + code: "31676001", + display: "HIV antigen test", + }, + ], + text: "HIV antigen test", + }, + contained, + subject: { reference: "#patient-1" }, + requester: { reference: "Organization/ORG001" }, + performer: [{ reference: "Organization/SUP001", display: "Test Supplier Ltd" }], + }); +}; + +const handleGetOrder = async ( + event: APIGatewayProxyEvent, + mockStatus: string | undefined, +): Promise => { + const orderId = event.queryStringParameters?.order_id ?? "018f6b6e-8b8e-7c2b-8f3b-8b8e7c2a8f3b"; + + if (mockStatus === "404") { + return fhirResponse(404, { + resourceType: "OperationOutcome", + issue: [ + { + severity: "error", + code: "not-found", + details: { text: "Resource Not Found" }, + diagnostics: "The requested resource could not be found", + }, + ], + }); + } + + // Map mock status to FHIR task statuses + const statusMap: Record = { + dispatched: "in-progress", + confirmed: "accepted", + complete: "completed", + }; + const fhirStatus = statusMap[mockStatus ?? ""] ?? "active"; + + return fhirResponse(200, { + resourceType: "Bundle", + type: "searchset", + total: 1, + entry: [ + { + fullUrl: `urn:uuid:${orderId}`, + resource: { + resourceType: "ServiceRequest", + id: orderId, + identifier: [ + { + system: "https://fhir.hometest.nhs.uk/Id/order-id", + value: orderId, + }, + ], + status: fhirStatus, + intent: "order", + code: { + coding: [ + { + system: "http://snomed.info/sct", + code: "31676001", + display: "HIV antigen test", + }, + ], + }, + subject: { reference: "#patient-1" }, + performer: [{ reference: "Organization/SUP001", display: "Test Supplier Ltd" }], + }, + }, + ], + }); +}; diff --git a/mock-service/src/handlers/postcode.ts b/mock-service/src/handlers/postcode.ts new file mode 100644 index 00000000..35324c56 --- /dev/null +++ b/mock-service/src/handlers/postcode.ts @@ -0,0 +1,65 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { jsonResponse } from "../utils/response"; + +/** + * Mock Postcode Lookup endpoint. + * + * GET /mock/postcode/{postcode} + * + * Returns a fake local authority mapping for the given postcode. + * The response shape matches the external postcode lookup API used by + * the postcode-lookup-lambda. + * + * Supports X-Mock-Status header: + * - "404" → postcode not found + * - "400" → invalid postcode + */ + +/** Known postcodes for deterministic testing */ +const POSTCODE_MAP: Record = { + "SW1A 1AA": { code: "E09000033", name: "City of Westminster" }, + "SW1A1AA": { code: "E09000033", name: "City of Westminster" }, + "EC1A 1BB": { code: "E09000001", name: "City of London" }, + "EC1A1BB": { code: "E09000001", name: "City of London" }, + "LS1 1UR": { code: "E08000035", name: "Leeds" }, + "LS11UR": { code: "E08000035", name: "Leeds" }, + "M1 1AA": { code: "E08000003", name: "Manchester" }, + "M11AA": { code: "E08000003", name: "Manchester" }, + "B1 1BB": { code: "E08000025", name: "Birmingham" }, + "B11BB": { code: "E08000025", name: "Birmingham" }, +}; + +const DEFAULT_LA = { code: "E09000999", name: "Mock Local Authority" }; + +export const handlePostcode = async ( + event: APIGatewayProxyEvent, +): Promise => { + const mockStatus = event.headers?.["X-Mock-Status"] ?? event.headers?.["x-mock-status"]; + + // Extract postcode from path: /mock/postcode/{postcode} + const pathMatch = event.path.match(/^\/mock\/postcode\/(.+)$/); + const postcode = decodeURIComponent(pathMatch?.[1] ?? "").toUpperCase().trim(); + + if (mockStatus === "400" || !postcode) { + return jsonResponse(400, { + error: "Bad Request", + message: "Invalid postcode format", + }); + } + + if (mockStatus === "404") { + return jsonResponse(404, { + error: "Not Found", + message: `Postcode '${postcode}' not found`, + }); + } + + const localAuthority = POSTCODE_MAP[postcode] ?? DEFAULT_LA; + + return jsonResponse(200, { + postcode, + localAuthority, + country: "England", + region: "Mock Region", + }); +}; diff --git a/mock-service/src/handlers/results.ts b/mock-service/src/handlers/results.ts new file mode 100644 index 00000000..771c801b --- /dev/null +++ b/mock-service/src/handlers/results.ts @@ -0,0 +1,117 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { fhirResponse } from "../utils/response"; +import { v4 as uuidv4 } from "uuid"; + +/** + * Mock Supplier Results endpoint. + * + * GET /mock/supplier/results?order_uid= + * + * Returns a FHIR Bundle with an Observation resource. + * + * Supports X-Mock-Status header: + * - "404" → results not found + * - "400" → invalid request + */ +export const handleResults = async ( + event: APIGatewayProxyEvent, +): Promise => { + const mockStatus = event.headers?.["X-Mock-Status"] ?? event.headers?.["x-mock-status"]; + const correlationId = event.headers?.["X-Correlation-ID"] ?? event.headers?.["x-correlation-id"]; + const orderUid = event.queryStringParameters?.order_uid; + + if (mockStatus === "400") { + return fhirResponse(400, { + resourceType: "OperationOutcome", + issue: [ + { + severity: "error", + code: "invalid", + details: { text: "Invalid Request" }, + diagnostics: "The request was invalid — order_uid is required", + }, + ], + }); + } + + if (mockStatus === "404" || !orderUid) { + return fhirResponse(404, { + resourceType: "OperationOutcome", + issue: [ + { + severity: "error", + code: "not-found", + details: { text: "Results Not Found" }, + diagnostics: "No test results were found for the specified order", + }, + ], + }); + } + + const observationId = uuidv4(); + const now = new Date(); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); + + return fhirResponse(200, { + resourceType: "Bundle", + type: "searchset", + total: 1, + link: [ + { + relation: "self", + url: `/results?order_uid=${orderUid}`, + }, + ], + entry: [ + { + fullUrl: `urn:uuid:${observationId}`, + resource: { + resourceType: "Observation", + id: observationId, + meta: { + ...(correlationId ? { tag: [{ code: correlationId }] } : {}), + }, + basedOn: [{ reference: `ServiceRequest/${orderUid}` }], + status: "final", + code: { + coding: [ + { + system: "http://snomed.info/sct", + code: "31676001", + display: "HIV antigen test", + }, + ], + text: "HIV antigen test", + }, + subject: { reference: "Patient/123e4567-e89b-12d3-a456-426614174000" }, + effectiveDateTime: `${twoDaysAgo.toISOString().split("T")[0]}T15:45:00Z`, + issued: `${oneDayAgo.toISOString().split("T")[0]}T16:00:00Z`, + performer: [ + { reference: "Organization/SUP001", display: "Test Supplier Ltd" }, + ], + valueCodeableConcept: { + coding: [ + { + system: "http://snomed.info/sct", + code: "260415000", + display: "Not detected", + }, + ], + }, + interpretation: [ + { + coding: [ + { + system: "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", + code: "N", + display: "Normal", + }, + ], + }, + ], + }, + }, + ], + }); +}; diff --git a/mock-service/src/index.ts b/mock-service/src/index.ts new file mode 100644 index 00000000..cbf1e89c --- /dev/null +++ b/mock-service/src/index.ts @@ -0,0 +1,33 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import middy from "@middy/core"; +import cors from "@middy/http-cors"; +import httpErrorHandler from "@middy/http-error-handler"; +import { route } from "./router"; + +/** + * Mock Service Lambda — routes incoming API Gateway requests to the appropriate + * stub handler based on path prefix: + * + * /mock/supplier/oauth/token → OAuth2 token endpoint + * /mock/supplier/order → Order placement / status + * /mock/supplier/results → Test results lookup + * /mock/cognito/.well-known/jwks → JWKS public key set + * /mock/postcode/{postcode} → Postcode → local authority lookup + * /mock/health → Health check + */ +const lambdaHandler = async ( + event: APIGatewayProxyEvent, +): Promise => { + console.log("mock-service", { + method: event.httpMethod, + path: event.path, + queryStringParameters: event.queryStringParameters, + }); + + return route(event); +}; + +export const handler = middy() + .use(cors({ origins: ["*"] })) + .use(httpErrorHandler()) + .handler(lambdaHandler); diff --git a/mock-service/src/router.test.ts b/mock-service/src/router.test.ts new file mode 100644 index 00000000..2c37c40e --- /dev/null +++ b/mock-service/src/router.test.ts @@ -0,0 +1,76 @@ +import { route } from "./router"; +import { mockEvent } from "./test-utils/mock-event"; + +describe("router", () => { + it("routes GET /mock/health to health handler", async () => { + const result = await route(mockEvent({ httpMethod: "GET", path: "/mock/health" })); + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.status).toBe("ok"); + expect(body.service).toBe("mock-service"); + }); + + it("routes GET /mock/cognito/.well-known/jwks.json to JWKS handler", async () => { + const result = await route(mockEvent({ httpMethod: "GET", path: "/mock/cognito/.well-known/jwks.json" })); + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.keys).toHaveLength(1); + expect(body.keys[0].kty).toBe("RSA"); + }); + + it("routes POST /mock/supplier/oauth/token to OAuth handler", async () => { + const result = await route( + mockEvent({ + httpMethod: "POST", + path: "/mock/supplier/oauth/token", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "grant_type=client_credentials&client_id=test&client_secret=secret", + }), + ); + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.token_type).toBe("Bearer"); + }); + + it("routes POST /mock/supplier/order to order handler", async () => { + const result = await route( + mockEvent({ + httpMethod: "POST", + path: "/mock/supplier/order", + headers: { "Content-Type": "application/fhir+json" }, + body: JSON.stringify({ resourceType: "ServiceRequest" }), + }), + ); + expect(result.statusCode).toBe(201); + const body = JSON.parse(result.body); + expect(body.resourceType).toBe("ServiceRequest"); + }); + + it("routes GET /mock/supplier/results with order_uid", async () => { + const result = await route( + mockEvent({ + httpMethod: "GET", + path: "/mock/supplier/results", + headers: { "X-Correlation-ID": "test-123" }, + queryStringParameters: { order_uid: "550e8400-e29b-41d4-a716-446655440000" }, + }), + ); + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.resourceType).toBe("Bundle"); + }); + + it("routes GET /mock/postcode/{postcode}", async () => { + const result = await route(mockEvent({ httpMethod: "GET", path: "/mock/postcode/SW1A1AA" })); + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.localAuthority.name).toBe("City of Westminster"); + }); + + it("returns 404 for unregistered routes", async () => { + const result = await route(mockEvent({ httpMethod: "GET", path: "/unknown" })); + expect(result.statusCode).toBe(404); + const body = JSON.parse(result.body); + expect(body.error).toBe("Not Found"); + }); +}); diff --git a/mock-service/src/router.ts b/mock-service/src/router.ts new file mode 100644 index 00000000..f9ab333b --- /dev/null +++ b/mock-service/src/router.ts @@ -0,0 +1,55 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { handleOAuthToken } from "./handlers/oauth-token"; +import { handleOrder } from "./handlers/order"; +import { handleResults } from "./handlers/results"; +import { handleJwks } from "./handlers/jwks"; +import { handlePostcode } from "./handlers/postcode"; +import { handleHealth } from "./handlers/health"; +import { jsonResponse } from "./utils/response"; + +interface Route { + /** HTTP method (GET, POST, ANY) */ + method: string; + /** Regex matched against event.path */ + pattern: RegExp; + handler: (event: APIGatewayProxyEvent) => Promise; +} + +const routes: Route[] = [ + // Health + { method: "GET", pattern: /^\/mock\/health$/, handler: handleHealth }, + + // Cognito JWKS + { method: "GET", pattern: /^\/mock\/cognito\/.well-known\/jwks(\.json)?$/, handler: handleJwks }, + + // Supplier OAuth2 + { method: "POST", pattern: /^\/mock\/supplier\/(oauth\/token|api\/oauth)$/, handler: handleOAuthToken }, + + // Supplier Order + { method: "POST", pattern: /^\/mock\/supplier\/order$/, handler: handleOrder }, + { method: "GET", pattern: /^\/mock\/supplier\/order$/, handler: handleOrder }, + + // Supplier Results + { method: "GET", pattern: /^\/mock\/supplier\/(results|api\/results|nhs_home_test\/results)$/, handler: handleResults }, + + // Postcode lookup + { method: "GET", pattern: /^\/mock\/postcode\/([A-Za-z0-9 ]+)$/, handler: handlePostcode }, +]; + +export const route = async ( + event: APIGatewayProxyEvent, +): Promise => { + const { httpMethod, path } = event; + + for (const r of routes) { + if ((r.method === "ANY" || r.method === httpMethod) && r.pattern.test(path)) { + return r.handler(event); + } + } + + return jsonResponse(404, { + error: "Not Found", + message: `No mock registered for ${httpMethod} ${path}`, + availableRoutes: routes.map((r) => `${r.method} ${r.pattern.source}`), + }); +}; diff --git a/mock-service/src/test-utils/mock-event.ts b/mock-service/src/test-utils/mock-event.ts new file mode 100644 index 00000000..00b2959d --- /dev/null +++ b/mock-service/src/test-utils/mock-event.ts @@ -0,0 +1,49 @@ +import { APIGatewayProxyEvent } from "aws-lambda"; + +/** + * Creates a minimal APIGatewayProxyEvent for testing. + */ +export const mockEvent = (overrides: Partial = {}): APIGatewayProxyEvent => ({ + httpMethod: "GET", + path: "/", + body: null, + headers: {}, + multiValueHeaders: {}, + isBase64Encoded: false, + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: { + accountId: "123456789012", + apiId: "mock-api", + authorizer: null, + protocol: "HTTP/1.1", + httpMethod: "GET", + identity: { + accessKey: null, + accountId: null, + apiKey: null, + apiKeyId: null, + caller: null, + clientCert: null, + cognitoAuthenticationProvider: null, + cognitoAuthenticationType: null, + cognitoIdentityId: null, + cognitoIdentityPoolId: null, + principalOrgId: null, + sourceIp: "127.0.0.1", + user: null, + userAgent: "jest-test", + userArn: null, + }, + path: "/", + stage: "test", + requestId: "mock-request-id", + requestTimeEpoch: Date.now(), + resourceId: "mock", + resourcePath: "/", + }, + resource: "/", + ...overrides, +}); diff --git a/mock-service/src/utils/response.ts b/mock-service/src/utils/response.ts new file mode 100644 index 00000000..5466a416 --- /dev/null +++ b/mock-service/src/utils/response.ts @@ -0,0 +1,25 @@ +import { APIGatewayProxyResult } from "aws-lambda"; + +export const jsonResponse = ( + statusCode: number, + body: Record, + headers?: Record, +): APIGatewayProxyResult => ({ + statusCode, + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), +}); + +export const fhirResponse = ( + statusCode: number, + body: Record, +): APIGatewayProxyResult => ({ + statusCode, + headers: { + "Content-Type": "application/fhir+json", + }, + body: JSON.stringify(body), +}); diff --git a/mock-service/tsconfig.json b/mock-service/tsconfig.json new file mode 100644 index 00000000..2b2ec183 --- /dev/null +++ b/mock-service/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "lib": ["ES2024"], + "baseUrl": ".", + "moduleResolution": "node", + "declaration": true, + "strict": true, + "noImplicitAny": false, + "esModuleInterop": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "resolveJsonModule": true, + "typeRoots": ["./node_modules/@types"], + "types": ["node", "jest", "aws-lambda"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index e84cc5f6..95ae895a 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,19 @@ "description": "NHS Home Test Service", "private": true, "scripts": { - "postinstall": "npm --prefix ui install && npm --prefix lambdas install && npm --prefix tests install", + "postinstall": "npm --prefix ui install && npm --prefix lambdas install && npm --prefix mock-service install && npm --prefix tests install", "test": "npm --prefix ui run test && npm --prefix lambdas run test", "test:playwright": "UI_BASE_URL=$(terraform -chdir=local-environment/infra output -raw ui_url) API_BASE_URL=$(terraform -chdir=local-environment/infra output -raw api_base_url) npm --prefix tests run test:chrome", "build:lambdas": "npm --prefix lambdas run build", "package:lambdas": "npm --prefix lambdas run package", + "build:mock-service": "npm --prefix mock-service run build", + "package:mock-service": "npm --prefix mock-service run package", "start": "npm ci && npm run local:start", "stop": "npm run local:stop", "local:start": "npm run local:backend:start && npm run local:terraform:init && npm run local:deploy && npm run local:frontend:start", "local:stop": "npm run local:terraform:destroy && COMPOSE_PROFILES=* npm run local:compose:down", "local:restart": "npm run local:stop && npm run local:start", - "local:deploy": "NODE_ENV=development npm run build:lambdas && npm run package:lambdas && npm run local:terraform:apply", + "local:deploy": "NODE_ENV=development npm run build:lambdas && npm run package:lambdas && npm run build:mock-service && npm run package:mock-service && npm run local:terraform:apply && npm run local:update-supplier-url", "local:backend:start": "COMPOSE_PROFILES=backend npm run local:compose:up && npm run local:service:db:migrate", "local:frontend:start": "npm run local:terraform:env && COMPOSE_PROFILES=frontend npm run local:compose:up", "local:service:ui:start": "npm run local:compose:up -- ui", @@ -30,6 +32,7 @@ "local:terraform:apply": "npm run local:terraform -- apply -auto-approve", "local:terraform:destroy": "npm run local:terraform -- destroy -auto-approve", "local:terraform:env": "bash scripts/terraform/post-apply-env-update.sh", + "local:update-supplier-url": "bash scripts/terraform/post-apply-update-supplier-url.sh", "local:compose": "docker compose -f local-environment/docker-compose.yml", "local:compose:up": "npm run local:compose -- up -d", "local:compose:down": "npm run local:compose -- down", diff --git a/scripts/terraform/post-apply-update-supplier-url.sh b/scripts/terraform/post-apply-update-supplier-url.sh new file mode 100755 index 00000000..e19184e7 --- /dev/null +++ b/scripts/terraform/post-apply-update-supplier-url.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +# Updates supplier service_url in the database to point at the mock-service +# Lambda running on LocalStack, after Terraform has deployed it. +# +# Called automatically by `npm run local:deploy` after terraform apply. + +MOCK_SUPPLIER_URL=$(terraform -chdir=local-environment/infra output -raw mock_supplier_base_url 2>/dev/null || echo "") + +if [[ -z "$MOCK_SUPPLIER_URL" ]]; then + echo "WARNING: Could not read mock_supplier_base_url from terraform outputs." + echo " Supplier service_url will not be updated." + exit 0 +fi + +echo "Updating supplier service_url → $MOCK_SUPPLIER_URL" + +docker exec postgres-db psql \ + "postgresql://app_user:STRONG_APP_PASSWORD@localhost:5432/local_hometest_db" \ + -c "SET search_path TO hometest; UPDATE supplier SET service_url = '${MOCK_SUPPLIER_URL}' WHERE service_url LIKE '%mock-service-placeholder%' OR service_url LIKE '%wiremock%';" + +echo "Supplier service_url updated successfully." From 566e9f2af6b68ab83cb87c3a6884703b9e810f62 Mon Sep 17 00:00:00 2001 From: Mikolaj Miotk Date: Mon, 16 Mar 2026 13:44:58 +0100 Subject: [PATCH 2/4] Add mock draft v2 --- database/03-seed-hometest-data.sql | 4 +- local-environment/docker-compose.yml | 12 --- local-environment/infra/main.tf | 105 +++++++++++++++++++++++++++ local-environment/infra/outputs.tf | 31 +++++++- 4 files changed, 137 insertions(+), 15 deletions(-) diff --git a/database/03-seed-hometest-data.sql b/database/03-seed-hometest-data.sql index 02fa0bfb..c90d0b03 100644 --- a/database/03-seed-hometest-data.sql +++ b/database/03-seed-hometest-data.sql @@ -23,7 +23,7 @@ INSERT INTO supplier ( VALUES ( 'c1a2b3c4-1234-4def-8abc-123456789abc', 'Preventx', - 'http://wiremock:8080', + 'http://mock-service-placeholder', 'https://www.preventx.com/', 'test_supplier_client_secret', 'preventx-client-id', @@ -49,7 +49,7 @@ INSERT INTO supplier ( VALUES ( 'd2b3c4d5-2345-4abc-8def-23456789abcd', 'SH:24', - 'http://wiremock:8080', + 'http://mock-service-placeholder', 'https://sh24.org.uk/', 'test_supplier_client_secret', 'sh24-client-id', diff --git a/local-environment/docker-compose.yml b/local-environment/docker-compose.yml index 9ef988e8..90bc4984 100644 --- a/local-environment/docker-compose.yml +++ b/local-environment/docker-compose.yml @@ -52,18 +52,6 @@ services: timeout: 5s retries: 10 - wiremock: - image: wiremock/wiremock:latest - container_name: wiremock - profiles: - - backend - ports: - - "8080:8080" - volumes: - - ./wiremock/mappings:/home/wiremock/mappings - - ./wiremock/__files:/home/wiremock/__files - command: ["--verbose"] - db-migrate: build: context: ./scripts/database diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 597cf094..4c7bd56f 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -538,3 +538,108 @@ resource "aws_api_gateway_stage" "api_stage" { data "external" "supplier_id" { program = ["bash", "${path.module}/../scripts/localstack/get_supplier_id.sh"] } + +################################################################################ +# Mock Service — replaces WireMock container +# Runs as a Lambda on LocalStack with its own API Gateway (proxy integration) +################################################################################ + +resource "aws_api_gateway_rest_api" "mock_api" { + name = "${var.project_name}-mock-api" + description = "Mock API for supplier, Cognito JWKS, postcode lookup" +} + +resource "aws_lambda_function" "mock_service" { + filename = "${path.module}/../../mock-service/dist/mock-service-lambda.zip" + function_name = "${var.project_name}-mock-service" + role = aws_iam_role.lambda_role.arn + handler = "index.handler" + runtime = "nodejs24.x" + source_code_hash = filebase64sha256("${path.module}/../../mock-service/dist/mock-service-lambda.zip") + timeout = 30 + + environment { + variables = { + NODE_OPTIONS = "--enable-source-maps" + ENVIRONMENT = var.environment + } + } + + depends_on = [aws_iam_role_policy_attachment.lambda_basic] +} + +# Proxy resource: {proxy+} +resource "aws_api_gateway_resource" "mock_proxy" { + rest_api_id = aws_api_gateway_rest_api.mock_api.id + parent_id = aws_api_gateway_rest_api.mock_api.root_resource_id + path_part = "{proxy+}" +} + +resource "aws_api_gateway_method" "mock_proxy_any" { + rest_api_id = aws_api_gateway_rest_api.mock_api.id + resource_id = aws_api_gateway_resource.mock_proxy.id + http_method = "ANY" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "mock_proxy" { + rest_api_id = aws_api_gateway_rest_api.mock_api.id + resource_id = aws_api_gateway_resource.mock_proxy.id + http_method = aws_api_gateway_method.mock_proxy_any.http_method + integration_http_method = "POST" + type = "AWS_PROXY" + uri = "arn:aws:apigateway:${var.aws_region}:lambda:path/2015-03-31/functions/${aws_lambda_function.mock_service.arn}/invocations" +} + +resource "aws_api_gateway_method" "mock_root" { + rest_api_id = aws_api_gateway_rest_api.mock_api.id + resource_id = aws_api_gateway_rest_api.mock_api.root_resource_id + http_method = "ANY" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "mock_root" { + rest_api_id = aws_api_gateway_rest_api.mock_api.id + resource_id = aws_api_gateway_rest_api.mock_api.root_resource_id + http_method = aws_api_gateway_method.mock_root.http_method + integration_http_method = "POST" + type = "AWS_PROXY" + uri = "arn:aws:apigateway:${var.aws_region}:lambda:path/2015-03-31/functions/${aws_lambda_function.mock_service.arn}/invocations" +} + +resource "aws_lambda_permission" "mock_api_gateway" { + statement_id = "AllowMockAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.mock_service.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_api_gateway_rest_api.mock_api.execution_arn}/*/*" +} + +resource "aws_api_gateway_deployment" "mock_deployment" { + rest_api_id = aws_api_gateway_rest_api.mock_api.id + + depends_on = [ + aws_api_gateway_integration.mock_proxy, + aws_api_gateway_integration.mock_root, + ] + + triggers = { + redeployment = sha1(jsonencode([ + aws_api_gateway_resource.mock_proxy.id, + aws_api_gateway_method.mock_proxy_any.id, + aws_api_gateway_integration.mock_proxy.id, + aws_api_gateway_method.mock_root.id, + aws_api_gateway_integration.mock_root.id, + ])) + } + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_api_gateway_stage" "mock_stage" { + deployment_id = aws_api_gateway_deployment.mock_deployment.id + rest_api_id = aws_api_gateway_rest_api.mock_api.id + stage_name = var.environment +} diff --git a/local-environment/infra/outputs.tf b/local-environment/infra/outputs.tf index 3acb736a..7e884ee8 100644 --- a/local-environment/infra/outputs.tf +++ b/local-environment/infra/outputs.tf @@ -65,7 +65,7 @@ output "postcode_lookup_endpoint" { output "seed_supplier_id" { value = data.external.supplier_id.result["supplier_id"] - description = "The supplier_id of the seeded supplier with service_url http://wiremock:8080" + description = "The supplier_id of the seeded supplier (service_url points at mock-service Lambda on LocalStack)" } output "order_placement_queue_url" { @@ -87,3 +87,32 @@ output "order_status_endpoint" { description = "Order Status Lambda endpoint" value = module.order_status_lambda.localstack_endpoint_url } + +################################################################################ +# Mock Service Outputs +################################################################################ + +output "mock_api_base_url" { + description = "Base URL for the mock API on LocalStack" + value = "http://localhost:4566/_aws/execute-api/${aws_api_gateway_rest_api.mock_api.id}/${var.environment}" +} + +output "mock_supplier_base_url" { + description = "Supplier mock base URL (use as service_url in supplier table)" + value = "http://localstack-main:4566/_aws/execute-api/${aws_api_gateway_rest_api.mock_api.id}/${var.environment}/mock/supplier" +} + +output "mock_supplier_base_url_host" { + description = "Supplier mock base URL accessible from host machine" + value = "http://localhost:4566/_aws/execute-api/${aws_api_gateway_rest_api.mock_api.id}/${var.environment}/mock/supplier" +} + +output "mock_cognito_jwks_url" { + description = "Mock Cognito JWKS URL" + value = "http://localhost:4566/_aws/execute-api/${aws_api_gateway_rest_api.mock_api.id}/${var.environment}/mock/cognito/.well-known/jwks.json" +} + +output "mock_postcode_base_url" { + description = "Mock postcode lookup base URL" + value = "http://localhost:4566/_aws/execute-api/${aws_api_gateway_rest_api.mock_api.id}/${var.environment}/mock/postcode" +} From 0918627c74416eecbada98d450fcfa23915e0eeb Mon Sep 17 00:00:00 2001 From: Mikolaj Miotk Date: Mon, 16 Mar 2026 15:17:16 +0100 Subject: [PATCH 3/4] Add mock draft version 2 --- .../wiremock/mappings/health.json | 17 + local-environment/wiremock/mappings/jwks.json | 26 ++ .../wiremock/mappings/postcode-lookup.json | 22 ++ .../wiremock/mappings/postcode-not-found.json | 17 + mock-service/README.md | 114 +++---- mock-service/package.json | 11 +- mock-service/scripts/build.ts | 13 +- mock-service/scripts/package.ts | 9 + mock-service/src/handlers/health.ts | 12 - mock-service/src/handlers/jwks.test.ts | 56 ---- mock-service/src/handlers/jwks.ts | 78 ----- mock-service/src/handlers/oauth-token.test.ts | 81 ----- mock-service/src/handlers/oauth-token.ts | 78 ----- mock-service/src/handlers/order.ts | 169 ---------- mock-service/src/handlers/postcode.ts | 65 ---- mock-service/src/handlers/results.ts | 117 ------- mock-service/src/index.ts | 92 +++++- mock-service/src/router.test.ts | 76 ----- mock-service/src/router.ts | 55 ---- mock-service/src/stub-matcher.test.ts | 166 ++++++++++ mock-service/src/stub-matcher.ts | 297 ++++++++++++++++++ mock-service/src/template-engine.test.ts | 40 +++ mock-service/src/template-engine.ts | 87 +++++ mock-service/src/test-utils/mock-event.ts | 49 --- mock-service/src/utils/response.ts | 25 -- 25 files changed, 833 insertions(+), 939 deletions(-) create mode 100644 local-environment/wiremock/mappings/health.json create mode 100644 local-environment/wiremock/mappings/jwks.json create mode 100644 local-environment/wiremock/mappings/postcode-lookup.json create mode 100644 local-environment/wiremock/mappings/postcode-not-found.json delete mode 100644 mock-service/src/handlers/health.ts delete mode 100644 mock-service/src/handlers/jwks.test.ts delete mode 100644 mock-service/src/handlers/jwks.ts delete mode 100644 mock-service/src/handlers/oauth-token.test.ts delete mode 100644 mock-service/src/handlers/oauth-token.ts delete mode 100644 mock-service/src/handlers/order.ts delete mode 100644 mock-service/src/handlers/postcode.ts delete mode 100644 mock-service/src/handlers/results.ts delete mode 100644 mock-service/src/router.test.ts delete mode 100644 mock-service/src/router.ts create mode 100644 mock-service/src/stub-matcher.test.ts create mode 100644 mock-service/src/stub-matcher.ts create mode 100644 mock-service/src/template-engine.test.ts create mode 100644 mock-service/src/template-engine.ts delete mode 100644 mock-service/src/test-utils/mock-event.ts delete mode 100644 mock-service/src/utils/response.ts diff --git a/local-environment/wiremock/mappings/health.json b/local-environment/wiremock/mappings/health.json new file mode 100644 index 00000000..9a747f82 --- /dev/null +++ b/local-environment/wiremock/mappings/health.json @@ -0,0 +1,17 @@ +{ + "priority": 1, + "request": { + "method": "GET", + "urlPath": "/health" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "status": "ok", + "service": "mock-service" + } + } +} diff --git a/local-environment/wiremock/mappings/jwks.json b/local-environment/wiremock/mappings/jwks.json new file mode 100644 index 00000000..c3dc0d76 --- /dev/null +++ b/local-environment/wiremock/mappings/jwks.json @@ -0,0 +1,26 @@ +{ + "priority": 1, + "request": { + "method": "GET", + "urlPathPattern": "/\\.well-known/jwks(\\.json)?" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600" + }, + "jsonBody": { + "keys": [ + { + "kty": "RSA", + "kid": "mock-key-1", + "use": "sig", + "alg": "RS256", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB" + } + ] + } + } +} diff --git a/local-environment/wiremock/mappings/postcode-lookup.json b/local-environment/wiremock/mappings/postcode-lookup.json new file mode 100644 index 00000000..811f6d0a --- /dev/null +++ b/local-environment/wiremock/mappings/postcode-lookup.json @@ -0,0 +1,22 @@ +{ + "priority": 5, + "request": { + "method": "GET", + "urlPathPattern": "/postcode/[A-Za-z0-9%20 ]+" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "postcode": "SW1A 1AA", + "localAuthority": { + "code": "E09000033", + "name": "City of Westminster" + }, + "country": "England", + "region": "London" + } + } +} diff --git a/local-environment/wiremock/mappings/postcode-not-found.json b/local-environment/wiremock/mappings/postcode-not-found.json new file mode 100644 index 00000000..1501485e --- /dev/null +++ b/local-environment/wiremock/mappings/postcode-not-found.json @@ -0,0 +1,17 @@ +{ + "priority": 3, + "request": { + "method": "GET", + "urlPathPattern": "/postcode/INVALID.*" + }, + "response": { + "status": 404, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "error": "Not Found", + "message": "Postcode not found" + } + } +} diff --git a/mock-service/README.md b/mock-service/README.md index 98561021..d40b674e 100644 --- a/mock-service/README.md +++ b/mock-service/README.md @@ -1,45 +1,58 @@ # Mock Service -Lambda-hosted mock API for dev/test environments. Replaces WireMock Docker with a serverless deployment that can be shared across dev environments on AWS. - -## What it mocks - -| Route | Purpose | Replaces | -|---|---|---| -| `GET /mock/health` | Health check | — | -| `POST /mock/supplier/oauth/token` | OAuth2 client_credentials grant | WireMock `oauth-token.json` | -| `POST /mock/supplier/order` | Create FHIR ServiceRequest order | WireMock `order-success.json` | -| `GET /mock/supplier/order` | Get order status | WireMock `order-confirmed.json` etc. | -| `GET /mock/supplier/results` | Get test results (FHIR Observation) | WireMock `results-success.json` | -| `GET /mock/cognito/.well-known/jwks.json` | JWKS public key set | Not previously mocked | -| `GET /mock/postcode/{postcode}` | Postcode → local authority | Not previously mocked | - -## Controlling responses - -Send the `X-Mock-Status` header to force specific error scenarios: - -```bash -# OAuth: force 401 (invalid credentials) -curl -X POST .../mock/supplier/oauth/token -H "X-Mock-Status: 401" ... - -# Order: force 404 (not found) or 422 (unprocessable) -curl .../mock/supplier/order?order_id=xxx -H "X-Mock-Status: 404" - -# Order: force status variant (dispatched, confirmed, complete) -curl .../mock/supplier/order?order_id=xxx -H "X-Mock-Status: dispatched" - -# Results: force 404 (not found) or 400 (invalid) -curl .../mock/supplier/results?order_uid=xxx -H "X-Mock-Status: 404" +Lambda-hosted WireMock-compatible stub runner for dev/test environments. Reads the same WireMock JSON mapping files from `local-environment/wiremock/mappings/` and serves them via a single Lambda function behind API Gateway. + +The JSON mapping files are the **single source of truth** — no per-endpoint TypeScript handlers. To add or change a mock, edit a JSON stub file. + +## How it works + +1. At build time, all WireMock JSON mapping files are copied from `../local-environment/wiremock/mappings/` into the Lambda bundle. +2. On cold start, the Lambda loads every `.json` file from the bundled `mappings/` directory. +3. For each incoming request, the stub matcher evaluates WireMock matching rules (method, URL path/pattern, headers, query parameters, body patterns) and returns the first matching response (respecting priority). +4. Response templates (`{{randomValue type='UUID'}}`, `{{now}}`) are rendered before returning. + +## Supported WireMock features + +| Feature | Example | +|---|---| +| Exact URL path | `"urlPath": "/oauth/token"` | +| Regex URL path | `"urlPathPattern": "/order/.*"` | +| Method matching | `"method": "POST"` | +| Header matching | `"contains"`, `"matches"` (regex), `"equalTo"` | +| Query parameter matching | `"matches"` (regex), `"equalTo"`, `"absent": true` | +| Body patterns | `"matches"` (regex), `"matchesJsonPath"` with `"absent": true` | +| Priority | `"priority": 1` (lower = matched first) | +| JSON response body | `"jsonBody": { ... }` | +| String response body | `"body": "..."` | +| Response headers | `"headers": { "Content-Type": "..." }` | +| Response templating | `{{randomValue type='UUID'}}`, `{{now}}`, `{{now offset='-2 days' format='yyyy-MM-dd'}}` | + +## Adding or changing mocks + +Drop a new JSON file into `local-environment/wiremock/mappings/`: + +```json +{ + "request": { + "method": "GET", + "urlPath": "/my-new-endpoint" + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { "message": "hello" } + } +} ``` -## JWKS / JWT signing +Rebuild the mock-service to pick up the change. No TypeScript code changes needed. -The `/mock/cognito/.well-known/jwks.json` endpoint returns an RSA public key. +## URL path prefixes -- **Auto-generated**: On cold start, a fresh RSA key pair is generated. The public key is served at the JWKS endpoint, and the private key stays in memory. -- **Shared key**: Set `MOCK_JWKS_PRIVATE_KEY` (PEM-encoded RSA private key) as a Lambda env var so all dev services use the same signing key. +API Gateway routes requests under `/mock/supplier/` and `/mock/cognito/` prefixes. The Lambda strips these before matching against stub files: -The `signMockJwt()` function (exported from `src/handlers/jwks.ts`) signs payloads with the private key for use in tests. +- `/mock/supplier/oauth/token` → matches stubs with `"urlPath": "/oauth/token"` +- `/mock/cognito/.well-known/jwks.json` → matches stubs with `"urlPath": "/.well-known/jwks.json"` ## Local development @@ -47,8 +60,8 @@ The `signMockJwt()` function (exported from `src/handlers/jwks.ts`) signs payloa cd mock-service npm install npm test # run unit tests -npm run build # esbuild → dist/mock-service-lambda/index.js -npm run package # zip → dist/mock-service-lambda.zip +npm run build # esbuild → dist/mock-service-lambda/index.js + mappings/ +npm run package # zip → dist/mock-service-lambda.zip (includes mappings) ``` ### Running locally (via LocalStack) @@ -57,7 +70,7 @@ The mock-service is deployed to LocalStack alongside the other Lambdas as part o The local flow: -1. `npm run build:mock-service && npm run package:mock-service` — builds the zip +1. `npm run build:mock-service && npm run package:mock-service` — builds the zip (bundles JSON mappings) 2. `npm run local:terraform:apply` — deploys it as a Lambda + API Gateway on LocalStack 3. `npm run local:update-supplier-url` — updates the DB supplier `service_url` to point at the mock API Gateway @@ -107,23 +120,12 @@ mock-service/ ├── tsconfig.json ├── jest.config.ts ├── scripts/ -│ ├── build.ts # esbuild bundler -│ └── package.ts # zip creator +│ ├── build.ts # esbuild bundler + copies JSON mappings +│ └── package.ts # zip creator (includes mappings/) └── src/ - ├── index.ts # Lambda entry point (Middy) - ├── router.ts # Path-based request router - ├── router.test.ts - ├── utils/ - │ └── response.ts # JSON / FHIR response helpers - ├── handlers/ - │ ├── health.ts - │ ├── jwks.ts # JWKS + JWT signing utility - │ ├── jwks.test.ts - │ ├── oauth-token.ts # OAuth2 client_credentials - │ ├── oauth-token.test.ts - │ ├── order.ts # FHIR ServiceRequest mock - │ ├── postcode.ts # Postcode lookup mock - │ └── results.ts # FHIR Observation results - └── test-utils/ - └── mock-event.ts # APIGatewayProxyEvent factory + ├── index.ts # Lambda entry point — loads stubs, routes requests + ├── stub-matcher.ts # Generic WireMock-compatible request matcher + ├── stub-matcher.test.ts + ├── template-engine.ts # WireMock response template renderer + └── template-engine.test.ts ``` diff --git a/mock-service/package.json b/mock-service/package.json index fb39c79c..4e98d774 100644 --- a/mock-service/package.json +++ b/mock-service/package.json @@ -1,7 +1,7 @@ { "name": "@hometest-service/mock-service", "version": "1.0.0", - "description": "Lambda-hosted mock service for dev environments — supplier APIs, Cognito JWKS, postcode lookup", + "description": "WireMock-compatible stub runner for AWS Lambda — reads JSON mapping files from local-environment/wiremock/mappings/", "private": true, "type": "module", "scripts": { @@ -16,18 +16,15 @@ }, "dependencies": { "@middy/core": "^7.1.3", - "@middy/http-cors": "^7.1.3", - "@middy/http-error-handler": "^7.1.3", - "@middy/http-security-headers": "^7.1.3", - "jsonwebtoken": "^9.0.3", - "uuid": "^13.0.0" + "@middy/http-error-handler": "^7.1.3" }, "devDependencies": { "@jest/globals": "^30.2.0", "@types/aws-lambda": "^8.10.161", "@types/jest": "^30.0.0", - "@types/jsonwebtoken": "^9.0.9", "@types/node": "^24.12.0", + "archiver": "7.0.1", + "@types/archiver": "7.0.0", "esbuild": "^0.27.3", "jest": "^30.2.0", "ts-jest": "^29.4.6", diff --git a/mock-service/scripts/build.ts b/mock-service/scripts/build.ts index f26e9588..eabfb240 100644 --- a/mock-service/scripts/build.ts +++ b/mock-service/scripts/build.ts @@ -1,12 +1,13 @@ #!/usr/bin/env node import { build } from "esbuild"; -import { existsSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { existsSync, rmSync, mkdirSync, writeFileSync, cpSync } from "fs"; import { join } from "path"; const ROOT_DIR = process.cwd(); const SRC_DIR = join(ROOT_DIR, "src"); const DIST_DIR = join(ROOT_DIR, "dist"); +const MAPPINGS_SRC = join(ROOT_DIR, "..", "local-environment", "wiremock", "mappings"); async function buildMockService(): Promise { console.log("Building mock-service lambda..."); @@ -14,6 +15,7 @@ async function buildMockService(): Promise { const entryPoint = join(SRC_DIR, "index.ts"); const outDir = join(DIST_DIR, "mock-service-lambda"); const outFile = join(outDir, "index.js"); + const mappingsDest = join(outDir, "mappings"); if (!existsSync(entryPoint)) { throw new Error(`Entry point not found: ${entryPoint}`); @@ -41,6 +43,15 @@ async function buildMockService(): Promise { }); writeFileSync(join(outDir, "meta.json"), JSON.stringify(result.metafile, null, 2)); + + // Copy WireMock JSON mapping files into the build output + if (existsSync(MAPPINGS_SRC)) { + cpSync(MAPPINGS_SRC, mappingsDest, { recursive: true }); + console.log(`Copied WireMock mappings from ${MAPPINGS_SRC} → ${mappingsDest}`); + } else { + console.warn(`WARNING: WireMock mappings directory not found at ${MAPPINGS_SRC}`); + } + console.log("Build complete."); } diff --git a/mock-service/scripts/package.ts b/mock-service/scripts/package.ts index 20e99579..5d714cce 100644 --- a/mock-service/scripts/package.ts +++ b/mock-service/scripts/package.ts @@ -34,7 +34,16 @@ async function createMockServiceZip(): Promise { archive.on("error", reject); archive.pipe(output); + // Bundle the compiled handler archive.file(indexPath, { name: "index.js" }); + + // Bundle the WireMock JSON mapping files + const mappingsPath = join(lambdaPath, "mappings"); + if (existsSync(mappingsPath)) { + archive.directory(mappingsPath, "mappings"); + console.log("Including WireMock mappings directory"); + } + archive.finalize(); }); } diff --git a/mock-service/src/handlers/health.ts b/mock-service/src/handlers/health.ts deleted file mode 100644 index 3fd71a05..00000000 --- a/mock-service/src/handlers/health.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { jsonResponse } from "../utils/response"; - -export const handleHealth = async ( - _event: APIGatewayProxyEvent, -): Promise => { - return jsonResponse(200, { - status: "ok", - service: "mock-service", - timestamp: new Date().toISOString(), - }); -}; diff --git a/mock-service/src/handlers/jwks.test.ts b/mock-service/src/handlers/jwks.test.ts deleted file mode 100644 index c6039657..00000000 --- a/mock-service/src/handlers/jwks.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { handleJwks, signMockJwt } from "./jwks"; -import { mockEvent } from "../test-utils/mock-event"; -import * as crypto from "crypto"; - -describe("handleJwks", () => { - it("returns a JWKS with one RSA key", async () => { - const result = await handleJwks(mockEvent({ httpMethod: "GET", path: "/mock/cognito/.well-known/jwks.json" })); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - expect(body.keys).toHaveLength(1); - - const key = body.keys[0]; - expect(key.kty).toBe("RSA"); - expect(key.use).toBe("sig"); - expect(key.alg).toBe("RS256"); - expect(key.kid).toBe("mock-key-1"); - expect(key.n).toBeTruthy(); - expect(key.e).toBeTruthy(); - }); - - it("returns a Cache-Control header", async () => { - const result = await handleJwks(mockEvent({ httpMethod: "GET", path: "/mock/cognito/.well-known/jwks.json" })); - expect(result.headers?.["Cache-Control"]).toBe("public, max-age=3600"); - }); -}); - -describe("signMockJwt", () => { - it("produces a JWT that can be verified against the JWKS public key", async () => { - const payload = { sub: "test-user", iss: "mock-cognito", aud: "hometest", exp: Math.floor(Date.now() / 1000) + 3600 }; - const token = signMockJwt(payload); - - // Parse the JWT - const [headerB64, payloadB64, signatureB64] = token.split("."); - const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); - const decoded = JSON.parse(Buffer.from(payloadB64, "base64url").toString()); - - expect(header.alg).toBe("RS256"); - expect(header.kid).toBe("mock-key-1"); - expect(decoded.sub).toBe("test-user"); - - // Verify the signature using the JWKS public key - const jwksResult = await handleJwks(mockEvent({ httpMethod: "GET", path: "/mock/cognito/.well-known/jwks.json" })); - const jwks = JSON.parse(jwksResult.body); - const publicKey = crypto.createPublicKey({ key: jwks.keys[0], format: "jwk" }); - - const isValid = crypto.verify( - "sha256", - Buffer.from(`${headerB64}.${payloadB64}`), - publicKey, - Buffer.from(signatureB64, "base64url"), - ); - - expect(isValid).toBe(true); - }); -}); diff --git a/mock-service/src/handlers/jwks.ts b/mock-service/src/handlers/jwks.ts deleted file mode 100644 index 10daab31..00000000 --- a/mock-service/src/handlers/jwks.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { jsonResponse } from "../utils/response"; -import * as crypto from "crypto"; - -/** - * Mock Cognito JWKS endpoint. - * - * GET /mock/cognito/.well-known/jwks.json - * - * Returns a JSON Web Key Set containing a single RSA public key. - * The matching private key (in MOCK_JWKS_PRIVATE_KEY env var) can be used - * to sign test JWTs that will validate against this JWKS. - * - * On cold start, generates a fresh RSA key pair unless MOCK_JWKS_PRIVATE_KEY - * is provided — this lets all dev services share the same signing key. - */ - -let cachedKeyPair: { publicKey: crypto.KeyObject; privateKey: crypto.KeyObject } | null = null; - -const getKeyPair = (): { publicKey: crypto.KeyObject; privateKey: crypto.KeyObject } => { - if (cachedKeyPair) return cachedKeyPair; - - const envPrivateKey = process.env.MOCK_JWKS_PRIVATE_KEY; - - if (envPrivateKey) { - const privateKey = crypto.createPrivateKey(envPrivateKey); - const publicKey = crypto.createPublicKey(privateKey); - cachedKeyPair = { publicKey, privateKey }; - } else { - const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { - modulusLength: 2048, - }); - cachedKeyPair = { publicKey, privateKey }; - } - - return cachedKeyPair; -}; - -const KID = "mock-key-1"; - -export const handleJwks = async ( - _event: APIGatewayProxyEvent, -): Promise => { - const { publicKey } = getKeyPair(); - - const jwk = publicKey.export({ format: "jwk" }); - - return jsonResponse( - 200, - { - keys: [ - { - ...jwk, - kid: KID, - use: "sig", - alg: "RS256", - }, - ], - }, - { "Cache-Control": "public, max-age=3600" }, - ); -}; - -/** - * Utility: sign a JWT payload using the mock private key. - * Used by tests or companion scripts to generate valid tokens. - */ -export const signMockJwt = (payload: Record): string => { - const { privateKey } = getKeyPair(); - - const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT", kid: KID })).toString( - "base64url", - ); - const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); - const signature = crypto.sign("sha256", Buffer.from(`${header}.${body}`), privateKey); - - return `${header}.${body}.${signature.toString("base64url")}`; -}; diff --git a/mock-service/src/handlers/oauth-token.test.ts b/mock-service/src/handlers/oauth-token.test.ts deleted file mode 100644 index a10d98c7..00000000 --- a/mock-service/src/handlers/oauth-token.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { handleOAuthToken } from "./oauth-token"; -import { mockEvent } from "../test-utils/mock-event"; - -describe("handleOAuthToken", () => { - it("returns a valid Bearer token for correct client_credentials grant", async () => { - const result = await handleOAuthToken( - mockEvent({ - httpMethod: "POST", - path: "/mock/supplier/oauth/token", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: "grant_type=client_credentials&client_id=supplier1&client_secret=s3cret", - }), - ); - - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - expect(body.token_type).toBe("Bearer"); - expect(body.expires_in).toBe(3600); - expect(body.scope).toBe("orders results"); - expect(body.access_token).toBeTruthy(); - }); - - it("returns 400 for wrong grant_type", async () => { - const result = await handleOAuthToken( - mockEvent({ - httpMethod: "POST", - path: "/mock/supplier/oauth/token", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: "grant_type=authorization_code&client_id=x&client_secret=y", - }), - ); - - expect(result.statusCode).toBe(400); - expect(JSON.parse(result.body).error).toBe("unsupported_grant_type"); - }); - - it("returns 400 for missing client_id/client_secret", async () => { - const result = await handleOAuthToken( - mockEvent({ - httpMethod: "POST", - path: "/mock/supplier/oauth/token", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: "grant_type=client_credentials", - }), - ); - - expect(result.statusCode).toBe(400); - expect(JSON.parse(result.body).error).toBe("invalid_request"); - }); - - it("returns 400 for wrong Content-Type", async () => { - const result = await handleOAuthToken( - mockEvent({ - httpMethod: "POST", - path: "/mock/supplier/oauth/token", - headers: { "Content-Type": "application/json" }, - body: "{}", - }), - ); - - expect(result.statusCode).toBe(400); - expect(JSON.parse(result.body).error).toBe("invalid_request"); - }); - - it("returns 401 when X-Mock-Status is 401", async () => { - const result = await handleOAuthToken( - mockEvent({ - httpMethod: "POST", - path: "/mock/supplier/oauth/token", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "X-Mock-Status": "401", - }, - body: "grant_type=client_credentials&client_id=x&client_secret=y", - }), - ); - - expect(result.statusCode).toBe(401); - expect(JSON.parse(result.body).error).toBe("invalid_client"); - }); -}); diff --git a/mock-service/src/handlers/oauth-token.ts b/mock-service/src/handlers/oauth-token.ts deleted file mode 100644 index 48343021..00000000 --- a/mock-service/src/handlers/oauth-token.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { jsonResponse } from "../utils/response"; - -/** - * Mock OAuth2 Client Credentials token endpoint. - * - * Mirrors the WireMock stub: POST /oauth/token - * Accepts any valid client_credentials grant and returns a static Bearer token. - * - * Supports X-Mock-Status header to force error scenarios: - * - "401" → invalid credentials - * - "400" → invalid grant type - */ -export const handleOAuthToken = async ( - event: APIGatewayProxyEvent, -): Promise => { - const mockStatus = event.headers?.["X-Mock-Status"] ?? event.headers?.["x-mock-status"]; - - if (mockStatus === "401") { - return jsonResponse( - 401, - { - error: "invalid_client", - error_description: "Client authentication failed", - }, - { "Cache-Control": "no-store", Pragma: "no-cache" }, - ); - } - - const body = event.body ?? ""; - const contentType = event.headers?.["Content-Type"] ?? event.headers?.["content-type"] ?? ""; - - if (!contentType.includes("application/x-www-form-urlencoded")) { - return jsonResponse(400, { - error: "invalid_request", - error_description: "Content-Type must be application/x-www-form-urlencoded", - }); - } - - const params = new URLSearchParams(body); - const grantType = params.get("grant_type"); - const clientId = params.get("client_id"); - const clientSecret = params.get("client_secret"); - - if (grantType !== "client_credentials") { - return jsonResponse( - 400, - { - error: "unsupported_grant_type", - error_description: `Grant type '${grantType ?? ""}' is not supported. Use 'client_credentials'.`, - }, - { "Cache-Control": "no-store", Pragma: "no-cache" }, - ); - } - - if (!clientId || !clientSecret) { - return jsonResponse( - 400, - { - error: "invalid_request", - error_description: "Missing required parameters: client_id, client_secret", - }, - { "Cache-Control": "no-store", Pragma: "no-cache" }, - ); - } - - return jsonResponse( - 200, - { - access_token: - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdXBwbGllcl9jbGllbnQiLCJzY29wZSI6Im9yZGVycyByZXN1bHRzIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjk5OTk5OTk5OTl9.mock-signature", - token_type: "Bearer", - expires_in: 3600, - scope: "orders results", - }, - { "Cache-Control": "no-store", Pragma: "no-cache" }, - ); -}; diff --git a/mock-service/src/handlers/order.ts b/mock-service/src/handlers/order.ts deleted file mode 100644 index b189b89e..00000000 --- a/mock-service/src/handlers/order.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { fhirResponse, jsonResponse } from "../utils/response"; -import { v4 as uuidv4 } from "uuid"; - -/** - * Mock Supplier Order endpoint. - * - * POST /mock/supplier/order → create order (FHIR ServiceRequest) - * GET /mock/supplier/order → get order status - * - * Supports X-Mock-Status header to force error scenarios: - * - "404" → order not found - * - "422" → unprocessable entity - * - "dispatched" / "confirmed" / "complete" → various order statuses - */ -export const handleOrder = async ( - event: APIGatewayProxyEvent, -): Promise => { - const mockStatus = event.headers?.["X-Mock-Status"] ?? event.headers?.["x-mock-status"]; - - if (event.httpMethod === "POST") { - return handleCreateOrder(event, mockStatus); - } - return handleGetOrder(event, mockStatus); -}; - -const handleCreateOrder = async ( - event: APIGatewayProxyEvent, - mockStatus: string | undefined, -): Promise => { - if (mockStatus === "422") { - return fhirResponse(422, { - resourceType: "OperationOutcome", - issue: [ - { - severity: "error", - code: "processing", - details: { text: "Unprocessable Entity" }, - diagnostics: "The order could not be processed due to validation errors", - }, - ], - }); - } - - // Validate FHIR content type - const contentType = event.headers?.["Content-Type"] ?? event.headers?.["content-type"] ?? ""; - if (!contentType.includes("application/fhir+json")) { - return jsonResponse(415, { - error: "Unsupported Media Type", - message: "Content-Type must be application/fhir+json", - }); - } - - // Parse request body to echo back patient details if present - let requestBody: Record = {}; - try { - requestBody = JSON.parse(event.body ?? "{}"); - } catch { - // use defaults - } - - const orderId = uuidv4(); - const contained = (requestBody.contained as Record[]) ?? [ - { - resourceType: "Patient", - id: "patient-1", - name: [{ use: "official", family: "Doe", given: ["John"], text: "John Doe" }], - telecom: [ - { system: "phone", value: "+447700900000", use: "mobile" }, - { system: "email", value: "john.doe@example.com", use: "home" }, - ], - address: [ - { - use: "home", - type: "both", - line: ["123 Main Street", "Flat 4B"], - city: "London", - postalCode: "SW1A 1AA", - country: "United Kingdom", - }, - ], - birthDate: "1990-01-01", - }, - ]; - - return fhirResponse(201, { - resourceType: "ServiceRequest", - id: orderId, - status: "active", - intent: "order", - code: { - coding: [ - { - system: "http://snomed.info/sct", - code: "31676001", - display: "HIV antigen test", - }, - ], - text: "HIV antigen test", - }, - contained, - subject: { reference: "#patient-1" }, - requester: { reference: "Organization/ORG001" }, - performer: [{ reference: "Organization/SUP001", display: "Test Supplier Ltd" }], - }); -}; - -const handleGetOrder = async ( - event: APIGatewayProxyEvent, - mockStatus: string | undefined, -): Promise => { - const orderId = event.queryStringParameters?.order_id ?? "018f6b6e-8b8e-7c2b-8f3b-8b8e7c2a8f3b"; - - if (mockStatus === "404") { - return fhirResponse(404, { - resourceType: "OperationOutcome", - issue: [ - { - severity: "error", - code: "not-found", - details: { text: "Resource Not Found" }, - diagnostics: "The requested resource could not be found", - }, - ], - }); - } - - // Map mock status to FHIR task statuses - const statusMap: Record = { - dispatched: "in-progress", - confirmed: "accepted", - complete: "completed", - }; - const fhirStatus = statusMap[mockStatus ?? ""] ?? "active"; - - return fhirResponse(200, { - resourceType: "Bundle", - type: "searchset", - total: 1, - entry: [ - { - fullUrl: `urn:uuid:${orderId}`, - resource: { - resourceType: "ServiceRequest", - id: orderId, - identifier: [ - { - system: "https://fhir.hometest.nhs.uk/Id/order-id", - value: orderId, - }, - ], - status: fhirStatus, - intent: "order", - code: { - coding: [ - { - system: "http://snomed.info/sct", - code: "31676001", - display: "HIV antigen test", - }, - ], - }, - subject: { reference: "#patient-1" }, - performer: [{ reference: "Organization/SUP001", display: "Test Supplier Ltd" }], - }, - }, - ], - }); -}; diff --git a/mock-service/src/handlers/postcode.ts b/mock-service/src/handlers/postcode.ts deleted file mode 100644 index 35324c56..00000000 --- a/mock-service/src/handlers/postcode.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { jsonResponse } from "../utils/response"; - -/** - * Mock Postcode Lookup endpoint. - * - * GET /mock/postcode/{postcode} - * - * Returns a fake local authority mapping for the given postcode. - * The response shape matches the external postcode lookup API used by - * the postcode-lookup-lambda. - * - * Supports X-Mock-Status header: - * - "404" → postcode not found - * - "400" → invalid postcode - */ - -/** Known postcodes for deterministic testing */ -const POSTCODE_MAP: Record = { - "SW1A 1AA": { code: "E09000033", name: "City of Westminster" }, - "SW1A1AA": { code: "E09000033", name: "City of Westminster" }, - "EC1A 1BB": { code: "E09000001", name: "City of London" }, - "EC1A1BB": { code: "E09000001", name: "City of London" }, - "LS1 1UR": { code: "E08000035", name: "Leeds" }, - "LS11UR": { code: "E08000035", name: "Leeds" }, - "M1 1AA": { code: "E08000003", name: "Manchester" }, - "M11AA": { code: "E08000003", name: "Manchester" }, - "B1 1BB": { code: "E08000025", name: "Birmingham" }, - "B11BB": { code: "E08000025", name: "Birmingham" }, -}; - -const DEFAULT_LA = { code: "E09000999", name: "Mock Local Authority" }; - -export const handlePostcode = async ( - event: APIGatewayProxyEvent, -): Promise => { - const mockStatus = event.headers?.["X-Mock-Status"] ?? event.headers?.["x-mock-status"]; - - // Extract postcode from path: /mock/postcode/{postcode} - const pathMatch = event.path.match(/^\/mock\/postcode\/(.+)$/); - const postcode = decodeURIComponent(pathMatch?.[1] ?? "").toUpperCase().trim(); - - if (mockStatus === "400" || !postcode) { - return jsonResponse(400, { - error: "Bad Request", - message: "Invalid postcode format", - }); - } - - if (mockStatus === "404") { - return jsonResponse(404, { - error: "Not Found", - message: `Postcode '${postcode}' not found`, - }); - } - - const localAuthority = POSTCODE_MAP[postcode] ?? DEFAULT_LA; - - return jsonResponse(200, { - postcode, - localAuthority, - country: "England", - region: "Mock Region", - }); -}; diff --git a/mock-service/src/handlers/results.ts b/mock-service/src/handlers/results.ts deleted file mode 100644 index 771c801b..00000000 --- a/mock-service/src/handlers/results.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { fhirResponse } from "../utils/response"; -import { v4 as uuidv4 } from "uuid"; - -/** - * Mock Supplier Results endpoint. - * - * GET /mock/supplier/results?order_uid= - * - * Returns a FHIR Bundle with an Observation resource. - * - * Supports X-Mock-Status header: - * - "404" → results not found - * - "400" → invalid request - */ -export const handleResults = async ( - event: APIGatewayProxyEvent, -): Promise => { - const mockStatus = event.headers?.["X-Mock-Status"] ?? event.headers?.["x-mock-status"]; - const correlationId = event.headers?.["X-Correlation-ID"] ?? event.headers?.["x-correlation-id"]; - const orderUid = event.queryStringParameters?.order_uid; - - if (mockStatus === "400") { - return fhirResponse(400, { - resourceType: "OperationOutcome", - issue: [ - { - severity: "error", - code: "invalid", - details: { text: "Invalid Request" }, - diagnostics: "The request was invalid — order_uid is required", - }, - ], - }); - } - - if (mockStatus === "404" || !orderUid) { - return fhirResponse(404, { - resourceType: "OperationOutcome", - issue: [ - { - severity: "error", - code: "not-found", - details: { text: "Results Not Found" }, - diagnostics: "No test results were found for the specified order", - }, - ], - }); - } - - const observationId = uuidv4(); - const now = new Date(); - const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); - const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); - - return fhirResponse(200, { - resourceType: "Bundle", - type: "searchset", - total: 1, - link: [ - { - relation: "self", - url: `/results?order_uid=${orderUid}`, - }, - ], - entry: [ - { - fullUrl: `urn:uuid:${observationId}`, - resource: { - resourceType: "Observation", - id: observationId, - meta: { - ...(correlationId ? { tag: [{ code: correlationId }] } : {}), - }, - basedOn: [{ reference: `ServiceRequest/${orderUid}` }], - status: "final", - code: { - coding: [ - { - system: "http://snomed.info/sct", - code: "31676001", - display: "HIV antigen test", - }, - ], - text: "HIV antigen test", - }, - subject: { reference: "Patient/123e4567-e89b-12d3-a456-426614174000" }, - effectiveDateTime: `${twoDaysAgo.toISOString().split("T")[0]}T15:45:00Z`, - issued: `${oneDayAgo.toISOString().split("T")[0]}T16:00:00Z`, - performer: [ - { reference: "Organization/SUP001", display: "Test Supplier Ltd" }, - ], - valueCodeableConcept: { - coding: [ - { - system: "http://snomed.info/sct", - code: "260415000", - display: "Not detected", - }, - ], - }, - interpretation: [ - { - coding: [ - { - system: "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", - code: "N", - display: "Normal", - }, - ], - }, - ], - }, - }, - ], - }); -}; diff --git a/mock-service/src/index.ts b/mock-service/src/index.ts index cbf1e89c..bc91cc20 100644 --- a/mock-service/src/index.ts +++ b/mock-service/src/index.ts @@ -1,33 +1,97 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import middy from "@middy/core"; -import cors from "@middy/http-cors"; import httpErrorHandler from "@middy/http-error-handler"; -import { route } from "./router"; +import { loadMappings, matchRequest } from "./stub-matcher"; +import { renderTemplate } from "./template-engine"; /** - * Mock Service Lambda — routes incoming API Gateway requests to the appropriate - * stub handler based on path prefix: + * Generic WireMock-compatible stub runner for AWS Lambda. * - * /mock/supplier/oauth/token → OAuth2 token endpoint - * /mock/supplier/order → Order placement / status - * /mock/supplier/results → Test results lookup - * /mock/cognito/.well-known/jwks → JWKS public key set - * /mock/postcode/{postcode} → Postcode → local authority lookup - * /mock/health → Health check + * Loads WireMock JSON mapping files from the bundled `mappings/` directory + * and matches incoming API Gateway requests against them using the same + * matching rules as WireMock (method, urlPath, urlPathPattern, headers, + * queryParameters, bodyPatterns, priority). + * + * The JSON stub files are the single source of truth — no per-endpoint + * TypeScript code needed. To add a new mock, just drop a JSON file into + * local-environment/wiremock/mappings/. */ + +const mappings = loadMappings(); + const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { + // Strip the API Gateway stage prefix and /mock/supplier or /mock/cognito prefix + // so the path matches what WireMock sees: + // /mock/supplier/oauth/token → /oauth/token + // /mock/supplier/order → /order + // /mock/cognito/.well-known/jwks.json → /.well-known/jwks.json + // /mock/postcode/SW1A1AA → /postcode/SW1A1AA + const rawPath = event.pathParameters?.proxy + ? `/${event.pathParameters.proxy}` + : event.path; + + const path = + rawPath + .replace(/^\/mock\/supplier/, "") + .replace(/^\/mock\/cognito/, "") + .replace(/^\/mock/, "") || "/"; + console.log("mock-service", { method: event.httpMethod, - path: event.path, - queryStringParameters: event.queryStringParameters, + originalPath: event.path, + matchPath: path, + }); + + const match = matchRequest(mappings, { + method: event.httpMethod, + path, + headers: event.headers ?? {}, + queryParameters: event.queryStringParameters ?? {}, + body: event.body ?? "", }); - return route(event); + if (!match) { + return { + statusCode: 404, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + error: "No matching stub", + message: `No WireMock mapping matched ${event.httpMethod} ${path}`, + availableMappings: mappings.map((m) => ({ + priority: m.priority, + method: m.request.method, + urlPath: m.request.urlPath, + urlPathPattern: m.request.urlPathPattern, + })), + }), + }; + } + + const response = match.response; + const headers: Record = { ...response.headers }; + + let body: string; + if (response.jsonBody !== undefined) { + body = JSON.stringify(response.jsonBody); + if (!headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + } else { + body = response.body ?? ""; + } + + // Apply WireMock response templating ({{randomValue}}, {{now}}, etc.) + body = renderTemplate(body); + + return { + statusCode: response.status ?? 200, + headers, + body, + }; }; export const handler = middy() - .use(cors({ origins: ["*"] })) .use(httpErrorHandler()) .handler(lambdaHandler); diff --git a/mock-service/src/router.test.ts b/mock-service/src/router.test.ts deleted file mode 100644 index 2c37c40e..00000000 --- a/mock-service/src/router.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { route } from "./router"; -import { mockEvent } from "./test-utils/mock-event"; - -describe("router", () => { - it("routes GET /mock/health to health handler", async () => { - const result = await route(mockEvent({ httpMethod: "GET", path: "/mock/health" })); - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - expect(body.status).toBe("ok"); - expect(body.service).toBe("mock-service"); - }); - - it("routes GET /mock/cognito/.well-known/jwks.json to JWKS handler", async () => { - const result = await route(mockEvent({ httpMethod: "GET", path: "/mock/cognito/.well-known/jwks.json" })); - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - expect(body.keys).toHaveLength(1); - expect(body.keys[0].kty).toBe("RSA"); - }); - - it("routes POST /mock/supplier/oauth/token to OAuth handler", async () => { - const result = await route( - mockEvent({ - httpMethod: "POST", - path: "/mock/supplier/oauth/token", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: "grant_type=client_credentials&client_id=test&client_secret=secret", - }), - ); - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - expect(body.token_type).toBe("Bearer"); - }); - - it("routes POST /mock/supplier/order to order handler", async () => { - const result = await route( - mockEvent({ - httpMethod: "POST", - path: "/mock/supplier/order", - headers: { "Content-Type": "application/fhir+json" }, - body: JSON.stringify({ resourceType: "ServiceRequest" }), - }), - ); - expect(result.statusCode).toBe(201); - const body = JSON.parse(result.body); - expect(body.resourceType).toBe("ServiceRequest"); - }); - - it("routes GET /mock/supplier/results with order_uid", async () => { - const result = await route( - mockEvent({ - httpMethod: "GET", - path: "/mock/supplier/results", - headers: { "X-Correlation-ID": "test-123" }, - queryStringParameters: { order_uid: "550e8400-e29b-41d4-a716-446655440000" }, - }), - ); - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - expect(body.resourceType).toBe("Bundle"); - }); - - it("routes GET /mock/postcode/{postcode}", async () => { - const result = await route(mockEvent({ httpMethod: "GET", path: "/mock/postcode/SW1A1AA" })); - expect(result.statusCode).toBe(200); - const body = JSON.parse(result.body); - expect(body.localAuthority.name).toBe("City of Westminster"); - }); - - it("returns 404 for unregistered routes", async () => { - const result = await route(mockEvent({ httpMethod: "GET", path: "/unknown" })); - expect(result.statusCode).toBe(404); - const body = JSON.parse(result.body); - expect(body.error).toBe("Not Found"); - }); -}); diff --git a/mock-service/src/router.ts b/mock-service/src/router.ts deleted file mode 100644 index f9ab333b..00000000 --- a/mock-service/src/router.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { handleOAuthToken } from "./handlers/oauth-token"; -import { handleOrder } from "./handlers/order"; -import { handleResults } from "./handlers/results"; -import { handleJwks } from "./handlers/jwks"; -import { handlePostcode } from "./handlers/postcode"; -import { handleHealth } from "./handlers/health"; -import { jsonResponse } from "./utils/response"; - -interface Route { - /** HTTP method (GET, POST, ANY) */ - method: string; - /** Regex matched against event.path */ - pattern: RegExp; - handler: (event: APIGatewayProxyEvent) => Promise; -} - -const routes: Route[] = [ - // Health - { method: "GET", pattern: /^\/mock\/health$/, handler: handleHealth }, - - // Cognito JWKS - { method: "GET", pattern: /^\/mock\/cognito\/.well-known\/jwks(\.json)?$/, handler: handleJwks }, - - // Supplier OAuth2 - { method: "POST", pattern: /^\/mock\/supplier\/(oauth\/token|api\/oauth)$/, handler: handleOAuthToken }, - - // Supplier Order - { method: "POST", pattern: /^\/mock\/supplier\/order$/, handler: handleOrder }, - { method: "GET", pattern: /^\/mock\/supplier\/order$/, handler: handleOrder }, - - // Supplier Results - { method: "GET", pattern: /^\/mock\/supplier\/(results|api\/results|nhs_home_test\/results)$/, handler: handleResults }, - - // Postcode lookup - { method: "GET", pattern: /^\/mock\/postcode\/([A-Za-z0-9 ]+)$/, handler: handlePostcode }, -]; - -export const route = async ( - event: APIGatewayProxyEvent, -): Promise => { - const { httpMethod, path } = event; - - for (const r of routes) { - if ((r.method === "ANY" || r.method === httpMethod) && r.pattern.test(path)) { - return r.handler(event); - } - } - - return jsonResponse(404, { - error: "Not Found", - message: `No mock registered for ${httpMethod} ${path}`, - availableRoutes: routes.map((r) => `${r.method} ${r.pattern.source}`), - }); -}; diff --git a/mock-service/src/stub-matcher.test.ts b/mock-service/src/stub-matcher.test.ts new file mode 100644 index 00000000..ff798bca --- /dev/null +++ b/mock-service/src/stub-matcher.test.ts @@ -0,0 +1,166 @@ +import { matchRequest, WireMockMapping } from "./stub-matcher"; + +const makeMappings = (overrides: Partial[]): WireMockMapping[] => + overrides.map((o, i) => ({ + priority: o.priority ?? i, + request: o.request ?? {}, + response: o.response ?? { status: 200 }, + })); + +describe("stub-matcher", () => { + describe("method matching", () => { + it("matches exact HTTP method", () => { + const mappings = makeMappings([{ request: { method: "POST" }, response: { status: 201 } }]); + expect(matchRequest(mappings, { method: "POST", path: "/", headers: {}, queryParameters: {}, body: "" })?.response.status).toBe(201); + }); + + it("rejects wrong HTTP method", () => { + const mappings = makeMappings([{ request: { method: "POST" }, response: { status: 201 } }]); + expect(matchRequest(mappings, { method: "GET", path: "/", headers: {}, queryParameters: {}, body: "" })).toBeUndefined(); + }); + }); + + describe("urlPath matching", () => { + it("matches exact urlPath", () => { + const mappings = makeMappings([{ request: { method: "GET", urlPath: "/order" }, response: { status: 200 } }]); + expect(matchRequest(mappings, { method: "GET", path: "/order", headers: {}, queryParameters: {}, body: "" })).toBeDefined(); + }); + + it("rejects non-matching urlPath", () => { + const mappings = makeMappings([{ request: { method: "GET", urlPath: "/order" }, response: { status: 200 } }]); + expect(matchRequest(mappings, { method: "GET", path: "/results", headers: {}, queryParameters: {}, body: "" })).toBeUndefined(); + }); + }); + + describe("urlPathPattern matching", () => { + it("matches regex urlPathPattern", () => { + const mappings = makeMappings([ + { request: { method: "GET", urlPathPattern: "/(results|api/results)" }, response: { status: 200 } }, + ]); + expect(matchRequest(mappings, { method: "GET", path: "/results", headers: {}, queryParameters: {}, body: "" })).toBeDefined(); + expect(matchRequest(mappings, { method: "GET", path: "/api/results", headers: {}, queryParameters: {}, body: "" })).toBeDefined(); + expect(matchRequest(mappings, { method: "GET", path: "/unknown", headers: {}, queryParameters: {}, body: "" })).toBeUndefined(); + }); + }); + + describe("header matching", () => { + it("matches header with 'contains'", () => { + const mappings = makeMappings([ + { + request: { + method: "POST", + headers: { "Content-Type": { contains: "application/x-www-form-urlencoded" } }, + }, + response: { status: 200 }, + }, + ]); + expect( + matchRequest(mappings, { + method: "POST", + path: "/", + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" }, + queryParameters: {}, + body: "", + }), + ).toBeDefined(); + }); + + it("matches header with 'matches' (regex)", () => { + const mappings = makeMappings([ + { request: { headers: { "X-Correlation-ID": { matches: ".*" } } }, response: { status: 200 } }, + ]); + expect( + matchRequest(mappings, { method: "GET", path: "/", headers: { "X-Correlation-ID": "abc-123" }, queryParameters: {}, body: "" }), + ).toBeDefined(); + }); + + it("matches headers case-insensitively", () => { + const mappings = makeMappings([ + { request: { headers: { "Content-Type": { contains: "json" } } }, response: { status: 200 } }, + ]); + expect( + matchRequest(mappings, { method: "GET", path: "/", headers: { "content-type": "application/json" }, queryParameters: {}, body: "" }), + ).toBeDefined(); + }); + }); + + describe("queryParameters matching", () => { + it("matches query param with 'matches'", () => { + const mappings = makeMappings([ + { request: { queryParameters: { order_uid: { matches: ".{10,}" } } }, response: { status: 200 } }, + ]); + expect( + matchRequest(mappings, { method: "GET", path: "/", headers: {}, queryParameters: { order_uid: "550e8400-e29b-41d4-a716-446655440000" }, body: "" }), + ).toBeDefined(); + }); + + it("matches query param with 'absent'", () => { + const mappings = makeMappings([ + { request: { queryParameters: { order_uid: { absent: true } } }, response: { status: 400 } }, + ]); + expect( + matchRequest(mappings, { method: "GET", path: "/", headers: {}, queryParameters: {}, body: "" })?.response.status, + ).toBe(400); + }); + }); + + describe("bodyPatterns matching", () => { + it("matches body with 'matches' (regex)", () => { + const mappings = makeMappings([ + { request: { bodyPatterns: [{ matches: ".*grant_type=client_credentials.*" }] }, response: { status: 200 } }, + ]); + expect( + matchRequest(mappings, { + method: "POST", + path: "/", + headers: {}, + queryParameters: {}, + body: "grant_type=client_credentials&client_id=x", + }), + ).toBeDefined(); + }); + + it("matches body with matchesJsonPath absent", () => { + const mappings = makeMappings([ + { + request: { + bodyPatterns: [{ matchesJsonPath: { expression: "$.subject", absent: true } }], + }, + response: { status: 422 }, + }, + ]); + expect( + matchRequest(mappings, { + method: "POST", + path: "/", + headers: {}, + queryParameters: {}, + body: JSON.stringify({ code: "test" }), + })?.response.status, + ).toBe(422); + + // subject IS present → should not match + expect( + matchRequest(mappings, { + method: "POST", + path: "/", + headers: {}, + queryParameters: {}, + body: JSON.stringify({ subject: { reference: "#patient-1" } }), + }), + ).toBeUndefined(); + }); + }); + + describe("priority ordering", () => { + it("returns the highest-priority (lowest number) match", () => { + const mappings = makeMappings([ + { priority: 10, request: { method: "POST", urlPath: "/oauth/token" }, response: { status: 400 } }, + { priority: 3, request: { method: "POST", urlPath: "/oauth/token" }, response: { status: 401 } }, + { priority: 5, request: { method: "POST", urlPath: "/oauth/token" }, response: { status: 200 } }, + ]); + // After sorting by priority, 3 comes first + expect(matchRequest(mappings, { method: "POST", path: "/oauth/token", headers: {}, queryParameters: {}, body: "" })?.response.status).toBe(401); + }); + }); +}); diff --git a/mock-service/src/stub-matcher.ts b/mock-service/src/stub-matcher.ts new file mode 100644 index 00000000..7c1d89ab --- /dev/null +++ b/mock-service/src/stub-matcher.ts @@ -0,0 +1,297 @@ +import { readdirSync, readFileSync } from "fs"; +import { join } from "path"; + +/** + * WireMock-compatible stub matcher. + * + * Loads JSON mapping files and matches incoming requests using the same + * rules as WireMock: method, urlPath/urlPathPattern, headers, + * queryParameters, bodyPatterns, and priority ordering. + * + * Supported matching operators: + * - equalTo, contains, matches (regex) + * - absent (true = param must not be present) + * - matchesJsonPath with expression + absent + */ + +// ---------- Types ---------- + +export interface WireMockMapping { + priority: number; + request: WireMockRequest; + response: WireMockResponse; +} + +interface WireMockRequest { + method?: string; + urlPath?: string; + urlPathPattern?: string; + headers?: Record; + queryParameters?: Record; + bodyPatterns?: BodyPattern[]; +} + +interface WireMockResponse { + status?: number; + headers?: Record; + jsonBody?: unknown; + body?: string; +} + +type MatcherDef = { + equalTo?: string; + contains?: string; + matches?: string; + absent?: boolean; + caseInsensitive?: boolean; +}; + +type BodyPattern = { + equalTo?: string; + contains?: string; + matches?: string; + matchesJsonPath?: JsonPathMatcher | string; +}; + +type JsonPathMatcher = { + expression: string; + absent?: boolean; +}; + +export interface IncomingRequest { + method: string; + path: string; + headers: Record; + queryParameters: Record; + body: string; +} + +// ---------- Loading ---------- + +const MAPPINGS_DIR = join(__dirname, "mappings"); + +export function loadMappings(): WireMockMapping[] { + let files: string[]; + try { + files = readdirSync(MAPPINGS_DIR).filter((f) => f.endsWith(".json")); + } catch { + console.warn(`No mappings directory found at ${MAPPINGS_DIR}`); + return []; + } + + const mappings: WireMockMapping[] = files.map((file) => { + const raw = readFileSync(join(MAPPINGS_DIR, file), "utf-8"); + const mapping = JSON.parse(raw) as Partial; + return { + priority: mapping.priority ?? 0, + request: mapping.request ?? {}, + response: mapping.response ?? { status: 200 }, + }; + }); + + // Sort by priority ascending (lower number = higher priority, matched first) + mappings.sort((a, b) => a.priority - b.priority); + + console.log(`Loaded ${mappings.length} WireMock mappings`); + return mappings; +} + +// ---------- Matching ---------- + +export function matchRequest( + mappings: WireMockMapping[], + req: IncomingRequest, +): WireMockMapping | undefined { + return mappings.find((mapping) => isMatch(mapping.request, req)); +} + +function isMatch(spec: WireMockRequest, req: IncomingRequest): boolean { + // Method + if (spec.method && spec.method.toUpperCase() !== req.method.toUpperCase()) { + return false; + } + + // URL path — exact match + if (spec.urlPath && spec.urlPath !== req.path) { + return false; + } + + // URL path — regex match + if (spec.urlPathPattern) { + try { + if (!new RegExp(spec.urlPathPattern).test(req.path)) { + return false; + } + } catch { + return false; + } + } + + // Headers + if (spec.headers) { + for (const [name, matcher] of Object.entries(spec.headers)) { + // Header lookup is case-insensitive + const actualValue = findHeader(req.headers, name); + if (!matchValue(matcher, actualValue)) { + return false; + } + } + } + + // Query parameters + if (spec.queryParameters) { + for (const [name, matcher] of Object.entries(spec.queryParameters)) { + const actualValue = req.queryParameters[name] ?? undefined; + if (!matchValue(matcher, actualValue)) { + return false; + } + } + } + + // Body patterns + if (spec.bodyPatterns) { + for (const pattern of spec.bodyPatterns) { + if (!matchBody(pattern, req.body)) { + return false; + } + } + } + + return true; +} + +// ---------- Value matchers ---------- + +function matchValue(matcher: MatcherDef, value: string | undefined): boolean { + // absent check + if (matcher.absent === true) { + return value === undefined || value === null; + } + if (matcher.absent === false) { + return value !== undefined && value !== null; + } + + // If the value is missing but matcher expects something, no match + if (value === undefined || value === null) { + return false; + } + + const caseInsensitive = matcher.caseInsensitive === true; + const v = caseInsensitive ? value.toLowerCase() : value; + + if (matcher.equalTo !== undefined) { + const expected = caseInsensitive ? matcher.equalTo.toLowerCase() : matcher.equalTo; + return v === expected; + } + + if (matcher.contains !== undefined) { + const expected = caseInsensitive ? matcher.contains.toLowerCase() : matcher.contains; + return v.includes(expected); + } + + if (matcher.matches !== undefined) { + try { + const flags = caseInsensitive ? "i" : undefined; + return new RegExp(matcher.matches, flags).test(value); + } catch { + return false; + } + } + + return true; +} + +// ---------- Body matchers ---------- + +function matchBody(pattern: BodyPattern, body: string): boolean { + if (pattern.equalTo !== undefined) { + return body === pattern.equalTo; + } + + if (pattern.contains !== undefined) { + return body.includes(pattern.contains); + } + + if (pattern.matches !== undefined) { + try { + return new RegExp(pattern.matches).test(body); + } catch { + return false; + } + } + + if (pattern.matchesJsonPath !== undefined) { + return matchJsonPath(pattern.matchesJsonPath, body); + } + + return true; +} + +// ---------- Simplified JSONPath matcher ---------- + +function matchJsonPath( + matcher: JsonPathMatcher | string, + body: string, +): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return false; + } + + if (typeof matcher === "string") { + // Simple JSONPath expression — just check if the path resolves to something + const value = resolveJsonPath(parsed, matcher); + return value !== undefined; + } + + // Object form with expression + absent + const value = resolveJsonPath(parsed, matcher.expression); + + if (matcher.absent === true) { + return value === undefined; + } + + return value !== undefined; +} + +/** + * Minimal JSONPath resolver — supports dot-notation paths like `$.subject`, `$.code.coding[0].code`. + * This isn't a full JSONPath implementation but covers the patterns used in the stubs. + */ +function resolveJsonPath(obj: unknown, expression: string): unknown { + // Strip leading $. prefix + const path = expression.replace(/^\$\.?/, ""); + if (!path) return obj; + + const segments = path.split(/\.|\[|\]/).filter(Boolean); + let current: unknown = obj; + + for (const seg of segments) { + if (current === null || current === undefined) return undefined; + if (typeof current === "object") { + current = (current as Record)[seg]; + } else { + return undefined; + } + } + + return current; +} + +// ---------- Helpers ---------- + +function findHeader( + headers: Record, + name: string, +): string | undefined { + // Case-insensitive header lookup + const lowerName = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lowerName) { + return value; + } + } + return undefined; +} diff --git a/mock-service/src/template-engine.test.ts b/mock-service/src/template-engine.test.ts new file mode 100644 index 00000000..78ef4560 --- /dev/null +++ b/mock-service/src/template-engine.test.ts @@ -0,0 +1,40 @@ +import { renderTemplate } from "./template-engine"; + +describe("template-engine", () => { + describe("{{randomValue type='UUID'}}", () => { + it("replaces with a valid UUID", () => { + const input = '{"id": "{{randomValue type=\'UUID\'}}"}'; + const result = renderTemplate(input); + const parsed = JSON.parse(result); + expect(parsed.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it("replaces multiple UUIDs with unique values", () => { + const input = "{{randomValue type='UUID'}} {{randomValue type='UUID'}}"; + const result = renderTemplate(input); + const [a, b] = result.split(" "); + expect(a).not.toBe(b); + }); + }); + + describe("{{now ...}}", () => { + it("replaces {{now}} with ISO date string", () => { + const result = renderTemplate("{{now}}"); + expect(new Date(result).toISOString()).toBe(result); + }); + + it("applies format", () => { + const result = renderTemplate("{{now format='yyyy-MM-dd'}}"); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("applies negative offset", () => { + const result = renderTemplate("{{now offset='-2 days' format='yyyy-MM-dd'}}"); + const expected = new Date(); + expected.setDate(expected.getDate() - 2); + expect(result).toBe( + `${expected.getFullYear()}-${String(expected.getMonth() + 1).padStart(2, "0")}-${String(expected.getDate()).padStart(2, "0")}`, + ); + }); + }); +}); diff --git a/mock-service/src/template-engine.ts b/mock-service/src/template-engine.ts new file mode 100644 index 00000000..ba58d20e --- /dev/null +++ b/mock-service/src/template-engine.ts @@ -0,0 +1,87 @@ +import { randomUUID } from "crypto"; + +/** + * WireMock response template renderer. + * + * Supports the template helpers used in the existing stub mappings: + * {{randomValue type='UUID'}} + * {{now format='yyyy-MM-dd'}} + * {{now offset='-2 days' format='yyyy-MM-dd'}} + */ + +export function renderTemplate(body: string): string { + // {{randomValue type='UUID'}} + body = body.replace(/\{\{randomValue\s+type='UUID'\}\}/g, () => randomUUID()); + + // {{now ...}} with optional offset and format + body = body.replace( + /\{\{now(?:\s+offset='([^']*)')?\s*(?:format='([^']*)')?\}\}/g, + (_match, offset?: string, format?: string) => { + let date = new Date(); + + if (offset) { + date = applyOffset(date, offset); + } + + if (format) { + return formatDate(date, format); + } + + return date.toISOString(); + }, + ); + + return body; +} + +function applyOffset(date: Date, offset: string): Date { + const result = new Date(date); + // Parse offsets like "-2 days", "+1 hours", "-30 minutes" + const match = offset.match(/^([+-]?\d+)\s+(second|minute|hour|day|week|month|year)s?$/i); + if (!match) return result; + + const amount = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + + switch (unit) { + case "second": + result.setSeconds(result.getSeconds() + amount); + break; + case "minute": + result.setMinutes(result.getMinutes() + amount); + break; + case "hour": + result.setHours(result.getHours() + amount); + break; + case "day": + result.setDate(result.getDate() + amount); + break; + case "week": + result.setDate(result.getDate() + amount * 7); + break; + case "month": + result.setMonth(result.getMonth() + amount); + break; + case "year": + result.setFullYear(result.getFullYear() + amount); + break; + } + + return result; +} + +/** + * Simplified Java SimpleDateFormat → JS date formatter. + * Covers the patterns used in WireMock stubs. + */ +function formatDate(date: Date, format: string): string { + const pad = (n: number, len = 2) => String(n).padStart(len, "0"); + + return format + .replace(/yyyy/g, String(date.getFullYear())) + .replace(/MM/g, pad(date.getMonth() + 1)) + .replace(/dd/g, pad(date.getDate())) + .replace(/HH/g, pad(date.getHours())) + .replace(/mm/g, pad(date.getMinutes())) + .replace(/ss/g, pad(date.getSeconds())); +} diff --git a/mock-service/src/test-utils/mock-event.ts b/mock-service/src/test-utils/mock-event.ts deleted file mode 100644 index 00b2959d..00000000 --- a/mock-service/src/test-utils/mock-event.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { APIGatewayProxyEvent } from "aws-lambda"; - -/** - * Creates a minimal APIGatewayProxyEvent for testing. - */ -export const mockEvent = (overrides: Partial = {}): APIGatewayProxyEvent => ({ - httpMethod: "GET", - path: "/", - body: null, - headers: {}, - multiValueHeaders: {}, - isBase64Encoded: false, - pathParameters: null, - queryStringParameters: null, - multiValueQueryStringParameters: null, - stageVariables: null, - requestContext: { - accountId: "123456789012", - apiId: "mock-api", - authorizer: null, - protocol: "HTTP/1.1", - httpMethod: "GET", - identity: { - accessKey: null, - accountId: null, - apiKey: null, - apiKeyId: null, - caller: null, - clientCert: null, - cognitoAuthenticationProvider: null, - cognitoAuthenticationType: null, - cognitoIdentityId: null, - cognitoIdentityPoolId: null, - principalOrgId: null, - sourceIp: "127.0.0.1", - user: null, - userAgent: "jest-test", - userArn: null, - }, - path: "/", - stage: "test", - requestId: "mock-request-id", - requestTimeEpoch: Date.now(), - resourceId: "mock", - resourcePath: "/", - }, - resource: "/", - ...overrides, -}); diff --git a/mock-service/src/utils/response.ts b/mock-service/src/utils/response.ts deleted file mode 100644 index 5466a416..00000000 --- a/mock-service/src/utils/response.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { APIGatewayProxyResult } from "aws-lambda"; - -export const jsonResponse = ( - statusCode: number, - body: Record, - headers?: Record, -): APIGatewayProxyResult => ({ - statusCode, - headers: { - "Content-Type": "application/json", - ...headers, - }, - body: JSON.stringify(body), -}); - -export const fhirResponse = ( - statusCode: number, - body: Record, -): APIGatewayProxyResult => ({ - statusCode, - headers: { - "Content-Type": "application/fhir+json", - }, - body: JSON.stringify(body), -}); From c009be81f2718c28aef0bc079465c37f51000280 Mon Sep 17 00:00:00 2001 From: Mikolaj Miotk Date: Mon, 16 Mar 2026 16:53:40 +0100 Subject: [PATCH 4/4] Add mock draft version 3 - stubr --- local-environment/infra/main.tf | 8 +- mock-service/Cargo.toml | 13 + mock-service/README.md | 61 ++--- mock-service/docs/test-scenario-routing.md | 167 ++++++++++++ mock-service/jest.config.ts | 14 - mock-service/package.json | 31 +-- mock-service/scripts/build.sh | 30 +++ mock-service/scripts/build.ts | 61 ----- mock-service/scripts/package.sh | 22 ++ mock-service/scripts/package.ts | 54 ---- mock-service/src/index.ts | 97 ------- mock-service/src/main.rs | 90 +++++++ mock-service/src/stub-matcher.test.ts | 166 ------------ mock-service/src/stub-matcher.ts | 297 --------------------- mock-service/src/template-engine.test.ts | 40 --- mock-service/src/template-engine.ts | 87 ------ mock-service/tsconfig.json | 26 -- 17 files changed, 352 insertions(+), 912 deletions(-) create mode 100644 mock-service/Cargo.toml create mode 100644 mock-service/docs/test-scenario-routing.md delete mode 100644 mock-service/jest.config.ts create mode 100755 mock-service/scripts/build.sh delete mode 100644 mock-service/scripts/build.ts create mode 100755 mock-service/scripts/package.sh delete mode 100644 mock-service/scripts/package.ts delete mode 100644 mock-service/src/index.ts create mode 100644 mock-service/src/main.rs delete mode 100644 mock-service/src/stub-matcher.test.ts delete mode 100644 mock-service/src/stub-matcher.ts delete mode 100644 mock-service/src/template-engine.test.ts delete mode 100644 mock-service/src/template-engine.ts delete mode 100644 mock-service/tsconfig.json diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 4c7bd56f..adf34505 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -553,15 +553,15 @@ resource "aws_lambda_function" "mock_service" { filename = "${path.module}/../../mock-service/dist/mock-service-lambda.zip" function_name = "${var.project_name}-mock-service" role = aws_iam_role.lambda_role.arn - handler = "index.handler" - runtime = "nodejs24.x" + handler = "bootstrap" + runtime = "provided.al2023" + architectures = ["arm64"] source_code_hash = filebase64sha256("${path.module}/../../mock-service/dist/mock-service-lambda.zip") timeout = 30 environment { variables = { - NODE_OPTIONS = "--enable-source-maps" - ENVIRONMENT = var.environment + ENVIRONMENT = var.environment } } diff --git a/mock-service/Cargo.toml b/mock-service/Cargo.toml new file mode 100644 index 00000000..395998bf --- /dev/null +++ b/mock-service/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "mock-service" +version = "0.1.0" +edition = "2021" + +[dependencies] +lambda_http = "0.13" +lambda_runtime = "0.13" +tokio = { version = "1", features = ["full"] } +stubr = "0.6" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/mock-service/README.md b/mock-service/README.md index d40b674e..14077b95 100644 --- a/mock-service/README.md +++ b/mock-service/README.md @@ -1,31 +1,21 @@ # Mock Service -Lambda-hosted WireMock-compatible stub runner for dev/test environments. Reads the same WireMock JSON mapping files from `local-environment/wiremock/mappings/` and serves them via a single Lambda function behind API Gateway. +Lambda-hosted WireMock-compatible stub runner for dev/test environments, powered by [stubr](https://github.com/beltram/stubr) (Rust). Reads the same WireMock JSON mapping files from `local-environment/wiremock/mappings/` and serves them via a single Lambda function behind API Gateway. -The JSON mapping files are the **single source of truth** — no per-endpoint TypeScript handlers. To add or change a mock, edit a JSON stub file. +The JSON mapping files are the **single source of truth**. To add or change a mock, edit a JSON stub file — no code changes needed. ## How it works -1. At build time, all WireMock JSON mapping files are copied from `../local-environment/wiremock/mappings/` into the Lambda bundle. -2. On cold start, the Lambda loads every `.json` file from the bundled `mappings/` directory. -3. For each incoming request, the stub matcher evaluates WireMock matching rules (method, URL path/pattern, headers, query parameters, body patterns) and returns the first matching response (respecting priority). -4. Response templates (`{{randomValue type='UUID'}}`, `{{now}}`) are rendered before returning. - -## Supported WireMock features - -| Feature | Example | -|---|---| -| Exact URL path | `"urlPath": "/oauth/token"` | -| Regex URL path | `"urlPathPattern": "/order/.*"` | -| Method matching | `"method": "POST"` | -| Header matching | `"contains"`, `"matches"` (regex), `"equalTo"` | -| Query parameter matching | `"matches"` (regex), `"equalTo"`, `"absent": true` | -| Body patterns | `"matches"` (regex), `"matchesJsonPath"` with `"absent": true` | -| Priority | `"priority": 1` (lower = matched first) | -| JSON response body | `"jsonBody": { ... }` | -| String response body | `"body": "..."` | -| Response headers | `"headers": { "Content-Type": "..." }` | -| Response templating | `{{randomValue type='UUID'}}`, `{{now}}`, `{{now offset='-2 days' format='yyyy-MM-dd'}}` | +1. At build time, `cargo lambda build` compiles a native `bootstrap` binary, then all WireMock JSON mapping files are copied from `../local-environment/wiremock/mappings/` into the Lambda bundle alongside it. +2. On cold start, stubr loads every `.json` file from the bundled `mappings/` directory and starts an in-process HTTP stub server. +3. For each incoming request, the Lambda strips API Gateway prefixes (`/mock/supplier/`, `/mock/cognito/`, `/mock/`) and proxies the request to stubr, which evaluates WireMock matching rules and returns the matching response. +4. stubr handles response templating (`{{randomValue}}`, `{{now}}`, etc.) natively. + +## Prerequisites + +- **Rust toolchain**: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` +- **cargo-lambda**: `cargo install cargo-lambda` (or `pip install cargo-lambda`) +- **Zig** (for cross-compilation): `cargo lambda` uses Zig under the hood — install via `pip install ziglang` or your package manager ## Adding or changing mocks @@ -45,7 +35,7 @@ Drop a new JSON file into `local-environment/wiremock/mappings/`: } ``` -Rebuild the mock-service to pick up the change. No TypeScript code changes needed. +Rebuild the mock-service to pick up the change. No Rust code changes needed. ## URL path prefixes @@ -58,10 +48,8 @@ API Gateway routes requests under `/mock/supplier/` and `/mock/cognito/` prefixe ```bash cd mock-service -npm install -npm test # run unit tests -npm run build # esbuild → dist/mock-service-lambda/index.js + mappings/ -npm run package # zip → dist/mock-service-lambda.zip (includes mappings) +npm run build # cargo lambda build + copy mappings → dist/ +npm run package # zip → dist/mock-service-lambda.zip (bootstrap + mappings/) ``` ### Running locally (via LocalStack) @@ -70,8 +58,8 @@ The mock-service is deployed to LocalStack alongside the other Lambdas as part o The local flow: -1. `npm run build:mock-service && npm run package:mock-service` — builds the zip (bundles JSON mappings) -2. `npm run local:terraform:apply` — deploys it as a Lambda + API Gateway on LocalStack +1. `npm run build:mock-service && npm run package:mock-service` — builds the zip (compiles Rust binary + bundles JSON mappings) +2. `npm run local:terraform:apply` — deploys it as a Lambda (`provided.al2023`) + API Gateway on LocalStack 3. `npm run local:update-supplier-url` — updates the DB supplier `service_url` to point at the mock API Gateway All three steps run automatically as part of `npm run local:deploy`. @@ -116,16 +104,11 @@ inputs = { ```bash mock-service/ -├── package.json -├── tsconfig.json -├── jest.config.ts +├── Cargo.toml # Rust dependencies (stubr, lambda_http, reqwest) +├── package.json # npm scripts wrapping cargo/shell commands ├── scripts/ -│ ├── build.ts # esbuild bundler + copies JSON mappings -│ └── package.ts # zip creator (includes mappings/) +│ ├── build.sh # cargo lambda build + copy mappings +│ └── package.sh # zip bootstrap + mappings/ └── src/ - ├── index.ts # Lambda entry point — loads stubs, routes requests - ├── stub-matcher.ts # Generic WireMock-compatible request matcher - ├── stub-matcher.test.ts - ├── template-engine.ts # WireMock response template renderer - └── template-engine.test.ts + └── main.rs # Lambda handler — starts stubr, proxies requests ``` diff --git a/mock-service/docs/test-scenario-routing.md b/mock-service/docs/test-scenario-routing.md new file mode 100644 index 00000000..7485ae20 --- /dev/null +++ b/mock-service/docs/test-scenario-routing.md @@ -0,0 +1,167 @@ +# Mock Service — Test Scenario Routing + +## How to achieve different data per test scenario + +There are several patterns for this, ranging from simple (no code changes) to more dynamic: + +### 1. Request-based routing (works with stubr today) + +WireMock matches on **request attributes**, so different inputs naturally get different responses. You already do this: + +```json +// order-success.json — matches when request body has valid fields +{ "request": { "method": "POST", "urlPath": "/order", "bodyPatterns": [{ "matchesJsonPath": "$.email" }] }, + "response": { "status": 200 } } + +// order-missing-email.json — matches when email is absent +{ "request": { "method": "POST", "urlPath": "/order", "bodyPatterns": [{ "matchesJsonPath": { "expression": "$.email", "absent": true } }] }, + "response": { "status": 422 } } +``` + +**Test scenario control**: your test code sends different request data → gets different responses. No mock configuration needed per test. + +### 2. Custom header routing (works with stubr today) + +Add stubs that match on a special header. Tests set the header to select a scenario: + +```json +// order-scenario-dispatched.json +{ + "priority": 1, + "request": { + "method": "GET", + "urlPathPattern": "/order/.*", + "headers": { "X-Mock-Scenario": { "equalTo": "dispatched" } } + }, + "response": { "status": 200, "jsonBody": { "status": "dispatched" } } +} + +// order-default.json (lower priority fallback) +{ + "priority": 5, + "request": { + "method": "GET", + "urlPathPattern": "/order/.*" + }, + "response": { "status": 200, "jsonBody": { "status": "received" } } +} +``` + +Test code: + +```typescript +// Test: order is dispatched +const res = await fetch(`${supplierUrl}/order/123`, { + headers: { "X-Mock-Scenario": "dispatched" } +}); +``` + +**Caveat**: the header must flow through your real service to the supplier call. This works if your order-service Lambda forwards custom headers (or you can add that). + +### 3. URL-based scenario routing (works with stubr today) + +Use different URL paths or query parameters per scenario: + +```json +// Match specific order IDs +{ "request": { "method": "GET", "urlPath": "/order/NOT_FOUND" }, + "response": { "status": 404 } } + +{ "request": { "method": "GET", "urlPathPattern": "/order/DISPATCH.*" }, + "response": { "status": 200, "jsonBody": { "status": "dispatched" } } } + +// Default fallback +{ "request": { "method": "GET", "urlPathPattern": "/order/.*" }, + "response": { "status": 200, "jsonBody": { "status": "received" } } } +``` + +Test code just creates orders with well-known IDs. + +### 4. WireMock Scenarios (stateful — NOT supported by stubr) + +Real WireMock has a `"scenario"` + `"requiredScenarioState"` + `"newScenarioState"` feature that returns different responses on sequential calls. **stubr does not support this**. You'd need: + +- **WireMock proper** (Java/Docker) as a long-running service, OR +- The custom TypeScript matcher with added state management + +### Recommendation + +**Approach #2 or #3** covers most test scenarios without any code changes — just add JSON stubs with different priority/matching rules. The pattern is: + +| Test needs... | Stub matches on... | +|---|---| +| Error response | Request body missing required fields | +| Specific status | `X-Mock-Scenario` header or well-known ID in URL | +| Default happy path | Low-priority catch-all stub | + +If you find yourself needing **stateful scenarios** (e.g., "first call returns pending, second call returns complete"), that's where stubr falls short and you'd need WireMock running as a persistent service. + +--- + +## Running multiple test scenarios against the same AWS environment + +With static stubs (stubr or any file-based approach), the mock has no state — it can only differentiate based on what's in the request. + +### What works: convention-based test data + +Design your stubs around **well-known patterns** in the request data that your test controls: + +```json +// order-not-found.json — any order ID starting with "NOTFOUND-" +{ "request": { "method": "GET", "urlPathPattern": "/order/NOTFOUND-.*" }, + "response": { "status": 404 } } + +// order-dispatched.json — any order ID starting with "DISPATCHED-" +{ "request": { "method": "GET", "urlPathPattern": "/order/DISPATCHED-.*" }, + "response": { "status": 200, "jsonBody": { "status": "dispatched" } } } + +// order-default.json — everything else gets "received" +{ "priority": 10, + "request": { "method": "GET", "urlPathPattern": "/order/.*" }, + "response": { "status": 200, "jsonBody": { "status": "received" } } } +``` + +Each test creates its order with a deterministic ID: + +```typescript +// Test A — expects "not found" +const orderId = `NOTFOUND-${uuid()}`; + +// Test B — expects "dispatched" +const orderId = `DISPATCHED-${uuid()}`; + +// Test C — expects default "received" +const orderId = `SCENARIO-C-${uuid()}`; +``` + +This works **concurrently** — multiple tests hitting the same mock Lambda at the same time, each getting the right response because the request URL differs. + +### What doesn't work with static stubs + +If your tests need the **exact same request** to return **different data at different times** — e.g.: + +1. Test calls `GET /order/123` → expects "received" +2. Same test calls `GET /order/123` again → expects "dispatched" + +That's **stateful mocking** (WireMock's "scenarios" feature). No static stub server — stubr, the custom TS matcher, or any file-based approach — can do this on Lambda, because: + +- Lambda is stateless between invocations +- stubr doesn't implement WireMock scenarios even if it were long-running + +### If you need stateful mocking + +You'd need **WireMock proper running as a persistent service** (not a Lambda): + +| Option | How | +|---|---| +| **WireMock on Fargate/ECS** | Docker container running `wiremock/wiremock`, always-on | +| **WireMock in test harness** | Start WireMock in-process during test run (Java/Node testcontainers) | +| **WireMock Cloud** | SaaS, hosted by WireMock team | + +With a persistent WireMock, tests could use the admin API to set scenario state before each test. + +### Bottom line + +**For 90% of integration test scenarios**: convention-based stub routing (URL patterns, body content, headers) works fine with stubr on Lambda and supports concurrent test execution. + +**For sequential state changes** (same request → different response on Nth call): you need a long-running WireMock instance, not a Lambda. diff --git a/mock-service/jest.config.ts b/mock-service/jest.config.ts deleted file mode 100644 index 7d4b8e34..00000000 --- a/mock-service/jest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Config } from "jest"; - -const config: Config = { - preset: "ts-jest", - testEnvironment: "node", - roots: ["/src"], - testMatch: ["**/*.test.ts"], - moduleFileExtensions: ["ts", "js", "json"], - transform: { - "^.+\\.ts$": ["ts-jest", { useESM: false }], - }, -}; - -export default config; diff --git a/mock-service/package.json b/mock-service/package.json index 4e98d774..5ed1bc10 100644 --- a/mock-service/package.json +++ b/mock-service/package.json @@ -1,34 +1,11 @@ { "name": "@hometest-service/mock-service", "version": "1.0.0", - "description": "WireMock-compatible stub runner for AWS Lambda — reads JSON mapping files from local-environment/wiremock/mappings/", + "description": "WireMock-compatible stub runner for AWS Lambda using stubr (Rust)", "private": true, - "type": "module", "scripts": { - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "build": "npx tsx scripts/build.ts", - "package": "npx tsx scripts/package.ts", - "clean": "rm -rf dist" - }, - "dependencies": { - "@middy/core": "^7.1.3", - "@middy/http-error-handler": "^7.1.3" - }, - "devDependencies": { - "@jest/globals": "^30.2.0", - "@types/aws-lambda": "^8.10.161", - "@types/jest": "^30.0.0", - "@types/node": "^24.12.0", - "archiver": "7.0.1", - "@types/archiver": "7.0.0", - "esbuild": "^0.27.3", - "jest": "^30.2.0", - "ts-jest": "^29.4.6", - "tsx": "4.21.0", - "typescript": "^5.9.3" + "build": "bash scripts/build.sh", + "package": "bash scripts/package.sh", + "clean": "cargo clean && rm -rf dist" } } diff --git a/mock-service/scripts/build.sh b/mock-service/scripts/build.sh new file mode 100755 index 00000000..cf3e828f --- /dev/null +++ b/mock-service/scripts/build.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +MAPPINGS_SRC="$ROOT_DIR/../local-environment/wiremock/mappings" +DIST_DIR="$ROOT_DIR/dist" +OUT_DIR="$DIST_DIR/mock-service-lambda" + +echo "Building mock-service (stubr) for Lambda..." + +cd "$ROOT_DIR" +cargo lambda build --release --arm64 + +# Prepare output directory +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR" + +# Copy the bootstrap binary +cp target/lambda/mock-service/bootstrap "$OUT_DIR/bootstrap" + +# Copy WireMock JSON mapping files +if [[ -d "$MAPPINGS_SRC" ]]; then + cp -r "$MAPPINGS_SRC" "$OUT_DIR/mappings" + echo "Copied WireMock mappings from $MAPPINGS_SRC" +else + echo "WARNING: WireMock mappings directory not found at $MAPPINGS_SRC" +fi + +echo "Build complete: $OUT_DIR" diff --git a/mock-service/scripts/build.ts b/mock-service/scripts/build.ts deleted file mode 100644 index eabfb240..00000000 --- a/mock-service/scripts/build.ts +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node - -import { build } from "esbuild"; -import { existsSync, rmSync, mkdirSync, writeFileSync, cpSync } from "fs"; -import { join } from "path"; - -const ROOT_DIR = process.cwd(); -const SRC_DIR = join(ROOT_DIR, "src"); -const DIST_DIR = join(ROOT_DIR, "dist"); -const MAPPINGS_SRC = join(ROOT_DIR, "..", "local-environment", "wiremock", "mappings"); - -async function buildMockService(): Promise { - console.log("Building mock-service lambda..."); - - const entryPoint = join(SRC_DIR, "index.ts"); - const outDir = join(DIST_DIR, "mock-service-lambda"); - const outFile = join(outDir, "index.js"); - const mappingsDest = join(outDir, "mappings"); - - if (!existsSync(entryPoint)) { - throw new Error(`Entry point not found: ${entryPoint}`); - } - - if (existsSync(outDir)) { - rmSync(outDir, { recursive: true }); - } - - mkdirSync(outDir, { recursive: true }); - - const result = await build({ - entryPoints: [entryPoint], - bundle: true, - outfile: outFile, - platform: "node", - target: "node24", - format: "cjs", - external: ["aws-sdk", "@aws-sdk/client-*", "@aws-sdk/lib-*"], - packages: "bundle", - minify: false, - sourcemap: false, - logLevel: "info", - metafile: true, - }); - - writeFileSync(join(outDir, "meta.json"), JSON.stringify(result.metafile, null, 2)); - - // Copy WireMock JSON mapping files into the build output - if (existsSync(MAPPINGS_SRC)) { - cpSync(MAPPINGS_SRC, mappingsDest, { recursive: true }); - console.log(`Copied WireMock mappings from ${MAPPINGS_SRC} → ${mappingsDest}`); - } else { - console.warn(`WARNING: WireMock mappings directory not found at ${MAPPINGS_SRC}`); - } - - console.log("Build complete."); -} - -buildMockService().catch((err) => { - console.error("Build failed:", err); - process.exit(1); -}); diff --git a/mock-service/scripts/package.sh b/mock-service/scripts/package.sh new file mode 100755 index 00000000..024db7b7 --- /dev/null +++ b/mock-service/scripts/package.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +DIST_DIR="$ROOT_DIR/dist" +OUT_DIR="$DIST_DIR/mock-service-lambda" +ZIP_PATH="$DIST_DIR/mock-service-lambda.zip" + +echo "Creating deployment zip for mock-service..." + +if [[ ! -d "$OUT_DIR" ]]; then + echo "No dist directory found — run 'npm run build' first" + exit 1 +fi + +rm -f "$ZIP_PATH" + +cd "$OUT_DIR" +zip -r "$ZIP_PATH" bootstrap mappings/ + +echo "Created $ZIP_PATH ($(du -h "$ZIP_PATH" | cut -f1))" diff --git a/mock-service/scripts/package.ts b/mock-service/scripts/package.ts deleted file mode 100644 index 5d714cce..00000000 --- a/mock-service/scripts/package.ts +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node - -import { createWriteStream, existsSync, rmSync } from "fs"; -import { join } from "path"; -import archiver from "archiver"; - -const ROOT_DIR = process.cwd(); -const DIST_DIR = join(ROOT_DIR, "dist"); - -async function createMockServiceZip(): Promise { - console.log("Creating deployment zip for mock-service..."); - - const lambdaPath = join(DIST_DIR, "mock-service-lambda"); - const indexPath = join(lambdaPath, "index.js"); - const zipPath = join(DIST_DIR, "mock-service-lambda.zip"); - - if (!existsSync(lambdaPath)) { - throw new Error(`No dist directory found — run 'npm run build' first`); - } - - if (existsSync(zipPath)) { - rmSync(zipPath); - } - - return new Promise((resolve, reject) => { - const output = createWriteStream(zipPath); - const archive = archiver("zip", { zlib: { level: 9 } }); - - output.on("close", () => { - console.log(`Created ${zipPath} (${archive.pointer()} bytes)`); - resolve(); - }); - - archive.on("error", reject); - archive.pipe(output); - - // Bundle the compiled handler - archive.file(indexPath, { name: "index.js" }); - - // Bundle the WireMock JSON mapping files - const mappingsPath = join(lambdaPath, "mappings"); - if (existsSync(mappingsPath)) { - archive.directory(mappingsPath, "mappings"); - console.log("Including WireMock mappings directory"); - } - - archive.finalize(); - }); -} - -createMockServiceZip().catch((err) => { - console.error("Package failed:", err); - process.exit(1); -}); diff --git a/mock-service/src/index.ts b/mock-service/src/index.ts deleted file mode 100644 index bc91cc20..00000000 --- a/mock-service/src/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import middy from "@middy/core"; -import httpErrorHandler from "@middy/http-error-handler"; -import { loadMappings, matchRequest } from "./stub-matcher"; -import { renderTemplate } from "./template-engine"; - -/** - * Generic WireMock-compatible stub runner for AWS Lambda. - * - * Loads WireMock JSON mapping files from the bundled `mappings/` directory - * and matches incoming API Gateway requests against them using the same - * matching rules as WireMock (method, urlPath, urlPathPattern, headers, - * queryParameters, bodyPatterns, priority). - * - * The JSON stub files are the single source of truth — no per-endpoint - * TypeScript code needed. To add a new mock, just drop a JSON file into - * local-environment/wiremock/mappings/. - */ - -const mappings = loadMappings(); - -const lambdaHandler = async ( - event: APIGatewayProxyEvent, -): Promise => { - // Strip the API Gateway stage prefix and /mock/supplier or /mock/cognito prefix - // so the path matches what WireMock sees: - // /mock/supplier/oauth/token → /oauth/token - // /mock/supplier/order → /order - // /mock/cognito/.well-known/jwks.json → /.well-known/jwks.json - // /mock/postcode/SW1A1AA → /postcode/SW1A1AA - const rawPath = event.pathParameters?.proxy - ? `/${event.pathParameters.proxy}` - : event.path; - - const path = - rawPath - .replace(/^\/mock\/supplier/, "") - .replace(/^\/mock\/cognito/, "") - .replace(/^\/mock/, "") || "/"; - - console.log("mock-service", { - method: event.httpMethod, - originalPath: event.path, - matchPath: path, - }); - - const match = matchRequest(mappings, { - method: event.httpMethod, - path, - headers: event.headers ?? {}, - queryParameters: event.queryStringParameters ?? {}, - body: event.body ?? "", - }); - - if (!match) { - return { - statusCode: 404, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - error: "No matching stub", - message: `No WireMock mapping matched ${event.httpMethod} ${path}`, - availableMappings: mappings.map((m) => ({ - priority: m.priority, - method: m.request.method, - urlPath: m.request.urlPath, - urlPathPattern: m.request.urlPathPattern, - })), - }), - }; - } - - const response = match.response; - const headers: Record = { ...response.headers }; - - let body: string; - if (response.jsonBody !== undefined) { - body = JSON.stringify(response.jsonBody); - if (!headers["Content-Type"]) { - headers["Content-Type"] = "application/json"; - } - } else { - body = response.body ?? ""; - } - - // Apply WireMock response templating ({{randomValue}}, {{now}}, etc.) - body = renderTemplate(body); - - return { - statusCode: response.status ?? 200, - headers, - body, - }; -}; - -export const handler = middy() - .use(httpErrorHandler()) - .handler(lambdaHandler); diff --git a/mock-service/src/main.rs b/mock-service/src/main.rs new file mode 100644 index 00000000..2b08baa8 --- /dev/null +++ b/mock-service/src/main.rs @@ -0,0 +1,90 @@ +use lambda_http::{run, service_fn, Body, Error, Request, Response}; +use std::sync::OnceLock; +use stubr::Stubr; + +static STUBR_URI: OnceLock = OnceLock::new(); +static CLIENT: OnceLock = OnceLock::new(); + +/// Lambda handler — proxies each API Gateway request to the local stubr server +/// after stripping the /mock/supplier, /mock/cognito, and /mock prefixes so +/// that paths match what WireMock JSON stubs expect. +async fn handler(event: Request) -> Result, Error> { + let stubr_uri = STUBR_URI.get().expect("stubr not initialised"); + let client = CLIENT.get().expect("reqwest client not initialised"); + + // Strip API Gateway path prefixes so stubs match raw paths: + // /mock/supplier/oauth/token → /oauth/token + // /mock/cognito/.well-known/jwks.json → /.well-known/jwks.json + // /mock/postcode/SW1A1AA → /postcode/SW1A1AA + let original_path = event.uri().path(); + let path = original_path + .strip_prefix("/mock/supplier") + .or_else(|| original_path.strip_prefix("/mock/cognito")) + .or_else(|| original_path.strip_prefix("/mock")) + .unwrap_or(original_path); + let path = if path.is_empty() { "/" } else { path }; + + tracing::info!( + method = %event.method(), + original_path, + match_path = path, + "mock-service request" + ); + + // Build forwarded URL (path + query string) + let mut url = format!("{}{}", stubr_uri, path); + if let Some(query) = event.uri().query() { + url.push('?'); + url.push_str(query); + } + + // Forward the request to stubr + let mut req = client.request(event.method().clone(), &url); + for (name, value) in event.headers() { + req = req.header(name, value); + } + req = match event.body() { + Body::Text(text) => req.body(text.clone()), + Body::Binary(bytes) => req.body(bytes.clone()), + Body::Empty => req, + }; + + let resp = req.send().await?; + + // Convert stubr's response back to a Lambda response + let status = resp.status(); + let resp_headers = resp.headers().clone(); + let body_text = resp.text().await?; + + let mut builder = Response::builder().status(status); + for (name, value) in &resp_headers { + builder = builder.header(name, value); + } + Ok(builder.body(Body::Text(body_text))?) +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .json() + .without_time() + .init(); + + // Start stubr with the bundled WireMock JSON mapping files. + // Lambda extracts the zip to /var/task/ so CWD is /var/task/. + let stubr = Stubr::start("mappings").await; + tracing::info!(uri = %stubr.uri(), "stubr started with WireMock mappings"); + + STUBR_URI.set(stubr.uri().to_string()).ok(); + CLIENT.set(reqwest::Client::new()).ok(); + + // Keep stubr alive for the Lambda runtime's lifetime. + // Leak is intentional — the process is killed when Lambda recycles. + std::mem::forget(stubr); + + run(service_fn(handler)).await +} diff --git a/mock-service/src/stub-matcher.test.ts b/mock-service/src/stub-matcher.test.ts deleted file mode 100644 index ff798bca..00000000 --- a/mock-service/src/stub-matcher.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { matchRequest, WireMockMapping } from "./stub-matcher"; - -const makeMappings = (overrides: Partial[]): WireMockMapping[] => - overrides.map((o, i) => ({ - priority: o.priority ?? i, - request: o.request ?? {}, - response: o.response ?? { status: 200 }, - })); - -describe("stub-matcher", () => { - describe("method matching", () => { - it("matches exact HTTP method", () => { - const mappings = makeMappings([{ request: { method: "POST" }, response: { status: 201 } }]); - expect(matchRequest(mappings, { method: "POST", path: "/", headers: {}, queryParameters: {}, body: "" })?.response.status).toBe(201); - }); - - it("rejects wrong HTTP method", () => { - const mappings = makeMappings([{ request: { method: "POST" }, response: { status: 201 } }]); - expect(matchRequest(mappings, { method: "GET", path: "/", headers: {}, queryParameters: {}, body: "" })).toBeUndefined(); - }); - }); - - describe("urlPath matching", () => { - it("matches exact urlPath", () => { - const mappings = makeMappings([{ request: { method: "GET", urlPath: "/order" }, response: { status: 200 } }]); - expect(matchRequest(mappings, { method: "GET", path: "/order", headers: {}, queryParameters: {}, body: "" })).toBeDefined(); - }); - - it("rejects non-matching urlPath", () => { - const mappings = makeMappings([{ request: { method: "GET", urlPath: "/order" }, response: { status: 200 } }]); - expect(matchRequest(mappings, { method: "GET", path: "/results", headers: {}, queryParameters: {}, body: "" })).toBeUndefined(); - }); - }); - - describe("urlPathPattern matching", () => { - it("matches regex urlPathPattern", () => { - const mappings = makeMappings([ - { request: { method: "GET", urlPathPattern: "/(results|api/results)" }, response: { status: 200 } }, - ]); - expect(matchRequest(mappings, { method: "GET", path: "/results", headers: {}, queryParameters: {}, body: "" })).toBeDefined(); - expect(matchRequest(mappings, { method: "GET", path: "/api/results", headers: {}, queryParameters: {}, body: "" })).toBeDefined(); - expect(matchRequest(mappings, { method: "GET", path: "/unknown", headers: {}, queryParameters: {}, body: "" })).toBeUndefined(); - }); - }); - - describe("header matching", () => { - it("matches header with 'contains'", () => { - const mappings = makeMappings([ - { - request: { - method: "POST", - headers: { "Content-Type": { contains: "application/x-www-form-urlencoded" } }, - }, - response: { status: 200 }, - }, - ]); - expect( - matchRequest(mappings, { - method: "POST", - path: "/", - headers: { "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" }, - queryParameters: {}, - body: "", - }), - ).toBeDefined(); - }); - - it("matches header with 'matches' (regex)", () => { - const mappings = makeMappings([ - { request: { headers: { "X-Correlation-ID": { matches: ".*" } } }, response: { status: 200 } }, - ]); - expect( - matchRequest(mappings, { method: "GET", path: "/", headers: { "X-Correlation-ID": "abc-123" }, queryParameters: {}, body: "" }), - ).toBeDefined(); - }); - - it("matches headers case-insensitively", () => { - const mappings = makeMappings([ - { request: { headers: { "Content-Type": { contains: "json" } } }, response: { status: 200 } }, - ]); - expect( - matchRequest(mappings, { method: "GET", path: "/", headers: { "content-type": "application/json" }, queryParameters: {}, body: "" }), - ).toBeDefined(); - }); - }); - - describe("queryParameters matching", () => { - it("matches query param with 'matches'", () => { - const mappings = makeMappings([ - { request: { queryParameters: { order_uid: { matches: ".{10,}" } } }, response: { status: 200 } }, - ]); - expect( - matchRequest(mappings, { method: "GET", path: "/", headers: {}, queryParameters: { order_uid: "550e8400-e29b-41d4-a716-446655440000" }, body: "" }), - ).toBeDefined(); - }); - - it("matches query param with 'absent'", () => { - const mappings = makeMappings([ - { request: { queryParameters: { order_uid: { absent: true } } }, response: { status: 400 } }, - ]); - expect( - matchRequest(mappings, { method: "GET", path: "/", headers: {}, queryParameters: {}, body: "" })?.response.status, - ).toBe(400); - }); - }); - - describe("bodyPatterns matching", () => { - it("matches body with 'matches' (regex)", () => { - const mappings = makeMappings([ - { request: { bodyPatterns: [{ matches: ".*grant_type=client_credentials.*" }] }, response: { status: 200 } }, - ]); - expect( - matchRequest(mappings, { - method: "POST", - path: "/", - headers: {}, - queryParameters: {}, - body: "grant_type=client_credentials&client_id=x", - }), - ).toBeDefined(); - }); - - it("matches body with matchesJsonPath absent", () => { - const mappings = makeMappings([ - { - request: { - bodyPatterns: [{ matchesJsonPath: { expression: "$.subject", absent: true } }], - }, - response: { status: 422 }, - }, - ]); - expect( - matchRequest(mappings, { - method: "POST", - path: "/", - headers: {}, - queryParameters: {}, - body: JSON.stringify({ code: "test" }), - })?.response.status, - ).toBe(422); - - // subject IS present → should not match - expect( - matchRequest(mappings, { - method: "POST", - path: "/", - headers: {}, - queryParameters: {}, - body: JSON.stringify({ subject: { reference: "#patient-1" } }), - }), - ).toBeUndefined(); - }); - }); - - describe("priority ordering", () => { - it("returns the highest-priority (lowest number) match", () => { - const mappings = makeMappings([ - { priority: 10, request: { method: "POST", urlPath: "/oauth/token" }, response: { status: 400 } }, - { priority: 3, request: { method: "POST", urlPath: "/oauth/token" }, response: { status: 401 } }, - { priority: 5, request: { method: "POST", urlPath: "/oauth/token" }, response: { status: 200 } }, - ]); - // After sorting by priority, 3 comes first - expect(matchRequest(mappings, { method: "POST", path: "/oauth/token", headers: {}, queryParameters: {}, body: "" })?.response.status).toBe(401); - }); - }); -}); diff --git a/mock-service/src/stub-matcher.ts b/mock-service/src/stub-matcher.ts deleted file mode 100644 index 7c1d89ab..00000000 --- a/mock-service/src/stub-matcher.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { readdirSync, readFileSync } from "fs"; -import { join } from "path"; - -/** - * WireMock-compatible stub matcher. - * - * Loads JSON mapping files and matches incoming requests using the same - * rules as WireMock: method, urlPath/urlPathPattern, headers, - * queryParameters, bodyPatterns, and priority ordering. - * - * Supported matching operators: - * - equalTo, contains, matches (regex) - * - absent (true = param must not be present) - * - matchesJsonPath with expression + absent - */ - -// ---------- Types ---------- - -export interface WireMockMapping { - priority: number; - request: WireMockRequest; - response: WireMockResponse; -} - -interface WireMockRequest { - method?: string; - urlPath?: string; - urlPathPattern?: string; - headers?: Record; - queryParameters?: Record; - bodyPatterns?: BodyPattern[]; -} - -interface WireMockResponse { - status?: number; - headers?: Record; - jsonBody?: unknown; - body?: string; -} - -type MatcherDef = { - equalTo?: string; - contains?: string; - matches?: string; - absent?: boolean; - caseInsensitive?: boolean; -}; - -type BodyPattern = { - equalTo?: string; - contains?: string; - matches?: string; - matchesJsonPath?: JsonPathMatcher | string; -}; - -type JsonPathMatcher = { - expression: string; - absent?: boolean; -}; - -export interface IncomingRequest { - method: string; - path: string; - headers: Record; - queryParameters: Record; - body: string; -} - -// ---------- Loading ---------- - -const MAPPINGS_DIR = join(__dirname, "mappings"); - -export function loadMappings(): WireMockMapping[] { - let files: string[]; - try { - files = readdirSync(MAPPINGS_DIR).filter((f) => f.endsWith(".json")); - } catch { - console.warn(`No mappings directory found at ${MAPPINGS_DIR}`); - return []; - } - - const mappings: WireMockMapping[] = files.map((file) => { - const raw = readFileSync(join(MAPPINGS_DIR, file), "utf-8"); - const mapping = JSON.parse(raw) as Partial; - return { - priority: mapping.priority ?? 0, - request: mapping.request ?? {}, - response: mapping.response ?? { status: 200 }, - }; - }); - - // Sort by priority ascending (lower number = higher priority, matched first) - mappings.sort((a, b) => a.priority - b.priority); - - console.log(`Loaded ${mappings.length} WireMock mappings`); - return mappings; -} - -// ---------- Matching ---------- - -export function matchRequest( - mappings: WireMockMapping[], - req: IncomingRequest, -): WireMockMapping | undefined { - return mappings.find((mapping) => isMatch(mapping.request, req)); -} - -function isMatch(spec: WireMockRequest, req: IncomingRequest): boolean { - // Method - if (spec.method && spec.method.toUpperCase() !== req.method.toUpperCase()) { - return false; - } - - // URL path — exact match - if (spec.urlPath && spec.urlPath !== req.path) { - return false; - } - - // URL path — regex match - if (spec.urlPathPattern) { - try { - if (!new RegExp(spec.urlPathPattern).test(req.path)) { - return false; - } - } catch { - return false; - } - } - - // Headers - if (spec.headers) { - for (const [name, matcher] of Object.entries(spec.headers)) { - // Header lookup is case-insensitive - const actualValue = findHeader(req.headers, name); - if (!matchValue(matcher, actualValue)) { - return false; - } - } - } - - // Query parameters - if (spec.queryParameters) { - for (const [name, matcher] of Object.entries(spec.queryParameters)) { - const actualValue = req.queryParameters[name] ?? undefined; - if (!matchValue(matcher, actualValue)) { - return false; - } - } - } - - // Body patterns - if (spec.bodyPatterns) { - for (const pattern of spec.bodyPatterns) { - if (!matchBody(pattern, req.body)) { - return false; - } - } - } - - return true; -} - -// ---------- Value matchers ---------- - -function matchValue(matcher: MatcherDef, value: string | undefined): boolean { - // absent check - if (matcher.absent === true) { - return value === undefined || value === null; - } - if (matcher.absent === false) { - return value !== undefined && value !== null; - } - - // If the value is missing but matcher expects something, no match - if (value === undefined || value === null) { - return false; - } - - const caseInsensitive = matcher.caseInsensitive === true; - const v = caseInsensitive ? value.toLowerCase() : value; - - if (matcher.equalTo !== undefined) { - const expected = caseInsensitive ? matcher.equalTo.toLowerCase() : matcher.equalTo; - return v === expected; - } - - if (matcher.contains !== undefined) { - const expected = caseInsensitive ? matcher.contains.toLowerCase() : matcher.contains; - return v.includes(expected); - } - - if (matcher.matches !== undefined) { - try { - const flags = caseInsensitive ? "i" : undefined; - return new RegExp(matcher.matches, flags).test(value); - } catch { - return false; - } - } - - return true; -} - -// ---------- Body matchers ---------- - -function matchBody(pattern: BodyPattern, body: string): boolean { - if (pattern.equalTo !== undefined) { - return body === pattern.equalTo; - } - - if (pattern.contains !== undefined) { - return body.includes(pattern.contains); - } - - if (pattern.matches !== undefined) { - try { - return new RegExp(pattern.matches).test(body); - } catch { - return false; - } - } - - if (pattern.matchesJsonPath !== undefined) { - return matchJsonPath(pattern.matchesJsonPath, body); - } - - return true; -} - -// ---------- Simplified JSONPath matcher ---------- - -function matchJsonPath( - matcher: JsonPathMatcher | string, - body: string, -): boolean { - let parsed: unknown; - try { - parsed = JSON.parse(body); - } catch { - return false; - } - - if (typeof matcher === "string") { - // Simple JSONPath expression — just check if the path resolves to something - const value = resolveJsonPath(parsed, matcher); - return value !== undefined; - } - - // Object form with expression + absent - const value = resolveJsonPath(parsed, matcher.expression); - - if (matcher.absent === true) { - return value === undefined; - } - - return value !== undefined; -} - -/** - * Minimal JSONPath resolver — supports dot-notation paths like `$.subject`, `$.code.coding[0].code`. - * This isn't a full JSONPath implementation but covers the patterns used in the stubs. - */ -function resolveJsonPath(obj: unknown, expression: string): unknown { - // Strip leading $. prefix - const path = expression.replace(/^\$\.?/, ""); - if (!path) return obj; - - const segments = path.split(/\.|\[|\]/).filter(Boolean); - let current: unknown = obj; - - for (const seg of segments) { - if (current === null || current === undefined) return undefined; - if (typeof current === "object") { - current = (current as Record)[seg]; - } else { - return undefined; - } - } - - return current; -} - -// ---------- Helpers ---------- - -function findHeader( - headers: Record, - name: string, -): string | undefined { - // Case-insensitive header lookup - const lowerName = name.toLowerCase(); - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === lowerName) { - return value; - } - } - return undefined; -} diff --git a/mock-service/src/template-engine.test.ts b/mock-service/src/template-engine.test.ts deleted file mode 100644 index 78ef4560..00000000 --- a/mock-service/src/template-engine.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { renderTemplate } from "./template-engine"; - -describe("template-engine", () => { - describe("{{randomValue type='UUID'}}", () => { - it("replaces with a valid UUID", () => { - const input = '{"id": "{{randomValue type=\'UUID\'}}"}'; - const result = renderTemplate(input); - const parsed = JSON.parse(result); - expect(parsed.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); - }); - - it("replaces multiple UUIDs with unique values", () => { - const input = "{{randomValue type='UUID'}} {{randomValue type='UUID'}}"; - const result = renderTemplate(input); - const [a, b] = result.split(" "); - expect(a).not.toBe(b); - }); - }); - - describe("{{now ...}}", () => { - it("replaces {{now}} with ISO date string", () => { - const result = renderTemplate("{{now}}"); - expect(new Date(result).toISOString()).toBe(result); - }); - - it("applies format", () => { - const result = renderTemplate("{{now format='yyyy-MM-dd'}}"); - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); - - it("applies negative offset", () => { - const result = renderTemplate("{{now offset='-2 days' format='yyyy-MM-dd'}}"); - const expected = new Date(); - expected.setDate(expected.getDate() - 2); - expect(result).toBe( - `${expected.getFullYear()}-${String(expected.getMonth() + 1).padStart(2, "0")}-${String(expected.getDate()).padStart(2, "0")}`, - ); - }); - }); -}); diff --git a/mock-service/src/template-engine.ts b/mock-service/src/template-engine.ts deleted file mode 100644 index ba58d20e..00000000 --- a/mock-service/src/template-engine.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { randomUUID } from "crypto"; - -/** - * WireMock response template renderer. - * - * Supports the template helpers used in the existing stub mappings: - * {{randomValue type='UUID'}} - * {{now format='yyyy-MM-dd'}} - * {{now offset='-2 days' format='yyyy-MM-dd'}} - */ - -export function renderTemplate(body: string): string { - // {{randomValue type='UUID'}} - body = body.replace(/\{\{randomValue\s+type='UUID'\}\}/g, () => randomUUID()); - - // {{now ...}} with optional offset and format - body = body.replace( - /\{\{now(?:\s+offset='([^']*)')?\s*(?:format='([^']*)')?\}\}/g, - (_match, offset?: string, format?: string) => { - let date = new Date(); - - if (offset) { - date = applyOffset(date, offset); - } - - if (format) { - return formatDate(date, format); - } - - return date.toISOString(); - }, - ); - - return body; -} - -function applyOffset(date: Date, offset: string): Date { - const result = new Date(date); - // Parse offsets like "-2 days", "+1 hours", "-30 minutes" - const match = offset.match(/^([+-]?\d+)\s+(second|minute|hour|day|week|month|year)s?$/i); - if (!match) return result; - - const amount = parseInt(match[1], 10); - const unit = match[2].toLowerCase(); - - switch (unit) { - case "second": - result.setSeconds(result.getSeconds() + amount); - break; - case "minute": - result.setMinutes(result.getMinutes() + amount); - break; - case "hour": - result.setHours(result.getHours() + amount); - break; - case "day": - result.setDate(result.getDate() + amount); - break; - case "week": - result.setDate(result.getDate() + amount * 7); - break; - case "month": - result.setMonth(result.getMonth() + amount); - break; - case "year": - result.setFullYear(result.getFullYear() + amount); - break; - } - - return result; -} - -/** - * Simplified Java SimpleDateFormat → JS date formatter. - * Covers the patterns used in WireMock stubs. - */ -function formatDate(date: Date, format: string): string { - const pad = (n: number, len = 2) => String(n).padStart(len, "0"); - - return format - .replace(/yyyy/g, String(date.getFullYear())) - .replace(/MM/g, pad(date.getMonth() + 1)) - .replace(/dd/g, pad(date.getDate())) - .replace(/HH/g, pad(date.getHours())) - .replace(/mm/g, pad(date.getMinutes())) - .replace(/ss/g, pad(date.getSeconds())); -} diff --git a/mock-service/tsconfig.json b/mock-service/tsconfig.json deleted file mode 100644 index 2b2ec183..00000000 --- a/mock-service/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "ESNext", - "lib": ["ES2024"], - "baseUrl": ".", - "moduleResolution": "node", - "declaration": true, - "strict": true, - "noImplicitAny": false, - "esModuleInterop": true, - "strictNullChecks": true, - "noImplicitThis": true, - "alwaysStrict": true, - "noUnusedLocals": false, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": false, - "inlineSourceMap": true, - "inlineSources": true, - "resolveJsonModule": true, - "typeRoots": ["./node_modules/@types"], - "types": ["node", "jest", "aws-lambda"] - }, - "exclude": ["node_modules", "dist"] -}