From e765f9aa35d9cf99769581c4fcbdc62aed6771e2 Mon Sep 17 00:00:00 2001 From: Muhammed Date: Fri, 31 Jul 2026 00:18:54 +0100 Subject: [PATCH] feat: implement Pact verification for notifications and update CI workflow --- .github/workflows/ci.yml | 34 ++++++++++++++++++++-- docs/notifications-pact-contract.md | 45 +++++++++++++++++++++++++++++ package-lock.json | 25 ++++++++++++++++ package.json | 1 + src/routes/notifications.test.ts | 12 ++++---- 5 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 docs/notifications-pact-contract.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ba54c2d..87996e9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: node-version: '20.x' - name: Install dependencies - run: npm ci + run: npm install - name: Run dependency audit gate run: npm run audit:ci @@ -34,7 +34,37 @@ jobs: node-version: '20.x' - name: Install dependencies - run: npm ci + run: npm install - name: Validate alert-to-runbook mappings run: npm run validate:alert-mappings + + pact-notifications: + runs-on: ubuntu-latest + env: + PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }} + PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }} + PACT_CONSUMER_TAG: main + PROVIDER_VERSION: ${{ github.sha }} + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: npm install + + - name: Publish notifications consumer pact + if: ${{ env.PACT_BROKER_URL != '' && env.PACT_BROKER_TOKEN != '' }} + run: npm run pact:publish + + - name: Verify notifications provider against Pact broker + if: ${{ env.PACT_BROKER_URL != '' && env.PACT_BROKER_TOKEN != '' }} + run: npm run pact:verify + + - name: Verify notifications provider against local Pact file + if: ${{ env.PACT_BROKER_URL == '' || env.PACT_BROKER_TOKEN == '' }} + run: PACT_DIR=./pacts/notifications npm run pact:verify diff --git a/docs/notifications-pact-contract.md b/docs/notifications-pact-contract.md new file mode 100644 index 00000000..3b03efe4 --- /dev/null +++ b/docs/notifications-pact-contract.md @@ -0,0 +1,45 @@ +# Notifications Pact Contract + +## Purpose + +This contract protects the `/notifications` API shape exposed to the frontend and prevents silent contract drift when response fields change without a consumer update. + +## Contract coverage + +The consumer tests in [src/routes/__tests__/notifications.consumer.test.ts](../src/routes/__tests__/notifications.consumer.test.ts) define the accepted response contract for: + +- `GET /notifications` +- `PATCH /notifications/:id/read` + +The provider verification in [src/routes/notifications.pact.test.ts](../src/routes/notifications.pact.test.ts) exercises the live Express handler from [src/routes/notifications.ts](../src/routes/notifications.ts) against those Pact files. + +## Provider states + +The contract uses provider states so the response can be deterministic and safe to review: + +- `user has notifications` +- `user has no notifications` +- `a notification exists` +- `notification does not exist` + +These states are set via the `/__state` hook used by the Pact verifier, keeping the contract contractually scoped to the authenticated notification flow. + +## Security assumptions + +- Authentication is required for all notification requests. +- The broker URL and token are injected via GitHub Actions secrets, not checked into source control. +- Verification fails if the provider response shape deviates from the consumer contract, making field removal or status regressions visible before merge. +- The route keeps authorization enforcement and validation separate from the business logic so regression tests catch unauthorized and malformed request paths. + +## CI enforcement + +The CI workflow in [.github/workflows/ci.yml](../.github/workflows/ci.yml) runs a dedicated `pact-notifications` job. It: + +1. Installs dependencies. +2. Publishes the consumer contract when broker credentials are configured. +3. Verifies the live backend provider against the Pact broker if the secret-backed broker is available. +4. Falls back to the local Pact files when the broker is not configured so the contract still validates in local or unsigned CI environments. + +## Drift detection + +Removing a field from the notifications payload will fail the Pact verification until the consumer explicitly accepts the schema change. This protects frontend regressions from landing unnoticed. diff --git a/package-lock.json b/package-lock.json index 9314553e..b524d904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1567,6 +1567,31 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@pact-foundation/pact": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/@pact-foundation/pact/-/pact-17.0.1.tgz", diff --git a/package.json b/package.json index 1af1395e..7267b6c7 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test:ci": "jest --coverage --ci", "stryker": "stryker run --concurrency 4", "stryker:quick": "stryker run --concurrency 4 --timeoutMS 120000", + "pact:publish": "npx pact-broker publish pacts/notifications/revora-consumer-revora-backend.json --consumer revora-consumer --provider revora-backend --tag main --consumer-app-version ${GITHUB_SHA:-local}", "pact:verify": "jest --testPathPatterns='\\.pact\\.test\\.ts$' --runInBand --forceExit" }, "dependencies": { diff --git a/src/routes/notifications.test.ts b/src/routes/notifications.test.ts index c6851821..daf7789f 100644 --- a/src/routes/notifications.test.ts +++ b/src/routes/notifications.test.ts @@ -140,7 +140,7 @@ describe("notifications routes", () => { }); assert(capturedError instanceof AppError); - assert.strictEqual(capturedError.httpCode, 401); + assert.strictEqual(capturedError.statusCode, 401); }); it("returns 404 when notification not found or belongs to another user", async () => { @@ -152,7 +152,7 @@ describe("notifications routes", () => { }); assert(capturedError instanceof AppError); - assert.strictEqual(capturedError.httpCode, 404); + assert.strictEqual(capturedError.statusCode, 404); }); it("returns 400 for invalid UUID in params", async () => { @@ -164,7 +164,7 @@ describe("notifications routes", () => { }); assert(capturedError instanceof AppError); - assert.strictEqual(capturedError.httpCode, 400); + assert.strictEqual(capturedError.statusCode, 400); assert.strictEqual((capturedError as any).code, "VALIDATION_ERROR"); }); @@ -177,7 +177,7 @@ describe("notifications routes", () => { }); assert(capturedError instanceof AppError); - assert.strictEqual(capturedError.httpCode, 400); + assert.strictEqual(capturedError.statusCode, 400); assert.strictEqual((capturedError as any).code, "VALIDATION_ERROR"); }); @@ -190,7 +190,7 @@ describe("notifications routes", () => { }); assert(capturedError instanceof AppError); - assert.strictEqual(capturedError.httpCode, 400); + assert.strictEqual(capturedError.statusCode, 400); }); it("returns 400 when bulk mark not supported by repo", async () => { @@ -208,7 +208,7 @@ describe("notifications routes", () => { }); assert(capturedError instanceof AppError); - assert.strictEqual(capturedError.httpCode, 400); + assert.strictEqual(capturedError.statusCode, 400); }); it("handles error in getNotifications", async () => {