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..adf34505 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 = "bootstrap" + runtime = "provided.al2023" + architectures = ["arm64"] + source_code_hash = filebase64sha256("${path.module}/../../mock-service/dist/mock-service-lambda.zip") + timeout = 30 + + environment { + variables = { + 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" +} 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/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 new file mode 100644 index 00000000..14077b95 --- /dev/null +++ b/mock-service/README.md @@ -0,0 +1,114 @@ +# Mock Service + +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**. To add or change a mock, edit a JSON stub file — no code changes needed. + +## How it works + +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 + +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" } + } +} +``` + +Rebuild the mock-service to pick up the change. No Rust code changes needed. + +## URL path prefixes + +API Gateway routes requests under `/mock/supplier/` and `/mock/cognito/` prefixes. The Lambda strips these before matching against stub files: + +- `/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 + +```bash +cd mock-service +npm run build # cargo lambda build + copy mappings → dist/ +npm run package # zip → dist/mock-service-lambda.zip (bootstrap + mappings/) +``` + +### 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 (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`. + +## 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/ +├── Cargo.toml # Rust dependencies (stubr, lambda_http, reqwest) +├── package.json # npm scripts wrapping cargo/shell commands +├── scripts/ +│ ├── build.sh # cargo lambda build + copy mappings +│ └── package.sh # zip bootstrap + mappings/ +└── src/ + └── 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/package.json b/mock-service/package.json new file mode 100644 index 00000000..5ed1bc10 --- /dev/null +++ b/mock-service/package.json @@ -0,0 +1,11 @@ +{ + "name": "@hometest-service/mock-service", + "version": "1.0.0", + "description": "WireMock-compatible stub runner for AWS Lambda using stubr (Rust)", + "private": true, + "scripts": { + "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/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/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/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."