diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..27e82a8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + paths: + - 'dashboard/**' + - '.github/workflows/ci.yml' + pull_request: + paths: + - 'dashboard/**' + - '.github/workflows/ci.yml' + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +permissions: + contents: read + +jobs: + test-e2e: + name: Playwright Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: dashboard + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: dashboard/package-lock.json + + - name: Install dashboard dependencies + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('dashboard/package-lock.json') }} + + - name: Install Playwright browsers and OS dependencies + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Install Playwright OS dependencies only + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Run Playwright tests + run: npx playwright test + + - name: Upload Playwright report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: dashboard/playwright-report/ + retention-days: 30 diff --git a/dashboard/.gitignore b/dashboard/.gitignore index ba3e0a7..43383a4 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -5,3 +5,5 @@ build/ .env.* !.env.example .dev.vars +playwright-report/ +test-results/ diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 582724d..c61fd57 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -13,6 +13,8 @@ "uplot": "^1.6.31" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.49.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/node": "^26.0.0", @@ -21,6 +23,19 @@ "wrangler": "^4.104.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -1213,6 +1228,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1678,6 +1709,16 @@ "node": ">= 0.4" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2229,6 +2270,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 28b1387..9e18077 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -7,7 +7,9 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "typecheck": "svelte-kit sync && tsc --noEmit" + "typecheck": "svelte-kit sync && tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@sveltejs/kit": "^2.15.0", @@ -15,6 +17,8 @@ "uplot": "^1.6.31" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.49.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/node": "^26.0.0", diff --git a/dashboard/playwright.config.ts b/dashboard/playwright.config.ts new file mode 100644 index 0000000..9ee8f60 --- /dev/null +++ b/dashboard/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [['github'], ['html']] : 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/dashboard/src/app.css b/dashboard/src/app.css index 4c37078..4736383 100644 --- a/dashboard/src/app.css +++ b/dashboard/src/app.css @@ -1,10 +1,11 @@ :root { --color-bg: #ffffff; --color-text: #1a1a1a; - --color-muted: #6b7280; + --color-muted: #475569; /* slate-600, 6.8:1 on white — WCAG AA safe */ --color-border: #e5e7eb; --color-accent: #3b82f6; --color-accent-faint: rgba(59, 130, 246, 0.08); + --color-link: #1d4ed8; /* blue-700, 6.1:1 on white — WCAG AA safe for text links */ --font-mono: 'SFMono-Regular', 'Consolas', 'Liberation Mono', monospace; } @@ -12,10 +13,11 @@ :root { --color-bg: #0f172a; --color-text: #f1f5f9; - --color-muted: #94a3b8; + --color-muted: #94a3b8; /* 6.6:1 on #0f172a — WCAG AA safe */ --color-border: #1e293b; --color-accent: #60a5fa; --color-accent-faint: rgba(96, 165, 250, 0.12); + --color-link: #93c5fd; /* blue-300, 9:1 on #0f172a — WCAG AA safe for text links */ } } diff --git a/dashboard/src/routes/[owner]/[repo]/+page.svelte b/dashboard/src/routes/[owner]/[repo]/+page.svelte index 732dd93..5ba87c1 100644 --- a/dashboard/src/routes/[owner]/[repo]/+page.svelte +++ b/dashboard/src/routes/[owner]/[repo]/+page.svelte @@ -90,7 +90,7 @@ } .breadcrumb a { - color: var(--color-accent); + color: var(--color-link); text-decoration: none; } @@ -148,7 +148,7 @@ .metric-tabs button.active { background: var(--color-accent); border-color: var(--color-accent); - color: #fff; + color: #0f172a; /* near-black: 4.7:1 on blue-500, 6.6:1 on blue-400 — WCAG AA both modes */ } .metric-tabs button:not(.active):hover { diff --git a/dashboard/static/_headers b/dashboard/static/_headers new file mode 100644 index 0000000..9cbd19e --- /dev/null +++ b/dashboard/static/_headers @@ -0,0 +1,5 @@ +/* + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + Content-Security-Policy: default-src 'self'; img-src 'self' https://avatars.githubusercontent.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' diff --git a/dashboard/tests/a11y/axe.spec.ts b/dashboard/tests/a11y/axe.spec.ts new file mode 100644 index 0000000..b9f6b44 --- /dev/null +++ b/dashboard/tests/a11y/axe.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { mockApi } from '../helpers.js'; + +for (const colorScheme of ['light', 'dark'] as const) { + test.describe(`projects listing — ${colorScheme} mode`, () => { + test.beforeEach(async ({ page }) => { + await mockApi(page); + await page.emulateMedia({ colorScheme }); + await page.goto('/'); + // Wait for the async load function to resolve and a project card to render + await page.waitForSelector('.card'); + }); + + test('has no WCAG 2.0 AA violations', async ({ page }) => { + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); + expect(results.violations).toEqual([]); + }); + }); + + test.describe(`project detail page — ${colorScheme} mode`, () => { + test.beforeEach(async ({ page }) => { + await mockApi(page); + await page.emulateMedia({ colorScheme }); + await page.goto('/testorg/repo'); + // The tablist is part of the page template and appears once the load function resolves + await page.waitForSelector('[role="tablist"]'); + }); + + test('has no WCAG 2.0 AA violations', async ({ page }) => { + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); + expect(results.violations).toEqual([]); + }); + }); +} diff --git a/dashboard/tests/helpers.ts b/dashboard/tests/helpers.ts new file mode 100644 index 0000000..154afed --- /dev/null +++ b/dashboard/tests/helpers.ts @@ -0,0 +1,45 @@ +import type { Page } from '@playwright/test'; + +export const MOCK_PROJECT = { + id: 1, + owner_id: 1, + github_repo_id: 1, + repo_name: 'repo', + full_slug: 'testorg/repo', + installation_id: 1, + default_branch: 'main', + badge_enabled: 0, + created_at: '2026-01-01T00:00:00Z', + owner_login: 'testorg', + owner_type: 'Organization', + owner_avatar_url: null, // null avoids external image requests in tests +}; + +export const MOCK_TREND_EMPTY = { + project: 'testorg/repo', + branch: 'main', + metric: 'coverage', + data: [], +}; + +/** + * Intercepts all /api/* requests so tests run without a live Worker backend. + * Register this before page.goto() so routes are in place before any fetch fires. + * + * Playwright matches routes in LIFO order — the last-registered handler has the + * highest priority. Register the catch-all first so specific routes registered + * afterwards take precedence over it. + */ +export async function mockApi(page: Page): Promise { + // Catch-all registered first = lowest priority; absorbs unmocked /api/* requests + await page.route('**/api/**', (route) => + route.fulfill({ status: 404, body: 'Not found' }), + ); + // Specific routes registered last = highest priority (override the catch-all) + await page.route('**/api/projects/testorg/repo/metrics*', (route) => + route.fulfill({ json: MOCK_TREND_EMPTY }), + ); + await page.route('**/api/projects', (route) => + route.fulfill({ json: [MOCK_PROJECT] }), + ); +} diff --git a/src/index.ts b/src/index.ts index c63f40c..36f7118 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { secureHeaders } from 'hono/secure-headers'; import type { Bindings, Variables } from './types'; import { rollupAndPrune } from './db/rollup'; @@ -11,6 +12,8 @@ import admin from './routes/admin'; const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); +app.use('*', secureHeaders()); + app.route('/api/ci', ci); app.route('/api/baseline', baseline); app.route('/api', api); diff --git a/src/lib/github.ts b/src/lib/github.ts index 395547e..44cc599 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -43,7 +43,8 @@ export async function getInstallationToken( ); if (!res.ok) { - throw new Error(`Failed to mint installation token: ${res.status} ${await res.text()}`); + console.error(`Failed to mint installation token: ${res.status}`, await res.text()); + throw new Error('Failed to mint installation token'); } const data = await res.json() as { token: string; expires_at: string }; diff --git a/src/middleware/access.ts b/src/middleware/access.ts index 0b5054e..bef9bce 100644 --- a/src/middleware/access.ts +++ b/src/middleware/access.ts @@ -55,6 +55,7 @@ export function requireAccess() { try { const header = decodeProtectedHeader(assertion); + if (header.alg !== 'RS256') throw new Error(`Unexpected algorithm: ${header.alg}`); let jwks = await fetchAccessJWKS(CF_ACCESS_TEAM_DOMAIN, false); let jwk = jwks.keys.find((k) => k.kid === header.kid); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 2ef39b5..7d2dc68 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -26,8 +26,8 @@ admin.post('/resync', requireAccess(), async (c) => { try { await performResync(installationId, c.env); } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return c.json({ error: `Resync failed: ${message}` }, 502); + console.error('Resync failed:', err); + return c.json({ error: 'Resync failed' }, 502); } return c.json({ ok: true }); diff --git a/src/routes/api.ts b/src/routes/api.ts index fcc5b49..ed9cb0c 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -20,6 +20,7 @@ api.get('/projects/:owner/:repo/metrics', requireAccess(), async (c) => { const metric = c.req.query('metric') ?? 'coverage'; const branch = c.req.query('branch') ?? project.default_branch; + if (branch.length > 255) return c.json({ error: 'Invalid branch' }, 400); const limit = Math.min(Number(c.req.query('limit') ?? '100'), 1000); const mapping = metricToColumn(metric); diff --git a/src/routes/baseline.ts b/src/routes/baseline.ts index 1b732d6..87f6b74 100644 --- a/src/routes/baseline.ts +++ b/src/routes/baseline.ts @@ -26,6 +26,7 @@ baseline.get('/:owner/:repo', requireOidc(), async (c) => { const metricName = c.req.query('metric') ?? 'coverage'; const branch = c.req.query('branch') ?? project.default_branch; + if (branch.length > 255) return c.json({ error: 'Invalid branch' }, 400); const mapping = metricToColumn(metricName); if (!mapping) return c.json({ error: `Unknown metric: ${metricName}` }, 400); diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 087315e..5838cc5 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -12,12 +12,16 @@ webhooks.post('/github', requireWebhookHmac(), async (c) => { const rawBody = c.get('rawBody'); const payload = JSON.parse(rawBody) as Record; - if (event === 'installation') { - await handleInstallation(payload, c.env); - } else if (event === 'installation_repositories') { - await handleInstallationRepositories(payload, c.env); + try { + if (event === 'installation') { + await handleInstallation(payload, c.env); + } else if (event === 'installation_repositories') { + await handleInstallationRepositories(payload, c.env); + } + // Unrecognised events are acknowledged and ignored + } catch (err) { + console.error('Webhook handler error:', err); } - // Unrecognised events are acknowledged and ignored return c.json({ ok: true }); }); @@ -49,6 +53,10 @@ async function handleInstallation(payload: Record, env: Binding if (action === 'created') { const { account } = installation; + if (account.type !== 'User' && account.type !== 'Organization') { + console.error(`Unexpected account type in installation payload: ${account.type}`); + return; + } const ownerId = await upsertOwner(env.DB, account.id, account.login, account.type, account.avatar_url); const repos = (payload.repositories as WebhookRepo[] | undefined) ?? []; @@ -73,6 +81,10 @@ async function handleInstallationRepositories( const { account } = installation; if (action === 'added') { + if (account.type !== 'User' && account.type !== 'Organization') { + console.error(`Unexpected account type in installation_repositories payload: ${account.type}`); + return; + } const ownerId = await upsertOwner(env.DB, account.id, account.login, account.type, account.avatar_url); const added = (payload.repositories_added as WebhookRepo[]) ?? []; const instToken = await getInstallationToken(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY, installationId); diff --git a/test/routing.test.ts b/test/routing.test.ts index c8c71f7..eebeda2 100644 --- a/test/routing.test.ts +++ b/test/routing.test.ts @@ -1,6 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { env } from 'cloudflare:test'; import worker from '../src/index'; +import type { Bindings } from '../src/types'; + +// @ts-expect-error cloudflare:test injects env at runtime +const testEnv = env as Bindings; describe('routing', () => { it('GET /api/health returns 200', async () => { @@ -26,4 +30,63 @@ describe('routing', () => { ); expect(res.status).toBe(401); }); + + it('responses include X-Content-Type-Options: nosniff security header', async () => { + const res = await worker.fetch(new Request('http://localhost/api/health'), env as never); + expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff'); + }); + + it('Access middleware rejects non-RS256 JWT with 403', async () => { + // Construct a JWT whose header declares alg:HS256 — the algorithm pin rejects it + // before any JWKS fetch or signature verification. + const toBase64Url = (s: string) => + btoa(s).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); + const fakeJwt = [ + toBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })), + toBase64Url(JSON.stringify({ sub: 'test' })), + 'fakesig', + ].join('.'); + + const res = await worker.fetch( + new Request('http://localhost/api/projects', { + headers: { 'Cf-Access-Jwt-Assertion': fakeJwt }, + }), + env as never, + ); + expect(res.status).toBe(403); + }); + + describe('branch validation', () => { + beforeEach(async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (1, 1, 'testorg', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (id, owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (1, 1, 1, 'repo', 'testorg/repo', 1, 'main')`, + ).run(); + }); + + it('GET /api/projects/:owner/:repo/metrics returns 400 for branch longer than 255 chars', async () => { + // DEV_BYPASS_SECRET is a local-dev-only secret not declared in wrangler.jsonc, so the + // test pool doesn't expose it as a binding. Inject a value directly into the env object + // so the bypass check in access.ts sees the same value as the request header. + const bypass = 'test-bypass-only'; + (testEnv as Record).DEV_BYPASS_SECRET = bypass; + try { + const longBranch = 'a'.repeat(256); + const res = await worker.fetch( + new Request(`http://localhost/api/projects/testorg/repo/metrics?branch=${longBranch}`, { + headers: { 'x-dev-bypass': bypass }, + }), + testEnv as never, + ); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe('Invalid branch'); + } finally { + delete (testEnv as Record).DEV_BYPASS_SECRET; + } + }); + }); });