Skip to content
Merged
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
7 changes: 7 additions & 0 deletions dashboard/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
.git
Dockerfile
.dockerignore
*.local

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add .env* too? Docker doesn't read .gitignore, so a local .env would be copied into the build stage.

.env*
2 changes: 2 additions & 0 deletions dashboard/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
test-results/
playwright-report/
1 change: 1 addition & 0 deletions dashboard/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.22.1
8 changes: 8 additions & 0 deletions dashboard/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -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 }]
}
}
24 changes: 24 additions & 0 deletions dashboard/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions dashboard/e2e/connect-first.ts
Original file line number Diff line number Diff line change
@@ -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')
}
31 changes: 31 additions & 0 deletions dashboard/e2e/connect.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
134 changes: 134 additions & 0 deletions dashboard/e2e/deliveries.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
168 changes: 168 additions & 0 deletions dashboard/e2e/endpoints.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading