The full Playwright suite had 5 pre-existing failures (stale assertions for since-reworked behavior) and the classifier carried dead code from removed flows. Stale tests refreshed to current behavior: - toast.spec (×3): toast.js now STACKS (sticky/dismissible) rather than showing one at a time — assert stacking + the "Clear all" control, read the message from .zddc-toast__msg (the toast also holds a × button), and dismiss via the × (clicking the body no longer dismisses, by design). - browse.spec: "New folder/New file" moved from the toolbar into the context menu — drop the #newFolderBtn/#newFileBtn assertions (Sort + Show-hidden stay). - tokens.spec XSS guard: rewritten to the current apiActions modal flow (#api-create-btn → .api-modal → #table-root) instead of the long-gone inline #desc form. The escaping assertion now actually runs and confirms it holds. Dead code removed: - classifier .mdl-overlay* CSS (orphaned when the "MDL from archive" instantiate flow moved to the tables tool). - classify.js filesInNode() — defined + exported but called nowhere. - "From a list" naming: refreshed a stale "catalog" comment and renamed the 3 remaining "By existing:" test titles. Full suite now 340 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
8.4 KiB
JavaScript
184 lines
8.4 KiB
JavaScript
/**
|
|
* tokens.spec.js — server-backed Playwright tests for /.tokens.
|
|
*
|
|
* Starts a real zddc-server master via tests/lib/server.mjs, drives a
|
|
* Chromium browser at the live URL with X-Auth-Request-Email injected
|
|
* (mimicking what an authenticating reverse proxy would do upstream),
|
|
* and exercises the create / list / revoke flow end-to-end through the
|
|
* inline JS on the page.
|
|
*
|
|
* Why a server-backed harness vs file:// — /.tokens is a server-rendered
|
|
* page that fetches /.api/tokens, and the JSON API authenticates via
|
|
* the email middleware. Reproducing that with file:// + mocks misses
|
|
* the whole contract under test. This is also the seed of a more
|
|
* general "drive the master in a browser" pattern that future UI
|
|
* debugging (the conflict-UI in phase 5, etc.) will reuse.
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
import { startMaster } from './lib/server.js';
|
|
|
|
test.describe('/.tokens self-service token UI', () => {
|
|
let server;
|
|
const TEST_EMAIL = 'alice@example.com';
|
|
|
|
test.beforeAll(async () => {
|
|
server = await startMaster({ adminEmail: TEST_EMAIL });
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
if (server) await server.stop();
|
|
});
|
|
|
|
// Inject X-Auth-Request-Email on every request from this Playwright
|
|
// browser context. In production the upstream auth proxy adds this
|
|
// after authenticating the user; in the test we set it directly so
|
|
// the master sees a consistent caller without standing up a real
|
|
// OIDC stack.
|
|
test.use({
|
|
extraHTTPHeaders: {
|
|
'X-Auth-Request-Email': 'alice@example.com',
|
|
},
|
|
});
|
|
|
|
test('anonymous → 401', async ({ request }) => {
|
|
// Override the per-test header by sending without auth.
|
|
const r = await request.get(`${server.baseURL}/.tokens`, {
|
|
headers: { 'X-Auth-Request-Email': '' },
|
|
});
|
|
expect(r.status()).toBe(401);
|
|
});
|
|
|
|
test('authenticated GET /.tokens renders the tokens table with email', async ({ page }) => {
|
|
// The page now renders through the shared tables engine (header chrome
|
|
// + declarative columns), not the bespoke skeleton: the title lives in
|
|
// #table-title, the signed-in email in the table description, create is
|
|
// the apiActions "+ New token" button, and the grid is #table-root.
|
|
await page.goto(`${server.baseURL}/.tokens`);
|
|
await expect(page.locator('#table-title')).toHaveText(/API tokens/i);
|
|
await expect(page.locator('#table-description')).toContainText(TEST_EMAIL);
|
|
await expect(page.locator('#api-create-btn')).toBeVisible();
|
|
await expect(page.locator('#table-root')).toBeVisible();
|
|
});
|
|
|
|
test('GET /.api/tokens initially returns empty list', async ({ request }) => {
|
|
const r = await request.get(`${server.baseURL}/.api/tokens`);
|
|
expect(r.status()).toBe(200);
|
|
const list = await r.json();
|
|
expect(Array.isArray(list)).toBe(true);
|
|
expect(list.filter(t => t.email === TEST_EMAIL)).toEqual([]);
|
|
});
|
|
|
|
test('create token via the page → plaintext shown once → list contains it → revoke', async ({ page }) => {
|
|
page.on('dialog', d => d.accept()); // auto-accept the revoke confirm()
|
|
await page.goto(`${server.baseURL}/.tokens`);
|
|
|
|
// Create via the apiActions "+ New token" modal.
|
|
await page.locator('#api-create-btn').click();
|
|
await expect(page.locator('.api-modal')).toBeVisible();
|
|
const description = `playwright-${Date.now()}`;
|
|
await page.locator('.api-modal input').first().fill(description);
|
|
await page.locator('.api-modal button[type="submit"]').click();
|
|
|
|
// The plaintext token is shown exactly once, in the secret dialog.
|
|
const secret = page.locator('.api-modal__secret');
|
|
await expect(secret).toBeVisible();
|
|
const plaintext = (await secret.textContent()).trim();
|
|
expect(plaintext.length).toBeGreaterThan(20);
|
|
expect(plaintext).not.toContain('<');
|
|
expect(plaintext).not.toContain('"');
|
|
|
|
// Verify via the API while the dialog is up.
|
|
const r = await page.request.get(`${server.baseURL}/.api/tokens`);
|
|
const matches = (await r.json()).filter(t => t.description === description);
|
|
expect(matches.length).toBe(1);
|
|
expect(matches[0].email).toBe(TEST_EMAIL);
|
|
|
|
// Done reloads; the new token appears as a row.
|
|
await page.locator('.api-modal button:has-text("Done")').click();
|
|
await page.waitForLoadState('networkidle');
|
|
const row = page.locator('#table-root tbody tr', { hasText: description });
|
|
await expect(row).toBeVisible();
|
|
|
|
// Revoke via the row's button (reloads on success).
|
|
await row.locator('.api-revoke').click();
|
|
await page.waitForLoadState('networkidle');
|
|
await expect(page.locator('#table-root tbody tr', { hasText: description })).toHaveCount(0);
|
|
|
|
// And gone from the API list.
|
|
const after = await (await page.request.get(`${server.baseURL}/.api/tokens`)).json();
|
|
expect(after.filter(t => t.description === description)).toEqual([]);
|
|
});
|
|
|
|
test('plaintext token authenticates a subsequent Bearer request', async ({ request }) => {
|
|
// Issue a token via the API.
|
|
const created = await request.post(`${server.baseURL}/.api/tokens`, {
|
|
data: { description: 'bearer-roundtrip' },
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(created.status()).toBe(201);
|
|
const { token } = await created.json();
|
|
|
|
// Use the bearer in a fresh request that does NOT carry the
|
|
// email header — the master should accept it because the token
|
|
// resolves to alice@example.com.
|
|
const noEmail = await request.get(`${server.baseURL}/`, {
|
|
headers: {
|
|
'X-Auth-Request-Email': '',
|
|
'Authorization': `Bearer ${token}`,
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
expect(noEmail.status()).toBe(200);
|
|
});
|
|
|
|
test('invalid Bearer → 401', async ({ request }) => {
|
|
const r = await request.get(`${server.baseURL}/`, {
|
|
headers: {
|
|
'X-Auth-Request-Email': '',
|
|
'Authorization': 'Bearer not-a-real-token',
|
|
},
|
|
});
|
|
expect(r.status()).toBe(401);
|
|
});
|
|
|
|
test('cross-user revoke returns 404 (no ownership leak)', async ({ request }) => {
|
|
// alice creates a token.
|
|
const r1 = await request.post(`${server.baseURL}/.api/tokens`, {
|
|
data: { description: 'alice-only' },
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
const { id } = await r1.json();
|
|
|
|
// bob attempts to revoke. Should 404, not 403, to avoid
|
|
// leaking the existence of alice's token.
|
|
const r2 = await request.delete(`${server.baseURL}/.api/tokens/${id}`, {
|
|
headers: { 'X-Auth-Request-Email': 'bob@example.com' },
|
|
});
|
|
expect(r2.status()).toBe(404);
|
|
|
|
// alice's token should still validate.
|
|
const list = await (await request.get(`${server.baseURL}/.api/tokens`)).json();
|
|
expect(list.find(t => t.id === id)).toBeTruthy();
|
|
});
|
|
|
|
test('XSS guard: description with HTML special chars is escaped on render', async ({ page }) => {
|
|
page.on('dialog', d => d.accept());
|
|
const xssDesc = `<img src=x onerror="window.__xss=1">`;
|
|
await page.goto(`${server.baseURL}/.tokens`);
|
|
// Create via the apiActions modal (the inline #desc form is long gone).
|
|
await page.locator('#api-create-btn').click();
|
|
await expect(page.locator('.api-modal')).toBeVisible();
|
|
await page.locator('.api-modal input').first().fill(xssDesc);
|
|
await page.locator('.api-modal button[type="submit"]').click();
|
|
await expect(page.locator('.api-modal__secret')).toBeVisible();
|
|
await page.locator('.api-modal button:has-text("Done")').click();
|
|
await page.waitForLoadState('networkidle');
|
|
// The description renders as a row — as TEXT, not parsed HTML.
|
|
const row = page.locator('#table-root tbody tr', { hasText: 'img src' });
|
|
await expect(row).toBeVisible();
|
|
// The <img> must NOT have been parsed (its onerror never fires)…
|
|
expect(await page.evaluate(() => window.__xss === 1)).toBe(false);
|
|
// …and the literal angle brackets survive in the cell text.
|
|
expect(await row.textContent()).toContain('<img');
|
|
});
|
|
});
|