diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 0000000..e8260b5 --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +Dockerfile +.dockerignore +*.local +.env* diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..aaa9103 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,2 @@ +test-results/ +playwright-report/ diff --git a/dashboard/.nvmrc b/dashboard/.nvmrc new file mode 100644 index 0000000..ce62013 --- /dev/null +++ b/dashboard/.nvmrc @@ -0,0 +1 @@ +24.22.1 diff --git a/dashboard/.oxlintrc.json b/dashboard/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/dashboard/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..fd8bdaf --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,24 @@ +# Two stages. The first turns the React source into plain files; the second keeps +# only those files and a web server. Node never reaches the running container. + +FROM node:24.22.1-alpine AS build +WORKDIR /app + +# package.json and the lockfile first, on their own layer: they change far less +# often than the source, so npm ci is skipped on most rebuilds. +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +# Vite reads VITE_* variables at build time and writes the values into the bundle, +# so the API address is chosen here, not when the container starts. +ARG VITE_API_URL=https://api.myrelay.tech +ENV VITE_API_URL=$VITE_API_URL + +RUN npm run build + +FROM nginxinc/nginx-unprivileged:1.29.3-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8080 diff --git a/dashboard/e2e/connect-first.ts b/dashboard/e2e/connect-first.ts new file mode 100644 index 0000000..5d9f2bb --- /dev/null +++ b/dashboard/e2e/connect-first.ts @@ -0,0 +1,11 @@ +import type { Page } from '@playwright/test' + +// Every screen except Connect needs a key in sessionStorage. Going through the +// real form rather than writing the key directly means the tests break if the +// gate breaks, instead of quietly walking around it. +export async function connect(page: Page) { + await page.goto('/') + await page.getByLabel('API key').fill('relay_live_8f2k') + await page.getByRole('button', { name: 'Connect' }).click() + await page.waitForURL('/deliveries') +} diff --git a/dashboard/e2e/connect.spec.ts b/dashboard/e2e/connect.spec.ts new file mode 100644 index 0000000..eb1e8fc --- /dev/null +++ b/dashboard/e2e/connect.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from './fixtures' + +// The gate in front of everything else: a key that works gets you in, one that +// doesn't gets a message, and no key at all gets you sent back. + +test('a key the API would reject shows the error from the contract', async ({ page }) => { + await page.goto('/') + await page.getByLabel('API key').fill('nope') + await page.getByRole('button', { name: 'Connect' }).click() + + await expect(page.getByText('Unknown or revoked API key')).toBeVisible() + await expect(page).toHaveURL('/') +}) + +test('a key that works lands on the delivery log', async ({ page }) => { + await page.goto('/') + await page.getByLabel('API key').fill('relay_live_8f2k') + await page.getByRole('button', { name: 'Connect' }).click() + + await expect(page).toHaveURL('/deliveries') + await expect(page.getByRole('heading', { name: 'Delivery log' })).toBeVisible() +}) + +test('without a key, a deep link goes back to Connect', async ({ page }) => { + // Straight to an inside page, no key in sessionStorage. The guard in App.tsx + // has to send us back, or the screens are reachable by typing a URL. + await page.goto('/endpoints') + + await expect(page).toHaveURL('/') + await expect(page.getByRole('heading', { name: 'Connect to Relay' })).toBeVisible() +}) diff --git a/dashboard/e2e/deliveries.spec.ts b/dashboard/e2e/deliveries.spec.ts new file mode 100644 index 0000000..3738b35 --- /dev/null +++ b/dashboard/e2e/deliveries.spec.ts @@ -0,0 +1,134 @@ +import type { Page } from '@playwright/test' +import { expect, test } from './fixtures' +import { connect } from './connect-first' + +// The fixtures in src/mocks.ts are fixed, so the counts are too. Writing them +// out means a test fails if a fixture quietly disappears, which is how two log +// rows ended up with no detail screen in the first place. +const ROWS = { all: 5, pending: 1, delivered: 2, dead: 2 } + +test.beforeEach(async ({ page }) => { + await connect(page) +}) + +// Opens the first row under a filter. Waiting for the heading, not just the URL: +// React Router changes the address before it renders, so the URL alone can be the +// detail screen while the DOM is still the log. +async function openFirst(page: Page, filter: keyof typeof ROWS) { + await page.getByRole('button', { name: filter, exact: true }).click() + await expect(page.locator('tbody tr')).toHaveCount(ROWS[filter]) + await page.locator('tbody a').first().click() + await expect(page.getByRole('link', { name: '← Back to the log' })).toBeVisible() +} + +// Opens one named delivery. `dead` holds two rows now, and "Old CRM" sorts first +// because the log is newest first, so a test that means a particular delivery has +// to say which one instead of taking whichever is on top. +async function openRow(page: Page, rowText: string) { + await page.goto('/deliveries') + await page.locator('tbody tr', { hasText: rowText }).locator('a').click() + await expect(page.getByRole('link', { name: '← Back to the log' })).toBeVisible() +} + +test('every row in the log opens its delivery', async ({ page }) => { + // Two of the four rows used to open "Delivery not found", because only the + // pending and dead deliveries had a detail fixture. + await expect(page.locator('tbody tr')).toHaveCount(ROWS.all) + + for (let i = 0; i < ROWS.all; i++) { + await page.goto('/deliveries') + await page.locator('tbody a').nth(i).click() + + await expect(page.getByRole('link', { name: '← Back to the log' })).toBeVisible() + await expect(page.getByText('Delivery not found')).toBeHidden() + await expect(page.getByTestId('status')).toBeVisible() + } +}) + +test('the filters narrow the log and can be cleared', async ({ page }) => { + await expect(page.locator('tbody tr')).toHaveCount(ROWS.all) + + await page.getByRole('button', { name: 'dead', exact: true }).click() + await expect(page.locator('tbody tr')).toHaveCount(ROWS.dead) + // An array, not a single string: the filter holds two rows now, and asserting + // every one of them is what makes this a filter test rather than a count test. + await expect(page.getByTestId('status')).toHaveText(Array(ROWS.dead).fill('dead')) + + await page.getByRole('button', { name: 'all', exact: true }).click() + await expect(page.locator('tbody tr')).toHaveCount(ROWS.all) +}) + +test('replaying a dead delivery survives the 2 s reload', async ({ page }) => { + // The detail screen reloads itself every 2 s while a delivery is pending. + // Replay makes it pending, so the reload used to hand back the dead fixture + // and undo the replay about two seconds later. + // By name, not by position: the dead filter also holds the deleted-endpoint + // delivery, which has no Replay button at all. + await openRow(page, 'payment.failed') + await expect(page.getByTestId('status')).toHaveText('dead') + + await page.getByRole('button', { name: 'Replay' }).click() + await expect(page.getByTestId('status')).toHaveText('pending') + + // Past two reloads, not one: the bug needed a single tick to show up. + await page.waitForTimeout(5000) + await expect(page.getByTestId('status')).toHaveText('pending') +}) + +test('a delivered delivery offers no Replay', async ({ page }) => { + await openFirst(page, 'delivered') + + await expect(page.getByTestId('status')).toHaveText('delivered') + await expect(page.getByRole('button', { name: 'Replay' })).toBeHidden() +}) + +test('the deleted-endpoint delivery opens and explains why Replay is gone', async ({ page }) => { + // This row arrived with #7 and had no detail fixture registered, so it opened + // "Delivery not found" while its fixture sat exported and unused. Counting rows + // could not see it: the count was right, the row was there, only the screen + // behind it was wrong. + await openRow(page, 'Old CRM') + + await expect(page.getByText('Delivery not found')).toBeHidden() + await expect(page.getByTestId('status')).toHaveText('dead') + await expect(page.getByText('Old CRM')).toBeVisible() + + // The contract refuses this replay with 409 endpoint_deleted, so the button + // must not be offered at all. + await expect(page.getByRole('button', { name: 'Replay' })).toBeHidden() + await expect(page.getByText(/endpoint was deleted/i)).toBeVisible() +}) + +test('a replay is still there in the log and on the way back', async ({ page }) => { + // Before the shared store, replay only touched the detail screen's own Map: the + // log kept saying dead/8 while the detail said pending/0, and the two disagreed + // for the rest of the session. One source now, so this walks all three steps: + // detail → log → detail. + await openRow(page, 'payment.failed') + await expect(page.getByTestId('status')).toHaveText('dead') + + await page.getByRole('button', { name: 'Replay' }).click() + await expect(page.getByTestId('status')).toHaveText('pending') + + // The log: the row must have followed, both its status and its attempt count. + await page.getByRole('link', { name: '← Back to the log' }).click() + const row = page.locator('tbody tr', { hasText: 'payment.failed' }) + await expect(row.getByTestId('status')).toHaveText('pending') + await expect(row).toContainText('0') + + // And back in again, to be sure it was stored and not just rendered once. Waiting + // for the back link first, like openFirst does: the address changes before the DOM + // does, so asserting on the status straight after the click reads the log's five. + await row.getByRole('link').click() + await expect(page.getByRole('link', { name: '← Back to the log' })).toBeVisible() + await expect(page.getByTestId('status')).toHaveText('pending') +}) + +test('an id that does not exist says so', async ({ page }) => { + // The positive control for the test above: "Delivery not found" has to still + // appear for a real miss, or that assertion proves nothing. + await page.goto('/deliveries/0199ffff-ffff-7fff-bfff-ffffffffffff') + + await expect(page.getByText('Delivery not found')).toBeVisible() + await expect(page.getByTestId('status')).toBeHidden() +}) diff --git a/dashboard/e2e/endpoints.spec.ts b/dashboard/e2e/endpoints.spec.ts new file mode 100644 index 0000000..38639ac --- /dev/null +++ b/dashboard/e2e/endpoints.spec.ts @@ -0,0 +1,168 @@ +import { expect, test } from './fixtures' +import { connect } from './connect-first' + +// src/mocks.ts starts with three endpoints. Writing the number out means +// toHaveCount can wait for the table, where count() would read 0 before React +// had rendered and quietly compare against nothing. +const START = 3 + +test.beforeEach(async ({ page }) => { + await connect(page) + await page.getByRole('link', { name: 'Endpoints' }).click() + await expect(page.getByRole('heading', { name: 'Endpoints' })).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(START) +}) + +test('two added endpoints are two separate rows', async ({ page }) => { + // They used to share the mock's id. Counting rows does NOT catch that: React + // still draws both rows and only warns. The noConsoleErrors fixture is what + // fails the test — see e2e/fixtures.ts. Proven by putting the duplicate id back. + const rows = page.locator('tbody tr') + + for (const [name, url] of [ + ['First one', 'https://example.com/hooks/one'], + ['Second one', 'https://example.com/hooks/two'], + ]) { + await page.getByLabel('Name').fill(name) + await page.getByLabel('URL').fill(url) + await page.getByRole('button', { name: 'Add endpoint' }).click() + } + + await expect(rows).toHaveCount(START + 2) + await expect(page.getByRole('cell', { name: 'First one' })).toBeVisible() + await expect(page.getByRole('cell', { name: 'Second one' })).toBeVisible() +}) + +test('the signing secret is shown once and then let go', async ({ page }) => { + await page.getByLabel('Name').fill('Ops webhook') + await page.getByLabel('URL').fill('https://example.com/hooks/ops') + await page.getByRole('button', { name: 'Add endpoint' }).click() + + const secret = page.getByText(/^whsec_/) + await expect(secret).toBeVisible() + + await page.getByRole('button', { name: 'I have copied it' }).click() + await expect(secret).toBeHidden() +}) + +// Customers' URLs must not point inside our own network (SSRF, docs/CONTEXT.md). +// The API rejects these with 422 invalid_endpoint_url; the form should too. +// +// Each case names the message it must produce. Asserting only "some error showed" +// let `loopback by name` pass with the loopback rule deleted: localhost fell +// through to the unrelated "does not look reachable" branch and still errored. +const PRIVATE = /private or internal/ +const NOT_A_URL = /Enter a full URL/ + +const refused = [ + ['https://', 'not a whole URL', NOT_A_URL], + ['example.com', 'no scheme', NOT_A_URL], + ['http://example.com', 'plain http', /must start with https/], + ['https://localhost:3000/hook', 'loopback by name', PRIVATE], + ['https://127.0.0.1/hook', 'loopback by address', PRIVATE], + ['https://0.0.0.0/hook', '0.0.0.0, which is loopback on Linux', PRIVATE], + ['https://192.168.1.10/hook', 'a private range', PRIVATE], + ['https://100.64.0.1/hook', 'the CGNAT range', PRIVATE], + ['https://169.254.169.254/latest/meta-data', "the cloud's metadata address", PRIVATE], + // Inside the cluster nothing is reached by number. These are the three the + // comment in Endpoints.tsx names as the threat, and they used to get through. + ['https://kubernetes.default.svc.cluster.local/hook', 'the Kubernetes API by name', PRIVATE], + ['https://postgres.relay.svc.cluster.local:5432/hook', 'Postgres by name', PRIVATE], + ['https://metadata.google.internal/token', 'cloud metadata by name', PRIVATE], + // IPv6, which the host-name rule used to decide: these have no dots, so they fell + // through to "does not look reachable" instead of being named as private. + ['https://[::1]/hook', 'IPv6 loopback', PRIVATE], + ['https://[fd00::1]/hook', 'an IPv6 unique-local address', PRIVATE], + ['https://[fe80::1]/hook', 'an IPv6 link-local address', PRIVATE], +] as const + +for (const [url, why, message] of refused) { + test(`refuses ${why}: ${url}`, async ({ page }) => { + await page.getByLabel('Name').fill('Should not be added') + await page.getByLabel('URL').fill(url) + await page.getByRole('button', { name: 'Add endpoint' }).click() + + // The right message, and nothing added. Either on its own would pass for the + // wrong reason: a message can come from a different rule, and a row count can + // stay put because the form silently did nothing. + await expect(page.locator('form p')).toHaveText(message) + await expect(page.locator('tbody tr')).toHaveCount(START) + }) +} + +// The refusals must not be so eager that ordinary URLs stop working. The second +// one contains "localhost" inside a public name and has to be accepted. +for (const url of [ + 'https://example.com/hooks/relay', + 'https://my-app.localhost.example.com/hook', + 'https://sub.domain.co.uk/x', + // Public IP literals are legitimate endpoints. The IPv6 one is why the dot rule + // had to stop applying to literals: [2001:db8::1] contains no dot at all. + 'https://93.184.216.34/hook', + 'https://[2001:db8::1]/hook', +]) { + test(`accepts ${url}`, async ({ page }) => { + await page.getByLabel('Name').fill('Good one') + await page.getByLabel('URL').fill(url) + await page.getByRole('button', { name: 'Add endpoint' }).click() + + await expect(page.locator('tbody tr')).toHaveCount(START + 1) + }) +} + +// The contract's length limits: name 1–100, URL at most 2048 characters +// (docs/api.md, POST /v1/endpoints). Both sides of each boundary, in one test, so a +// rule that refuses everything cannot pass as a rule that refuses what it should. +test('an added endpoint is still there after leaving the screen', async ({ page }) => { + // The rows used to live in this screen's own useState, so navigating away threw + // the new endpoint away. Only in-app navigation counts here: page.goto() reloads + // the page, which restarts the mock store and would hide the bug. + await page.getByLabel('Name').fill('Survives navigation') + await page.getByLabel('URL').fill('https://example.com/hooks/survives') + await page.getByRole('button', { name: 'Add endpoint' }).click() + await expect(page.locator('tbody tr')).toHaveCount(START + 1) + + await page.getByRole('link', { name: 'Delivery log' }).click() + await expect(page.getByRole('heading', { name: 'Delivery log' })).toBeVisible() + + await page.getByRole('link', { name: 'Endpoints' }).click() + await expect(page.locator('tbody tr')).toHaveCount(START + 1) + await expect(page.getByRole('cell', { name: 'Survives navigation' })).toBeVisible() + + // The secret must not come back with it: it is shown once, and the stored row + // never held it. + await expect(page.getByText(/^whsec_/)).toBeHidden() +}) + +test('a name is accepted at 100 characters and refused at 101', async ({ page }) => { + await page.getByLabel('URL').fill('https://example.com/hooks/limits') + + await page.getByLabel('Name').fill('n'.repeat(101)) + await page.getByRole('button', { name: 'Add endpoint' }).click() + await expect(page.locator('form p')).toHaveText(/at most 100 characters/) + await expect(page.locator('tbody tr')).toHaveCount(START) + + await page.getByLabel('Name').fill('n'.repeat(100)) + await page.getByRole('button', { name: 'Add endpoint' }).click() + await expect(page.locator('tbody tr')).toHaveCount(START + 1) +}) + +test('a URL is accepted at 2048 characters and refused at 2049', async ({ page }) => { + const base = 'https://example.com/' + const ofLength = (total: number) => base + 'a'.repeat(total - base.length) + + await page.getByLabel('Name').fill('Long URL') + + const tooLong = ofLength(2049) + expect(tooLong).toHaveLength(2049) // the test's own arithmetic, checked + await page.getByLabel('URL').fill(tooLong) + await page.getByRole('button', { name: 'Add endpoint' }).click() + await expect(page.locator('form p')).toHaveText(/at most 2048 characters/) + await expect(page.locator('tbody tr')).toHaveCount(START) + + const justFits = ofLength(2048) + expect(justFits).toHaveLength(2048) + await page.getByLabel('URL').fill(justFits) + await page.getByRole('button', { name: 'Add endpoint' }).click() + await expect(page.locator('tbody tr')).toHaveCount(START + 1) +}) diff --git a/dashboard/e2e/fixtures.ts b/dashboard/e2e/fixtures.ts new file mode 100644 index 0000000..90a329a --- /dev/null +++ b/dashboard/e2e/fixtures.ts @@ -0,0 +1,27 @@ +import { test as base, expect } from '@playwright/test' + +// A test that also fails when the browser console logs an error. +// +// Why: counting rows can't catch two React children sharing a key — React still +// draws both rows and only warns. The warning is the only visible symptom, so a +// test that ignores the console can't see the bug at all. Proven by putting the +// duplicate id back: the row count stayed right and the test stayed green. +// +// This runs against the dev server, where React still emits these warnings; a +// production build strips them. +export const test = base.extend<{ noConsoleErrors: void }>({ + noConsoleErrors: [ + async ({ page }, use) => { + const errors: string[] = [] + page.on('console', (m) => m.type() === 'error' && errors.push(m.text())) + page.on('pageerror', (e) => errors.push(String(e))) + + await use() + + expect(errors, `browser console errors:\n${errors.join('\n')}`).toEqual([]) + }, + { auto: true }, // every test in a file that imports this one gets it + ], +}) + +export { expect } from '@playwright/test' diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..52b5e14 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + + Relay + + +
+ + + diff --git a/dashboard/nginx.conf b/dashboard/nginx.conf new file mode 100644 index 0000000..4d7d7f6 --- /dev/null +++ b/dashboard/nginx.conf @@ -0,0 +1,84 @@ +# Serves the built dashboard. Port 8080 because the unprivileged nginx image runs +# as uid 101 and cannot bind a port below 1024. + +# nginx's add_header does not merge: a location with its own add_header drops every +# header inherited from the server block. Rather than repeat the security headers in +# each location, the one header that differs per path is chosen here, so all of them +# can live in a single place. +map $uri $cache_control { + # Vite puts a content hash in every asset filename, so an asset never changes + # under the same name and can be cached for a year. + ~^/assets/ "public, max-age=31536000, immutable"; + + # Everything else is index.html, which points at the hashed assets. A cached copy + # would keep serving the previous deploy. + default "no-cache"; +} + +server { + listen 8080; + server_name _; + + # Stop announcing the exact nginx version in every response: knowing it makes it + # cheap to look up vulnerabilities for this specific build. + server_tokens off; + + # Redirects go out as a path, not a full URL. nginx would otherwise build one from + # this server's own name and port 8080, which is neither the scheme nor the port + # the client reached us on through Traefik. + absolute_redirect off; + + # Compress text responses. The bundle is 275 kB on the wire and 86 kB gzipped. + gzip on; + gzip_types application/javascript text/css image/svg+xml application/json; + gzip_min_length 1024; + + root /usr/share/nginx/html; + index index.html; + + # The dashboard keeps the owner's API key in sessionStorage, which any script on + # the page can read. React escapes the values it renders, so that is the first + # line of defence; this is the second. + # + # script-src is what actually stops the attack: an injected script is never run. + # connect-src and img-src are defence in depth - they close the easy ways to beacon + # the key out, but not every way: CSP cannot restrain a top-level navigation, so + # location.href = 'https://…?k=' + key would still get through if a script ran. + # + # script-src 'self' would be weak on its own here, because the SPA fallback makes + # every path serve index.html, so