diff --git a/.dockerignore b/.dockerignore index 674030d..4779a6c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ certs/ vhost/ CHANGELOG.md docker-compose.example.* +dist/ diff --git a/.env.example b/.env.example index 1be1d73..3dda779 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,13 @@ -# MongoDB connection info -DB_USER= -DB_PASS= -DB_HOST=127.0.0.1 +# Where shared credentials are kept: memory, mongodb or dynamodb (required). +# See settings.contract.json for every setting. +CREDENTIAL_STORE=mongodb + +# MongoDB connection info: DB_URI, or (as in 2.x, for MongoDB Atlas) DB_HOST +# with DB_USER and DB_PASS. Not both. +DB_URI=mongodb://127.0.0.1:27017 +#DB_USER= +#DB_PASS= +#DB_HOST=your-cluster.mongodb.net DB_NAME=verifier-plus-DEV DB_COLLECTION=credentials @@ -14,4 +20,4 @@ HTTPPORT=80 EXCHANGES=on # Where wallets reach the exchange (optional; defaults to the address the # browser used to reach the page) -EXCHANGE_SERVER_URL=https://verifierplus.org \ No newline at end of file +#EXCHANGE_SERVER_URL=https://verifierplus.org diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2e3634b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,132 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Unit tests and types + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm test + - run: npx tsc --noEmit + + stores: + name: Store contract tests (memory, MongoDB, DynamoDB Local) + runs-on: ubuntu-latest + services: + mongo: + image: mongo:7 + ports: ["27017:27017"] + dynamodb: + image: amazon/dynamodb-local:3.3.2 + ports: ["8000:8000"] + env: + TEST_STORES: memory,mongodb,dynamodb + TEST_MONGO_URI: mongodb://localhost:27017 + DYNAMODB_ENDPOINT: http://localhost:8000 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm test + + # Proves the Lambda build: dist/lambda/run.sh serves the app with the memory + # store. It does not prove the Lambda Web Adapter, DynamoDB or API Gateway; + # the staging deployment's tests do. + lambda-shape: + name: Lambda build and smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run build:lambda + - name: Start dist/lambda/run.sh + run: | + CREDENTIAL_STORE=memory EXCHANGES=off PORT=3000 dist/lambda/run.sh > server.log 2>&1 & + for _ in $(seq 1 60); do + if curl -sf http://localhost:3000/health/ready; then exit 0; fi + sleep 1 + done + echo "/health/ready did not answer 200 within 60 s" + cat server.log + exit 1 + - run: npx playwright install --with-deps chromium + - run: npx playwright test --project=API --project=DesktopChrome --project=Lambda + env: + PLAYWRIGHT_TEST_URL: http://localhost:3000 + - if: ${{ !cancelled() }} + run: cat server.log + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-report-lambda-shape + path: playwright-report/ + retention-days: 14 + + # Proves the Docker image self-hosters run: MongoDB store, exchanges on. + docker: + name: Docker image with MongoDB + runs-on: ubuntu-latest + services: + mongo: + image: mongo:7 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + - run: docker build -t verifier-plus:ci . + - name: Start the image beside MongoDB + run: | + docker run -d --name verifier-plus \ + --network "${{ job.services.mongo.network }}" -p 3000:3000 \ + -e CREDENTIAL_STORE=mongodb -e DB_URI=mongodb://mongo:27017 -e EXCHANGES=on \ + verifier-plus:ci + for _ in $(seq 1 60); do + if curl -sf http://localhost:3000/api/healthz | grep -q '"healthy":true'; then + curl -s http://localhost:3000/api/healthz + exit 0 + fi + sleep 1 + done + echo "/api/healthz did not report healthy within 60 s" + curl -s http://localhost:3000/api/healthz || true + docker logs verifier-plus + exit 1 + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npx playwright test --project=API --project=DesktopChrome --project=Lambda + env: + PLAYWRIGHT_TEST_URL: http://localhost:3000 + - if: ${{ !cancelled() }} + run: docker logs verifier-plus + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-report-docker + path: playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 9fa2f74..a804385 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # production /build +/dist/ # misc .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a1c9e..9e28b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,32 @@ # verifier-plus Changelog -## Unreleased +## 3.0.0 +### Breaking +- `CREDENTIAL_STORE` and `EXCHANGES` are required. Docker users who set only `DB_USER`, `DB_PASS` and `DB_HOST` must add `CREDENTIAL_STORE=mongodb`, and `EXCHANGES=on` to keep requesting credentials from the Learner Credential Wallet (or `EXCHANGES=off`). See the README's "Upgrading from 2.x". +- `NEXT_PUBLIC_EXCHANGE_SERVER_URL` is replaced by `EXCHANGE_SERVER_URL`, which is optional and defaults to the address the browser used to reach the page. The old name is no longer read; the server logs a warning when it is set. +- A missing or invalid setting no longer crashes the server or a request. The server starts, routes that need the setting answer 503 naming it, and `/health/ready` reports it. +- Shared credentials expire one year after they are shared (`CREDENTIAL_TTL_DAYS`). Records stored by 2.x have no expiry date and are treated as expiring one year after they were created. +- The MongoDB store creates a unique index on `id` and a TTL index on `expiresAt`. A collection that already has a different index named `id_1` cannot share credentials until that index is removed; `/health/ready` names it. +- `npm test` runs the unit tests (Vitest). The Playwright tests moved to `npm run test:e2e`. +### Added +- Credential stores: `memory` (local development and tests), `mongodb` and `dynamodb`. +- `DB_URI`, a plain `mongodb://` or `mongodb+srv://` connection string, as an alternative to `DB_HOST`, `DB_USER` and `DB_PASS`. +- A 350 KB limit on a shared credential: larger ones get 413 "Credential too large to share". +- `/health/ready`: 200 when every setting is valid and the credential store answers, otherwise 503 naming what is wrong. +- `/api/config`: whether exchanges are on, and where wallets reach them. The landing page reads it. +- Limits that keep every request inside AWS Lambda's: a 20 second deadline on `/api/verify` and on `/api/proxy`'s fetch (504), a 5 MB limit on request bodies (413), and a 5 MB limit on documents `/api/proxy` fetches (502). +- `npm run build:lambda`, which builds `dist/lambda/` for AWS Lambda with the Lambda Web Adapter. +- `settings.contract.json`, every environment variable the app reads, checked against the code by a unit test. +- CI on every pull request: unit tests, the store tests against MongoDB and DynamoDB Local, the Lambda build with a smoke test, and the Docker image with MongoDB. ### Changed -- Node 24; Next 15.5.29 +- Node 24; Next 15.5.29. +- `GET /api/credentials/{id}` no longer returns MongoDB's `_id`. +- `EXCHANGES=on` is refused on AWS Lambda: exchanges are kept in process memory. +- The Playwright tests no longer compare screenshots; they check the verification icons and that images loaded. +### Fixed +- A credential that cannot be read (unknown, unshared or expired) returns 404 with an error body, instead of 500. +- `/api/verify` answers 400 to a body that is not JSON, instead of 500. +- The Docker image listens on every interface, so `/api/healthz` works inside the container. ## 2.0.2 ### Added diff --git a/Dockerfile b/Dockerfile index ba8c30b..cb531db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,6 +51,9 @@ ENV PORT=3000 # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/config/next-config-js/output -# ENV HOSTNAME="0.0.0.0" +# Listen on every interface. Docker sets HOSTNAME to the container id, which +# would make server.js listen on that interface only, so /api/healthz could +# not reach the server at localhost. +ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"] diff --git a/README.md b/README.md index 33b027d..dbdaebc 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,9 @@ The Learner Credential Wallet also provides options making it easy to share that The storage service itself is simply three API endpoints for storing, retrieving, and deleting a credential. You can find them in the [/app/api/credentials/route.ts](/app/api/credentials/route.ts) file. -The api endpoints save to a mongo backend, which you will have to setup. We've used Mongo Atlas ourselves, but you can of course use whatever Mongo setup you like. +The api endpoints save to a credential store, which you choose with the `CREDENTIAL_STORE` setting: MongoDB (see [MongoDB](#mongodb)), Amazon DynamoDB (see [AWS Lambda](#aws-lambda)), or, for local development and tests only, the server's memory. A shared credential stays viewable for a year (`CREDENTIAL_TTL_DAYS`), and can be at most 350 KB. -When storing a credential with an intial POST of the credential to /api/credentials the server will put the credential into mongo and return a list of three urls, like so: +When storing a credential with an intial POST of the credential to /api/credentials the server will put the credential into the store and return a list of three urls, like so: ```JSON { @@ -144,6 +144,41 @@ Worth noting is that the same effect as the 'view' url can be achieved with a cr VerifierPlus knows to go fetch the credential, verify it, and display it in the rendered view. +## Settings + +verifier-plus is configured by environment variables. [settings.contract.json](./settings.contract.json) lists every one the app reads, and a unit test keeps it in step with the code. The ones you set: + +| Setting | Required | Purpose | +|---|---|---| +| `CREDENTIAL_STORE` | always | `memory`, `mongodb` or `dynamodb`. `memory` loses every shared credential on restart: local development and tests only. | +| `EXCHANGES` | always | `on` or `off`: whether the landing page can request credentials from the Learner Credential Wallet. See [below](#verifiable-presentation-request-exchange). | +| `EXCHANGE_SERVER_URL` | no | Where wallets reach the exchange, when exchanges are on. Defaults to the address the browser used to reach the page. | +| `CREDENTIAL_TTL_DAYS` | no | How many days a shared credential stays viewable. Default 365. | +| `DB_URI` | for `mongodb`, unless `DB_HOST` is set | A `mongodb://` or `mongodb+srv://` connection string. | +| `DB_HOST`, `DB_USER`, `DB_PASS` | for `mongodb`, unless `DB_URI` is set | The 2.x way to connect to MongoDB Atlas: the app builds `mongodb+srv://DB_USER:DB_PASS@DB_HOST`. | +| `DB_NAME`, `DB_COLLECTION` | no | Default `verifier-plus` and `credentials`. | +| `CREDENTIAL_TABLE` | for `dynamodb` | The DynamoDB table. | +| `AWS_REGION` | for `dynamodb` | The table's region. AWS Lambda sets it. | +| `DYNAMODB_ENDPOINT` | no | Only for [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html), such as `http://localhost:8000`. | + +A missing or invalid setting does not stop the server. Routes that need it answer 503 naming it, and `GET /health/ready` answers 503 with a list of every problem, so check it after a deployment: + +``` +$ curl http://localhost:3000/health/ready +{"status":"UP","problems":[],"components":{"credentialStore":{"status":"UP","durationMs":2}}} +``` + +### Upgrading from 2.x + +3.0.0 requires two settings that 2.x did not have. If you set only `DB_USER`, `DB_PASS` and `DB_HOST`, add: + +``` +CREDENTIAL_STORE=mongodb +EXCHANGES=on +``` + +`EXCHANGES=on` keeps the "request credentials from the Learner Credential Wallet" option; use `off` to drop it. If you set `NEXT_PUBLIC_EXCHANGE_SERVER_URL`, rename it to `EXCHANGE_SERVER_URL`. Shared credentials now expire after a year; ones 2.x stored count from when they were shared. See the [CHANGELOG](./CHANGELOG.md) for the rest. + ## Local Development Clone the repo then: @@ -155,6 +190,8 @@ $ npm run dev *This spins up a [dev server](http://localhost:3000) hosted at `http://localhost:3000`, which you can now open in a web browser.* +`npm run dev` needs no database: [.env.development](./.env.development) sets `CREDENTIAL_STORE=memory` and `EXCHANGES=on`, so sharing works until the server stops. To use MongoDB or DynamoDB locally, set the settings for it in `.env.local`, which overrides `.env.development`. + >[!WARNING] >Make sure you run the right npm script. There are two sets of scripts, one in the package.json in the root directory and another in the .next/standalone/package.json directory. The latter is for running on the server, and if you run it locally it doesn't work properly. It is particularly easy to accidentally run the standalone script if you are working in Visual Code because the standalone scripts appear above the regular scripts in the VCode NPM Scripts tab. Make sure you run these: @@ -162,17 +199,17 @@ $ npm run dev The development server updates as you save changes to the underlying files. -Note that unless you've also configured a mongo backend as described in the [MongoDB](#mongodb) section below, then the credentials api endpoints won't work, but all verification should work as expected. - ## MongoDB -Th credential storage part of VerifierPlus requires MongoDB for its backend storage. To set up MongoDB for this app: +Set `CREDENTIAL_STORE=mongodb` and either `DB_URI` (any MongoDB, such as `mongodb://localhost:27017`) or, as in 2.x, `DB_HOST`, `DB_USER` and `DB_PASS` for a MongoDB Atlas cluster. To set up Atlas: * create a Mongo Atlas cluster. * from there create a collection, as well as a user with read and write permissions. * copy `.env.example` to `.env` * copy the connection details for your Atlas cluster into the appropriate fields in the `.env` file. +On first use the app creates two indexes on the collection: a unique index on `id`, and a TTL index on `expiresAt` that has MongoDB delete each record when it expires. If the collection already has a different index named `id_1`, sharing fails and `/health/ready` names the index to remove. Records that 2.x stored have no `expiresAt`; the app treats them as expiring `CREDENTIAL_TTL_DAYS` after their `createdAt`, and MongoDB does not delete them. + > [!TIP] > If the app can't connect to your Mongo Atlas collection, make sure that you've added your server IP to the whitelist for your Mongo Atlas account. @@ -190,6 +227,13 @@ Th credential storage part of VerifierPlus requires MongoDB for its backend stor We've set the configuation values in the 'environment' sections of the compose file, but you can also use a .env file, like the .env.example we provide. The .env file may be more secure. + Both compose files set `CREDENTIAL_STORE=mongodb` and `EXCHANGES=on`, which 3.0.0 requires (see [Upgrading from 2.x](#upgrading-from-2x)). To run the image by hand: + +``` +$ docker build -t verifier-plus . +$ docker run -p 3000:3000 -e CREDENTIAL_STORE=mongodb -e DB_URI=mongodb://your-mongo-host:27017 -e EXCHANGES=on verifier-plus +``` + > [!TIP] > If, when deploying to a different architecture, you get errors that seem related to tailwind, it may be that you need to include specific builds of the [lightningcss](https://lightningcss.dev) library, which [tailwind](https://tailwindcss.com) uses under the hood. The error should tell you which specific build is needed. You can then find the right package in npm and install it as an optionalDependency as we've already done for the linux build we needed for our AWS deployment: `npm i lightningcss-linux-x64-musl --save-optional`. You'll see in the optionalDependencies section of [package.json](./package.json) that we've also installed another build for an M4 Mac, `lightningcss-linux-arm64-musl`. @@ -205,25 +249,58 @@ Th credential storage part of VerifierPlus requires MongoDB for its backend stor See [.env.example](./.env.example) for an example + The exchange is temporary: [transaction-service](https://github.com/digitalcredentials/transaction-service) is its planned home. The code to remove then is marked `TEMPORARY(transaction-service)`. + +## AWS Lambda + + `npm run build:lambda` builds the app for AWS Lambda into `dist/lambda/`: the Next.js standalone server, its static files, and `run.sh`, which the [Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter) starts. Build on Linux x86_64 for a deployable artifact, because the build includes native modules for the platform it ran on. + + On Lambda, use `CREDENTIAL_STORE=dynamodb` with `CREDENTIAL_TABLE`, and `EXCHANGES=off`. The table's key is the string attribute `id`, and its TTL attribute is `expiresAt`. The Digital Credentials Consortium's deployment lives in its platform repository, not here. + + To try the build locally: + +``` +$ npm run build:lambda +$ CREDENTIAL_STORE=memory EXCHANGES=off PORT=3000 dist/lambda/run.sh +``` + ## Testing -We use [Playwright](https://playwright.dev) for testing, pretty much as described on the excellent Playwright site, so we won't reproduce the Playwright documentation. +### Unit tests + +`npm test` runs the unit tests with [Vitest](https://vitest.dev). They need no database and no network: each store's tests run against the memory store. + +`npm run test:stores` runs the same store tests against MongoDB and DynamoDB Local as well. Start both first with [docker-compose.test.yml](./docker-compose.test.yml): + +``` +$ docker compose -f docker-compose.test.yml up -d +$ npm run test:stores +``` + +`TEST_STORES` picks the stores (a comma list of `memory`, `mongodb`, `dynamodb`); `TEST_MONGO_URI` and `DYNAMODB_ENDPOINT` point at other servers. A store that is named but unreachable fails the run rather than being skipped. + +### End-to-end tests + +We use [Playwright](https://playwright.dev) for end-to-end testing, pretty much as described on the excellent Playwright site, so we won't reproduce the Playwright documentation. The basic idea, though, is that the playwright tests check a running server, which by default is localhost, but can be set to any server by setting `PLAYWRIGHT_TEST_URL` in [.env.playwright](./.env.playwright), like so: -`PLAYWRIGHT_TEST_URL=https://stage.verifierplus.org` +`PLAYWRIGHT_TEST_URL=https://verifierplus.org` This therefore allows us to run end to end tests on our local development server, on a staging server, or on a production server. Any running instance of verifier-plus. You can also pass the value in on the command line if you like, e.g., -`PLAYWRIGHT_TEST_URL=https://stage.verifierplus.org npm run test:e2e` +`PLAYWRIGHT_TEST_URL=http://localhost:3000 npm run test:e2e` + +`npm run test:e2e` runs three projects: `API` (the credential API), `DesktopChrome` (the pages), and `Lambda` (what a deployment on AWS Lambda must get right: readiness, reads straight after writes, and the size limits). The test that requests credentials from the wallet asks `/api/config` first and is skipped when exchanges are off. Some tests fetch credentials and registries from the internet. Note too that you can set which browsers you'd like to run the tests on using the --project flag as described in this [Playwright doc](https://playwright.dev/docs/running-tests#run-tests-on-different-browsers). Note that you'll have to have defined the projects in the [playwright.config.ts](./playwright.config.ts). ->[!NOTE] ->We've set a fairly relaxed maxDiffPixels and maxDiffPixelRatio in [playwright.config.ts](./playwright.config.ts) for the screenshot matching because our tests were failing because of inconsequential differences between the images. You may want to change those if need be. +### CI + +[.github/workflows/ci.yml](./.github/workflows/ci.yml) runs on every pull request: the unit tests and types, the store tests against MongoDB and DynamoDB Local, the Lambda build with the end-to-end tests against `dist/lambda/run.sh`, and the Docker image with MongoDB and the end-to-end tests. >[!TIP] >If for whatever reason you don't want to set the playwright PLAYWRIGHT_TEST_URL as an environment variable you can of course also directly change the baseURL in [playwright.config.ts](./playwright.config.ts). diff --git a/app/lib/settings.contract.test.ts b/app/lib/settings.contract.test.ts new file mode 100644 index 0000000..3832545 --- /dev/null +++ b/app/lib/settings.contract.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { GET } from '@/app/health/ready/route'; +import { createAppContext } from '@/lib/context/app-context'; +import { runInContext } from '@/lib/context/context-store'; +import contract from '@/settings.contract.json'; + +import { type Env, readSettings } from './settings'; + +type Entry = (typeof contract.settings)[number]; +const entries: Entry[] = contract.settings; +const byName = new Map(entries.map((entry) => [entry.name, entry])); + +/** A minimal environment with no problems. */ +const validEnv: Env = { CREDENTIAL_STORE: 'memory', EXCHANGES: 'off' }; + +/** Environments that between them take every branch of readSettings. */ +const scenarios: Env[] = [ + validEnv, + { CREDENTIAL_STORE: 'mongodb', DB_URI: 'mongodb://localhost:27017', EXCHANGES: 'on' }, + { CREDENTIAL_STORE: 'mongodb', DB_HOST: 'cluster.example.net', DB_USER: 'u', DB_PASS: 'p', EXCHANGES: 'off' }, + { CREDENTIAL_STORE: 'dynamodb', CREDENTIAL_TABLE: 't', AWS_REGION: 'us-east-1', EXCHANGES: 'off' }, + { CREDENTIAL_STORE: 'memory', EXCHANGES: 'on', AWS_LAMBDA_FUNCTION_NAME: 'verifier-plus' }, +]; + +/** The names readSettings looks up for env. */ +function namesRead(env: Env): Set { + const names = new Set(); + const recording = new Proxy(env, { + get(target, name) { + if (typeof name === 'string') names.add(name); + return Reflect.get(target, name); + }, + has(target, name) { + if (typeof name === 'string') names.add(name); + return Reflect.has(target, name); + }, + }); + readSettings(recording); + return names; +} + +describe('settings.contract.json', () => { + it('names each setting once, with every field', () => { + expect(new Set(entries.map((entry) => entry.name)).size).toBe(entries.length); + for (const entry of entries) { + expect(entry, entry.name).toEqual({ + name: expect.stringMatching(/^[A-Z][A-Z0-9_]*$/), + required: expect.stringMatching(/^(always|never|when .+)$/), + values: expect.anything(), + default: entry.default === null ? null : expect.any(String), + purpose: expect.any(String), + readBy: expect.stringMatching(/^(app|next)$/), + docker: expect.stringMatching(/^(you|image|unused)$/), + lambda: expect.stringMatching(/^(template|runtime|run\.sh|unused)$/), + }); + } + }); + + it('lists every name the settings module reads', () => { + const read = new Set(scenarios.flatMap((env) => [...namesRead(env)])); + for (const name of read) { + expect(byName.get(name)?.readBy, `${name} is read by app/lib/settings.ts but not in the contract`).toBe('app'); + } + }); + + it('lists no app setting that the settings module never reads', () => { + const read = new Set(scenarios.flatMap((env) => [...namesRead(env)])); + const unread = entries.filter((entry) => entry.readBy === 'app' && !read.has(entry.name)).map((e) => e.name); + expect(unread).toEqual([]); + }); + + const alwaysRequired = entries.filter((entry) => entry.required === 'always').map((entry) => entry.name); + + it('has always-required settings', () => { + expect(alwaysRequired).toEqual(['CREDENTIAL_STORE', 'EXCHANGES']); + }); + + it.each(alwaysRequired)('makes /health/ready answer 503 naming %s when it is left out', async (name) => { + const env = { ...validEnv }; + delete env[name]; + + const res = await runInContext(await createAppContext(env), () => GET()); + + expect(res.status).toBe(503); + const { problems } = await res.json(); + expect(problems).toEqual(expect.arrayContaining([expect.stringContaining(name)])); + }); + + it('is ready with only the always-required settings', async () => { + const res = await runInContext(await createAppContext(validEnv), () => GET()); + expect(res.status).toBe(200); + }); +}); diff --git a/docker-compose.example.prod.yml b/docker-compose.example.prod.yml index c27ed0c..c87e351 100644 --- a/docker-compose.example.prod.yml +++ b/docker-compose.example.prod.yml @@ -14,6 +14,14 @@ services: - HEALTH_CHECK_WEB_HOOK=a webhook like those of Slack - HEALTH_CHECK_SERVICE_URL=http://testing.dcconsortium.org/api/healthz - the healthz endpoint - HEALTH_CHECK_SERVICE_NAME=VERIFIER-PLUS + # Required since 3.0.0: where shared credentials are kept, and whether + # the landing page can request credentials from the Learner Credential Wallet. + - CREDENTIAL_STORE=mongodb + - EXCHANGES=on + # Optional: where wallets reach the exchange (defaults to the page's address). + # - EXCHANGE_SERVER_URL=https://your server host + # MongoDB: DB_URI, or DB_HOST with DB_USER and DB_PASS + # - DB_URI=mongodb://your mongo host:27017 - DB_USER=your mongo user - DB_PASS=and mongo password - DB_HOST=and mongo host diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 6f974ca..233458a 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -14,6 +14,14 @@ services: - HEALTH_CHECK_WEB_HOOK=a webhook like those of Slack - HEALTH_CHECK_SERVICE_URL=http://localhost:3000/api/healthz - the healthz endpoint - HEALTH_CHECK_SERVICE_NAME=VERIFIER-PLUS + # Required since 3.0.0: where shared credentials are kept, and whether + # the landing page can request credentials from the Learner Credential Wallet. + - CREDENTIAL_STORE=mongodb + - EXCHANGES=on + # Optional: where wallets reach the exchange (defaults to the page's address). + # - EXCHANGE_SERVER_URL=https://your server host + # MongoDB: DB_URI, or DB_HOST with DB_USER and DB_PASS + # - DB_URI=mongodb://your mongo host:27017 - DB_USER=your mongo user - DB_PASS=and mongo password - DB_HOST=and mongo host diff --git a/package-lock.json b/package-lock.json index 7bf2a3b..56aefec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "verifier-plus", - "version": "2.0.2", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "verifier-plus", - "version": "2.0.2", + "version": "3.0.0", "dependencies": { "@aws-sdk/client-dynamodb": "^3.1166.0", "@aws-sdk/lib-dynamodb": "^3.1166.0", @@ -38,7 +38,7 @@ "validator": "^13.15.15" }, "devDependencies": { - "@playwright/test": "^1.55.0", + "@playwright/test": "^1.65.0", "@tailwindcss/postcss": "^4", "@types/node": "^24", "@types/react": "^19", @@ -1922,18 +1922,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", - "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", "devOptional": true, + "license": "Apache-2.0", "dependencies": { - "playwright": "1.56.1" + "playwright": "1.65.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@rolldown/binding-android-arm-eabi": { @@ -3555,19 +3556,6 @@ "node": ">=12.20.0" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5216,33 +5204,32 @@ } }, "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", "devOptional": true, + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.65.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", "devOptional": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/possible-typed-array-names": { diff --git a/package.json b/package.json index 620101a..8f83aed 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { "name": "verifier-plus", - "version": "2.0.2", + "version": "3.0.0", "engines": { "node": ">=24 <25" }, "scripts": { "dev": "next dev --turbopack", "build": "next build --turbopack", + "build:lambda": "bash scripts/build-lambda.sh", "start": "next start", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test --project=DesktopChrome --project=API", + "test:e2e": "playwright test --project=API --project=DesktopChrome --project=Lambda", "test:stores": "TEST_STORES=memory,mongodb,dynamodb vitest run" }, "dependencies": { @@ -44,7 +45,7 @@ "validator": "^13.15.15" }, "devDependencies": { - "@playwright/test": "^1.55.0", + "@playwright/test": "^1.65.0", "@tailwindcss/postcss": "^4", "@types/node": "^24", "@types/react": "^19", diff --git a/playwright.config.ts b/playwright.config.ts index a424bf7..2d05f4c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,6 +21,8 @@ export default defineConfig({ retries: 2, // Artifacts folder where screenshots, videos, and traces are stored. outputDir: "test-results/", + // In CI, also write an HTML report (playwright-report/) to upload. + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list", // Run your local dev server before starting the tests: @@ -62,30 +64,34 @@ export default defineConfig({ } } }, + { + // What a deployment on AWS Lambda must get right; no browser. + name: 'Lambda', + testMatch: /.*lambda.spec.ts/, + retries: 0, + use: { + extraHTTPHeaders: { + 'Content-Type': 'application/json' + } + } + }, { name: "DesktopChrome", - testIgnore: /.*api.spec.ts/, + testIgnore: /.*(api|lambda).spec.ts/, use: { ...devices["Desktop Chrome"], }, - expect: { - toHaveScreenshot: { - maxDiffPixels: 2000, // Allow up to 2000 pixels to be different - maxDiffPixelRatio: 0.1, // Allow up to 10% of pixels to be different - //threshold: 0.1, // Adjust color difference tolerance - }, - }, }, // { // name: 'Desktop Firefox', - //. testIgnore: /.*api.spec.ts/, + //. testIgnore: /.*(api|lambda).spec.ts/, // use: { // ...devices['Desktop Firefox'], // }, // }, // { // name: 'Desktop Safari', - //. testIgnore: /.*api.spec.ts/, + //. testIgnore: /.*(api|lambda).spec.ts/, // use: { // ...devices['Desktop Safari'], // }, @@ -93,21 +99,14 @@ export default defineConfig({ // Test against mobile viewports. { name: "MobileChrome", - testIgnore: /.*api.spec.ts/, + testIgnore: /.*(api|lambda).spec.ts/, use: { ...devices["Pixel 5"], }, - expect: { - toHaveScreenshot: { - maxDiffPixels: 2000, // Allow up to 2000 pixels to be different - maxDiffPixelRatio: 0.1, // Allow up to 10% of pixels to be different - //threshold: 0.1, // Adjust color difference tolerance - }, - }, }, // { // name: "MobileSafari", - // testIgnore: /.*api.spec.ts/, + // testIgnore: /.*(api|lambda).spec.ts/, // use: devices["iPhone 12"], // }, ], diff --git a/scripts/build-lambda.sh b/scripts/build-lambda.sh new file mode 100755 index 0000000..95faa27 --- /dev/null +++ b/scripts/build-lambda.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Builds the app for AWS Lambda with the Lambda Web Adapter, into dist/lambda/. +# +# The result is a directory, not a zip: the deployment zips it. Lambda runs +# dist/lambda/run.sh through the adapter, which proxies each request to the +# Next.js standalone server on PORT. +# +# Build on Linux x86_64 for a deployable artifact: the standalone output holds +# native modules (sharp) for the platform it was built on. +set -euo pipefail + +cd "$(dirname "$0")/.." +out=dist/lambda + +npm run build + +rm -rf "$out" +mkdir -p "$out" + +# The same files the Dockerfile copies into its runner image. +cp -R .next/standalone/. "$out/" +mkdir -p "$out/.next" +cp -R .next/static "$out/.next/static" +cp -R public "$out/public" + +# Lambda's file system is read-only except /tmp, so Next's cache goes there. +rm -rf "$out/.next/cache" +ln -s /tmp/cache "$out/.next/cache" + +cat > "$out/run.sh" <<'EOF' +#!/bin/bash +# Lambda Web Adapter entry. The adapter starts this and proxies HTTP API +# events to the server on PORT. Lambda starts it from the code directory; +# the cd lets it run from anywhere else too (CI's smoke test). +cd "$(dirname "$0")" +mkdir -p /tmp/cache +export HOSTNAME=0.0.0.0 +export PORT="${PORT:-8080}" +exec node server.js +EOF +chmod 755 "$out/run.sh" + +missing=0 +for path in run.sh server.js .next/static public; do + if [ ! -e "$out/$path" ]; then + echo "build:lambda: $out/$path is missing" >&2 + missing=1 + fi +done +if [ ! -x "$out/run.sh" ]; then + echo "build:lambda: $out/run.sh is not executable" >&2 + missing=1 +fi +if [ "$missing" -ne 0 ]; then + exit 1 +fi + +echo "build:lambda: wrote $out/" diff --git a/settings.contract.json b/settings.contract.json new file mode 100644 index 0000000..8a570c4 --- /dev/null +++ b/settings.contract.json @@ -0,0 +1,165 @@ +{ + "$comment": "Every environment variable verifier-plus reads. A deployment sets the ones marked for it; app/lib/settings.contract.test.ts checks this file against app/lib/settings.ts. 'required' is 'always', 'never', or the condition under which the setting must be set. 'readBy' is 'app' (app/lib/settings.ts) or 'next' (the Next.js standalone server). 'docker' and 'lambda' say who sets it there: 'you' (the operator), 'image' (the Dockerfile), 'run.sh' (dist/lambda/run.sh), 'template' (the deployment's template), 'runtime' (AWS Lambda itself), or 'unused'.", + "settings": [ + { + "name": "CREDENTIAL_STORE", + "required": "always", + "values": ["memory", "mongodb", "dynamodb"], + "default": null, + "purpose": "Where shared credentials are kept. memory keeps them in the process and loses them on restart (local development and tests only).", + "readBy": "app", + "docker": "you", + "lambda": "template" + }, + { + "name": "CREDENTIAL_TTL_DAYS", + "required": "never", + "values": "a positive whole number of days", + "default": "365", + "purpose": "How long a shared credential stays viewable after it is shared.", + "readBy": "app", + "docker": "you", + "lambda": "template" + }, + { + "name": "CREDENTIAL_TABLE", + "required": "when CREDENTIAL_STORE=dynamodb", + "values": "a DynamoDB table name", + "default": null, + "purpose": "The table shared credentials are kept in. Its key is the string attribute id, and its TTL attribute is expiresAt.", + "readBy": "app", + "docker": "unused", + "lambda": "template" + }, + { + "name": "DYNAMODB_ENDPOINT", + "required": "never", + "values": "a URL", + "default": null, + "purpose": "Only for DynamoDB Local, such as http://localhost:8000. Unset to reach AWS.", + "readBy": "app", + "docker": "unused", + "lambda": "unused" + }, + { + "name": "DB_URI", + "required": "when CREDENTIAL_STORE=mongodb, unless DB_HOST is set", + "values": "a mongodb:// or mongodb+srv:// connection string", + "default": null, + "purpose": "The MongoDB connection string, used as given. Set this or DB_HOST, not both.", + "readBy": "app", + "docker": "you", + "lambda": "unused" + }, + { + "name": "DB_HOST", + "required": "when CREDENTIAL_STORE=mongodb, unless DB_URI is set", + "values": "a MongoDB Atlas host", + "default": null, + "purpose": "The 2.x way to connect: the app builds mongodb+srv://DB_USER:DB_PASS@DB_HOST.", + "readBy": "app", + "docker": "you", + "lambda": "unused" + }, + { + "name": "DB_USER", + "required": "when DB_HOST is set", + "values": "a MongoDB user", + "default": null, + "purpose": "The user for DB_HOST.", + "readBy": "app", + "docker": "you", + "lambda": "unused" + }, + { + "name": "DB_PASS", + "required": "when DB_HOST is set", + "values": "a MongoDB password", + "default": null, + "purpose": "The password for DB_HOST.", + "readBy": "app", + "docker": "you", + "lambda": "unused" + }, + { + "name": "DB_NAME", + "required": "never", + "values": "a MongoDB database name", + "default": "verifier-plus", + "purpose": "The database shared credentials are kept in.", + "readBy": "app", + "docker": "you", + "lambda": "unused" + }, + { + "name": "DB_COLLECTION", + "required": "never", + "values": "a MongoDB collection name", + "default": "credentials", + "purpose": "The collection shared credentials are kept in.", + "readBy": "app", + "docker": "you", + "lambda": "unused" + }, + { + "name": "EXCHANGES", + "required": "always", + "values": ["on", "off"], + "default": null, + "purpose": "Whether the landing page offers to request credentials from the Learner Credential Wallet. Exchanges live in process memory, so on is refused on Lambda.", + "readBy": "app", + "docker": "you", + "lambda": "template" + }, + { + "name": "EXCHANGE_SERVER_URL", + "required": "never", + "values": "an absolute http or https URL", + "default": "the address the browser used to reach the page", + "purpose": "Where wallets reach the exchange. Read only when EXCHANGES=on.", + "readBy": "app", + "docker": "you", + "lambda": "unused" + }, + { + "name": "AWS_REGION", + "required": "when CREDENTIAL_STORE=dynamodb", + "values": "an AWS region", + "default": null, + "purpose": "The region of CREDENTIAL_TABLE. Provided by the runtime on Lambda; set it yourself only to run the dynamodb store elsewhere.", + "readBy": "app", + "docker": "unused", + "lambda": "runtime" + }, + { + "name": "AWS_LAMBDA_FUNCTION_NAME", + "required": "never", + "values": "the function's name", + "default": null, + "purpose": "Provided by the runtime. Its presence tells the app it is on Lambda, where EXCHANGES=on is refused.", + "readBy": "app", + "docker": "unused", + "lambda": "runtime" + }, + { + "name": "PORT", + "required": "never", + "values": "a TCP port", + "default": "3000 (Docker), 8080 (Lambda)", + "purpose": "The port the server listens on. On Lambda, the port the Web Adapter forwards to.", + "readBy": "next", + "docker": "image", + "lambda": "run.sh" + }, + { + "name": "HOSTNAME", + "required": "never", + "values": "an address to listen on", + "default": "0.0.0.0", + "purpose": "The interface the server listens on.", + "readBy": "next", + "docker": "image", + "lambda": "run.sh" + } + ] +} diff --git a/tests/credential.spec.ts b/tests/credential.spec.ts index f98d5ce..0c789be 100644 --- a/tests/credential.spec.ts +++ b/tests/credential.spec.ts @@ -97,8 +97,8 @@ test("full credential display OBv3 VCv2", async ({ page }) => { // confirm the credential image is shown const credentialImageURL = testVC.credentialSubject.achievement.image.id - await expect(page.getByTestId(TestId.AchievementImage)).toHaveScreenshot(); const credentialImage = page.getByTestId(TestId.AchievementImage) + await expect.poll(() => credentialImage.evaluate((el: HTMLImageElement) => el.complete && el.naturalWidth > 0)).toBe(true) const srcAttrValue = await credentialImage.getAttribute('src'); await expect(srcAttrValue).toEqual(credentialImageURL) }); @@ -145,8 +145,8 @@ test("full credential display OBv3 VCv1", async ({ page }) => { // confirm the credential image is shown const credentialImageURL = testVC.credentialSubject.achievement.image.id - await expect(page.getByTestId(TestId.AchievementImage)).toHaveScreenshot(); const credentialImage = page.getByTestId(TestId.AchievementImage) + await expect.poll(() => credentialImage.evaluate((el: HTMLImageElement) => el.complete && el.naturalWidth > 0)).toBe(true) const srcAttrValue = await credentialImage.getAttribute('src'); await expect(srcAttrValue).toEqual(credentialImageURL) }); diff --git a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-Desktop-Chrome-darwin.png b/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-Desktop-Chrome-darwin.png deleted file mode 100644 index 884f4e8..0000000 Binary files a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-DesktopChrome-darwin.png b/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-DesktopChrome-darwin.png deleted file mode 100644 index a417de9..0000000 Binary files a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-Mobile-Chrome-darwin.png b/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-Mobile-Chrome-darwin.png deleted file mode 100644 index a417de9..0000000 Binary files a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-MobileChrome-darwin.png b/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-MobileChrome-darwin.png deleted file mode 100644 index a417de9..0000000 Binary files a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv1-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-Desktop-Chrome-darwin.png b/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-Desktop-Chrome-darwin.png deleted file mode 100644 index 884f4e8..0000000 Binary files a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-DesktopChrome-darwin.png b/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-DesktopChrome-darwin.png deleted file mode 100644 index a417de9..0000000 Binary files a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-Mobile-Chrome-darwin.png b/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-Mobile-Chrome-darwin.png deleted file mode 100644 index a417de9..0000000 Binary files a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-MobileChrome-darwin.png b/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-MobileChrome-darwin.png deleted file mode 100644 index a417de9..0000000 Binary files a/tests/credential.spec.ts-snapshots/full-credential-display-OBv3-VCv2-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tests/helpers/resultLog.ts b/tests/helpers/resultLog.ts new file mode 100644 index 0000000..1a804c5 --- /dev/null +++ b/tests/helpers/resultLog.ts @@ -0,0 +1,61 @@ +import { expect, type Page } from "@playwright/test"; +import { LogMessages } from "@/components/ResultLog/ResultLog"; +import { TestId } from "@/lib/testIds"; + +/** The accessible names of the status icons beside each verification log line. */ +export enum LogIcon { + Positive = 'green checkmark', + Warning = 'yellow warning', + Negative = 'red x', +} + +/** The icon ResultLog shows beside each message. */ +const iconForMessage: Record = { + [LogMessages.WellFormed]: LogIcon.Positive, + [LogMessages.ValidSignature]: LogIcon.Positive, + [LogMessages.KnownIssuer]: LogIcon.Positive, + [LogMessages.NotRevoked]: LogIcon.Positive, + [LogMessages.HasNotExpired]: LogIcon.Positive, + [LogMessages.NoExpirationDate]: LogIcon.Positive, + [LogMessages.NotSuspended]: LogIcon.Positive, + [LogMessages.UnknownIssuer]: LogIcon.Warning, + [LogMessages.HasExpired]: LogIcon.Warning, + [LogMessages.MalFormed]: LogIcon.Negative, + [LogMessages.InvalidSignature]: LogIcon.Negative, + [LogMessages.Revoked]: LogIcon.Negative, + [LogMessages.UncheckedRevocation]: LogIcon.Negative, + [LogMessages.Suspended]: LogIcon.Negative, + // not log lines + [LogMessages.GeneralError]: undefined, + [LogMessages.UnknownError]: undefined, +}; + +export type ExpectedLog = { + wellFormed: LogMessages, + signature: LogMessages, + issuer: LogMessages, + revocation: LogMessages, + expiry: LogMessages, +}; + +const lineTestIds: Record = { + wellFormed: TestId.MalformedLogMsg, + signature: TestId.SigningLogMsg, + issuer: TestId.IssuerLogMsg, + revocation: TestId.RevocationLogMsg, + expiry: TestId.ExpirationLogMsg, +}; + +/** + * Checks the status icon beside each log line: a green checkmark, yellow + * warning or red x, as its message calls for. Each line is an icon and its + * message under one parent element. + */ +export async function expectLogIcons(page: Page, expected: ExpectedLog) { + for (const [line, message] of Object.entries(expected) as [keyof ExpectedLog, LogMessages][]) { + const icon = iconForMessage[message]; + if (!icon) throw new Error(`'${message}' is not a log line message`); + const lineElement = page.getByTestId(lineTestIds[line]).locator('..'); + await expect(lineElement.getByRole('img'), `${line} icon`).toHaveAccessibleName(icon); + } +} diff --git a/tests/lambda.spec.ts b/tests/lambda.spec.ts new file mode 100644 index 0000000..68576d0 --- /dev/null +++ b/tests/lambda.spec.ts @@ -0,0 +1,68 @@ +import { test, expect, type APIRequestContext } from "@playwright/test"; +import { getTestVP } from "@/tests/fixtures/testVP"; + +/** + * The Lambda group: what a deployment on AWS Lambda must get right that a + * single long-running server gets right for free. Runs against any server + * (`--project=Lambda`); no browser. On Lambda, requests in parallel reach more + * than one sandbox, so every read must find what another sandbox wrote. + */ + +const MAX_BODY_BYTES = 5 * 1024 * 1024; +const MAX_SHARED_VP_BYTES = 350 * 1024; + +/** Shares the test VP, reads it straight back, then unshares it. */ +async function roundTrip(request: APIRequestContext) { + const testVP = getTestVP(); + const postReply = await request.post('/api/credentials/', { data: testVP }); + expect(postReply.status()).toBe(201); + const { url } = await postReply.json(); + + const getReply = await request.get(url.get); + expect(getReply.status(), `GET ${url.get}`).toBe(200); + expect((await getReply.json()).vp).toEqual(testVP.vp); + + const deleteReply = await request.delete(url.unshare); + expect(deleteReply.ok()).toBeTruthy(); +} + +test('GET /health/ready is 200', async ({ request }) => { + const reply = await request.get('/health/ready'); + expect(reply.status(), JSON.stringify(await reply.json())).toBe(200); +}); + +test('20 share-then-read round trips, 10 of them in parallel, all read back', async ({ request }) => { + for (let i = 0; i < 10; i++) { + await roundTrip(request); + } + await Promise.all(Array.from({ length: 10 }, () => roundTrip(request))); +}); + +test('a read after unshare is 404', async ({ request }) => { + const postReply = await request.post('/api/credentials/', { data: getTestVP() }); + expect(postReply.status()).toBe(201); + const { url } = await postReply.json(); + + const deleteReply = await request.delete(url.unshare); + expect(deleteReply.ok()).toBeTruthy(); + + const getReply = await request.get(url.get); + expect(getReply.status()).toBe(404); +}); + +test('a body over 5 MB to /api/verify is 413', async ({ request }) => { + const body = JSON.stringify({ padding: 'x'.repeat(MAX_BODY_BYTES) }); + const reply = await request.post('/api/verify', { data: body }); + expect(reply.status()).toBe(413); +}); + +test('sharing a credential just over 350 KB is 413, too large', async ({ request }) => { + const testVP = getTestVP(); + const size = Buffer.byteLength(JSON.stringify(testVP.vp), 'utf8'); + testVP.vp.padding = 'x'.repeat(MAX_SHARED_VP_BYTES - size); + expect(Buffer.byteLength(JSON.stringify(testVP.vp), 'utf8')).toBeGreaterThan(MAX_SHARED_VP_BYTES); + + const reply = await request.post('/api/credentials/', { data: testVP }); + expect(reply.status()).toBe(413); + expect((await reply.json()).error).toMatch(/too large/i); +}); diff --git a/tests/log.spec.ts b/tests/log.spec.ts index e135443..55cc341 100644 --- a/tests/log.spec.ts +++ b/tests/log.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { getTamperedVCAsString } from "@/tests/fixtures/minimalVCv2"; import { LogMessages } from "@/components/ResultLog/ResultLog"; import { TestId } from "@/lib/testIds" +import { expectLogIcons } from "@/tests/helpers/resultLog"; const badDiDWebErrorMessage = "The signature could not be checked because the public signing key could not be retrieved from https://digitalcredentials.github.io/dcc-did-web-bad/did.json"; @@ -74,10 +75,8 @@ logTests.forEach(({ name, vc, expected }) => { // check expired message matches expected await expect(page.getByText(expectedMessages.expiry)).toBeVisible(); await expect(page.getByTestId(TestId.ExpirationLogMsg)).toHaveText(expectedMessages.expiry) - // finally take a screenshot of the log area to capture the combination of checkmarks and x's - // NOTE: it is very important that this screenshot be taken when the app is working/displaying correctly - // SEE: https://playwright.dev/docs/test-snapshots - await expect(page.getByTestId(TestId.ResultLog)).toHaveScreenshot(); + // finally check the combination of checkmarks, warnings and x's beside the lines + await expectLogIcons(page, expectedMessages) }) }); diff --git a/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-Desktop-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-Desktop-Chrome-darwin.png deleted file mode 100644 index b515713..0000000 Binary files a/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-DesktopChrome-darwin.png b/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-DesktopChrome-darwin.png deleted file mode 100644 index 413f3bb..0000000 Binary files a/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-Mobile-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-Mobile-Chrome-darwin.png deleted file mode 100644 index 37cd20c..0000000 Binary files a/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-MobileChrome-darwin.png b/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-MobileChrome-darwin.png deleted file mode 100644 index 37cd20c..0000000 Binary files a/tests/log.spec.ts-snapshots/log-legacy-noStatus-noExpiry-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-Desktop-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-Desktop-Chrome-darwin.png deleted file mode 100644 index b2c36c4..0000000 Binary files a/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-DesktopChrome-darwin.png b/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-DesktopChrome-darwin.png deleted file mode 100644 index 925b1f6..0000000 Binary files a/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-Mobile-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-Mobile-Chrome-darwin.png deleted file mode 100644 index 45cc4b1..0000000 Binary files a/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-MobileChrome-darwin.png b/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-MobileChrome-darwin.png deleted file mode 100644 index 45cc4b1..0000000 Binary files a/tests/log.spec.ts-snapshots/log-legacy-revoked-expired-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-Desktop-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-Desktop-Chrome-darwin.png deleted file mode 100644 index 6685673..0000000 Binary files a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-DesktopChrome-darwin.png b/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-DesktopChrome-darwin.png deleted file mode 100644 index 1fe42e4..0000000 Binary files a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-Mobile-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-Mobile-Chrome-darwin.png deleted file mode 100644 index 8a75119..0000000 Binary files a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-MobileChrome-darwin.png b/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-MobileChrome-darwin.png deleted file mode 100644 index 8a75119..0000000 Binary files a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-expired-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-Desktop-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-Desktop-Chrome-darwin.png deleted file mode 100644 index b515713..0000000 Binary files a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-DesktopChrome-darwin.png b/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-DesktopChrome-darwin.png deleted file mode 100644 index 413f3bb..0000000 Binary files a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-Mobile-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-Mobile-Chrome-darwin.png deleted file mode 100644 index f8e86d8..0000000 Binary files a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-MobileChrome-darwin.png b/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-MobileChrome-darwin.png deleted file mode 100644 index f8e86d8..0000000 Binary files a/tests/log.spec.ts-snapshots/log-mixedRegistry-validStatus-noExpiry-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-Desktop-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-Desktop-Chrome-darwin.png deleted file mode 100644 index 3488209..0000000 Binary files a/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-DesktopChrome-darwin.png b/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-DesktopChrome-darwin.png deleted file mode 100644 index 93b2acf..0000000 Binary files a/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-Mobile-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-Mobile-Chrome-darwin.png deleted file mode 100644 index cf888fa..0000000 Binary files a/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-MobileChrome-darwin.png b/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-MobileChrome-darwin.png deleted file mode 100644 index cf888fa..0000000 Binary files a/tests/log.spec.ts-snapshots/log-noRegistry-noStatus-noExpiry-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-Desktop-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-Desktop-Chrome-darwin.png deleted file mode 100644 index 6685673..0000000 Binary files a/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-DesktopChrome-darwin.png b/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-DesktopChrome-darwin.png deleted file mode 100644 index 1fe42e4..0000000 Binary files a/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-Mobile-Chrome-darwin.png b/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-Mobile-Chrome-darwin.png deleted file mode 100644 index 8a75119..0000000 Binary files a/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-MobileChrome-darwin.png b/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-MobileChrome-darwin.png deleted file mode 100644 index 8a75119..0000000 Binary files a/tests/log.spec.ts-snapshots/log-oidf-noStatus-expired-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tests/storedCredView.spec.ts b/tests/storedCredView.spec.ts index 91ab9d4..3964b1a 100644 --- a/tests/storedCredView.spec.ts +++ b/tests/storedCredView.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { getTestVP } from "@/tests/fixtures/testVP"; import { LogMessages } from "@/components/ResultLog/ResultLog"; import { TestId } from "@/lib/testIds" +import { expectLogIcons } from "@/tests/helpers/resultLog"; const generalErrorMessages = { NOT_FOUND: '404 credential not found' @@ -63,10 +64,14 @@ test('a stored credential should display', async ({ request, page }) => { // check expired message matches expected await expect(page.getByText(LogMessages.NoExpirationDate)).toBeVisible(); await expect(page.getByTestId(TestId.ExpirationLogMsg)).toHaveText(LogMessages.NoExpirationDate) - // finally take a screenshot of the log area to capture the combination of checkmarks and x's - // NOTE: it is very important that this screenshot be taken when the app is working/displaying correctly - // SEE: https://playwright.dev/docs/test-snapshots - await expect(page.getByTestId(TestId.ResultLog)).toHaveScreenshot(); + // finally check the checkmarks beside the lines + await expectLogIcons(page, { + wellFormed: LogMessages.WellFormed, + signature: LogMessages.ValidSignature, + issuer: LogMessages.KnownIssuer, + revocation: LogMessages.NotRevoked, + expiry: LogMessages.NoExpirationDate, + }) // 6. now delete the credential const deleteReply = await request.delete(unshareURL) diff --git a/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-Desktop-Chrome-darwin.png b/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-Desktop-Chrome-darwin.png deleted file mode 100644 index f238502..0000000 Binary files a/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-DesktopChrome-darwin.png b/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-DesktopChrome-darwin.png deleted file mode 100644 index 04c8631..0000000 Binary files a/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-Mobile-Chrome-darwin.png b/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-Mobile-Chrome-darwin.png deleted file mode 100644 index 8f0d99a..0000000 Binary files a/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-MobileChrome-darwin.png b/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-MobileChrome-darwin.png deleted file mode 100644 index 8f0d99a..0000000 Binary files a/tests/storedCredView.spec.ts-snapshots/a-stored-credential-should-display-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tests/vpqr.spec.ts b/tests/vpqr.spec.ts index 9ce0272..99fd6a6 100644 --- a/tests/vpqr.spec.ts +++ b/tests/vpqr.spec.ts @@ -4,8 +4,13 @@ import { getTestVPForVPQR } from '@/tests/fixtures/testVP'; import { vprQuery } from '@/app/api/exchanges/[txId]/route'; import { LogMessages } from "@/components/ResultLog/ResultLog"; import { TestId } from "@/lib/testIds" +import { expectLogIcons } from "@/tests/helpers/resultLog"; +// TEMPORARY(transaction-service): remove with the in-app exchange test('test the qr', async ({ page, request }) => { + const config = await (await request.get('/api/config')).json(); + test.skip(!config.exchanges, 'exchanges are off on this server'); + await page.goto('/'); await page.getByTestId('lcw-request-btn').click(); const qr = await page.getByTestId("lcw-qr-request") @@ -62,10 +67,14 @@ example of the value of the deeplinks 'request' parameter: // check expired message matches expected await expect(page.getByText(LogMessages.NoExpirationDate)).toBeVisible(); await expect(page.getByTestId(TestId.ExpirationLogMsg)).toHaveText(LogMessages.NoExpirationDate) - // finally take a screenshot of the log area to capture the combination of checkmarks and x's - // NOTE: it is very important that this screenshot be taken when the app is working/displaying correctly - // SEE: https://playwright.dev/docs/test-snapshots - await expect(page.getByTestId(TestId.ResultLog)).toHaveScreenshot(); + // finally check the checkmarks beside the lines + await expectLogIcons(page, { + wellFormed: LogMessages.WellFormed, + signature: LogMessages.ValidSignature, + issuer: LogMessages.KnownIssuer, + revocation: LogMessages.NotRevoked, + expiry: LogMessages.NoExpirationDate, + }) }); \ No newline at end of file diff --git a/tests/vpqr.spec.ts-snapshots/test-the-qr-1-Desktop-Chrome-darwin.png b/tests/vpqr.spec.ts-snapshots/test-the-qr-1-Desktop-Chrome-darwin.png deleted file mode 100644 index f238502..0000000 Binary files a/tests/vpqr.spec.ts-snapshots/test-the-qr-1-Desktop-Chrome-darwin.png and /dev/null differ diff --git a/tests/vpqr.spec.ts-snapshots/test-the-qr-1-DesktopChrome-darwin.png b/tests/vpqr.spec.ts-snapshots/test-the-qr-1-DesktopChrome-darwin.png deleted file mode 100644 index 04c8631..0000000 Binary files a/tests/vpqr.spec.ts-snapshots/test-the-qr-1-DesktopChrome-darwin.png and /dev/null differ diff --git a/tests/vpqr.spec.ts-snapshots/test-the-qr-1-Mobile-Chrome-darwin.png b/tests/vpqr.spec.ts-snapshots/test-the-qr-1-Mobile-Chrome-darwin.png deleted file mode 100644 index f8e86d8..0000000 Binary files a/tests/vpqr.spec.ts-snapshots/test-the-qr-1-Mobile-Chrome-darwin.png and /dev/null differ diff --git a/tests/vpqr.spec.ts-snapshots/test-the-qr-1-MobileChrome-darwin.png b/tests/vpqr.spec.ts-snapshots/test-the-qr-1-MobileChrome-darwin.png deleted file mode 100644 index f8e86d8..0000000 Binary files a/tests/vpqr.spec.ts-snapshots/test-the-qr-1-MobileChrome-darwin.png and /dev/null differ diff --git a/tsconfig.json b/tsconfig.json index a914168..b5e3737 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -50,6 +50,7 @@ "app/types/presentation.d.ts" ], "exclude": [ - "node_modules" + "node_modules", + "dist" ] } \ No newline at end of file