Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
45 changes: 45 additions & 0 deletions docs/notifications-pact-contract.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 6 additions & 6 deletions src/routes/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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");
});

Expand All @@ -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");
});

Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Loading