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
39 changes: 39 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: E2E

# Browser end-to-end regressions for the web UI — guards the bugs that only a real browser exposes
# (CSRF cross-origin/cross-port, X-Frame-Options, editor keyboard a11y, XSS escaping, axe a11y).
on:
push:
branches: [main]
pull_request:

jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile

# The e2e suite serves the built web client + server, which imports the built @truspec/core,
# so build the whole graph (turbo orders core before web).
- run: pnpm build

- name: Install Playwright chromium + OS deps
run: pnpm exec playwright install --with-deps chromium

- run: pnpm test:e2e

- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ coverage/
.stryker-tmp/
qa/.stryker-tmp/
qa/mutation-report.json

# Playwright e2e artifacts
test-results/
playwright-report/
35 changes: 35 additions & 0 deletions e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import AxeBuilder from "@axe-core/playwright";
import { test, expect } from "./fixtures";

// Guards the accessibility fixes (BUG-P: labels/heading/landmark; color-contrast raised to WCAG AA).
// Asserts ZERO axe violations on the main view, the editor, and in light theme.
const scan = (page: import("@playwright/test").Page) => new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze();

test.describe("accessibility (axe-core)", () => {
test("main view has no WCAG 2 A/AA violations", async ({ app, page }) => {
await page.goto(`${app.url}/`, { waitUntil: "networkidle" });
await page.waitForSelector(".rname");
const { violations } = await scan(page);
expect(violations, JSON.stringify(violations.map((v) => ({ id: v.id, nodes: v.nodes.length })))).toEqual([]);
});

test("editor view has no WCAG 2 A/AA violations (labeled controls)", async ({ app, page }) => {
await page.goto(`${app.url}/?new=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".editor .editor-text");
const { violations } = await scan(page);
expect(violations, JSON.stringify(violations.map((v) => ({ id: v.id, nodes: v.nodes.length })))).toEqual([]);
});

test("light theme has no WCAG 2 A/AA violations (contrast)", async ({ app, page }) => {
await page.goto(`${app.url}/?theme=light`, { waitUntil: "networkidle" });
await page.waitForSelector(".rname");
const { violations } = await scan(page);
expect(violations, JSON.stringify(violations.map((v) => ({ id: v.id, nodes: v.nodes.length })))).toEqual([]);
});

test("the spec dropdown and editor fields have accessible names", async ({ app, page }) => {
await page.goto(`${app.url}/`, { waitUntil: "networkidle" });
await expect(page.locator(".spec-pick select")).toHaveAttribute("aria-label", "OpenAPI spec");
await expect(page.locator("h1")).toHaveCount(1);
});
});
63 changes: 63 additions & 0 deletions e2e/editor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { test, expect } from "./fixtures";

// Guards the editor interaction bugs (BUG-O keyboard scoping) and the save flow.
test.describe("editor interactions (real browser)", () => {
test("valid save writes the file and updates the sidebar", async ({ app, page }) => {
await page.goto(`${app.url}/`, { waitUntil: "networkidle" });
await page.waitForSelector(".newreq");
await page.click(".newreq");
await page.fill(".editor .path-input", "folderx/created.tspec.yaml");
await page.fill(".editor .editor-text", 'tspec: "0.1"\nname: Created Req\nmethod: POST\nurl: "http://x/y"\nassertions: []\n');
await page.click(".editor .btn.run");
await page.waitForTimeout(700);
expect(existsSync(join(app.dir, "folderx", "created.tspec.yaml"))).toBe(true);
await expect(page.locator(".rname", { hasText: "Created Req" })).toHaveCount(1);
});

test("BUG-O: Esc cancels the editor from the path input (not only the textarea)", async ({ app, page }) => {
await page.goto(`${app.url}/`, { waitUntil: "networkidle" });
await page.click(".newreq");
await page.click(".editor .path-input");
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
await expect(page.locator(".editor")).toHaveCount(0);
});

test("BUG-O: Ctrl+Enter saves from anywhere in the editor", async ({ app, page }) => {
await page.goto(`${app.url}/`, { waitUntil: "networkidle" });
await page.click(".newreq");
await page.fill(".editor .path-input", "kbd.tspec.yaml");
await page.fill(".editor .editor-text", 'tspec: "0.1"\nname: Kbd\nurl: "http://x"\nassertions: []\n');
await page.click(".editor .editor-text");
await page.keyboard.press("Control+Enter");
await page.waitForTimeout(700);
expect(existsSync(join(app.dir, "kbd.tspec.yaml"))).toBe(true);
});

test("a traversal save path is refused and writes nothing outside the workspace", async ({ app, page }) => {
await page.goto(`${app.url}/`, { waitUntil: "networkidle" });
await page.click(".newreq");
await page.fill(".editor .path-input", "../../../../tmp/tspec-e2e-escape.tspec.yaml");
await page.fill(".editor .editor-text", 'tspec: "0.1"\nname: Evil\nurl: "http://x"\nassertions: []\n');
await page.click(".editor .btn.run");
await page.waitForTimeout(500);
await expect(page.locator(".editor-err")).toHaveCount(1);
expect(existsSync("/tmp/tspec-e2e-escape.tspec.yaml")).toBe(false);
});

test("double-click save produces one uncorrupted file (re-entrancy)", async ({ app, page }) => {
await page.goto(`${app.url}/`, { waitUntil: "networkidle" });
await page.click(".newreq");
await page.fill(".editor .path-input", "dbl.tspec.yaml");
await page.fill(".editor .editor-text", 'tspec: "0.1"\nname: Dbl\nurl: "http://x"\nassertions: []\n');
const save = page.locator(".editor .btn.run");
await save.click();
await save.click({ timeout: 400 }).catch(() => {});
await page.waitForTimeout(700);
const content = existsSync(join(app.dir, "dbl.tspec.yaml")) ? readFileSync(join(app.dir, "dbl.tspec.yaml"), "utf8") : "";
expect(content).toMatch(/name: Dbl/);
expect(content.trim().endsWith("assertions: []")).toBe(true);
});
});
43 changes: 43 additions & 0 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createServer } from "node:http";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { test as base } from "@playwright/test";

const ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");

export interface App {
url: string;
/** Absolute path to the temp workspace the server is serving (for server-side assertions). */
dir: string;
}

// A per-test web server over a fresh temp workspace (with a benign request, an XSS-payload-named
// request, an env, and a spec) plus a mock upstream so `run` succeeds.
export const test = base.extend<{ app: App }>({
app: async ({}, use) => {
const { startWebServer } = (await import(`${ROOT}/packages/web/dist/server/index.js`)) as {
startWebServer: (o: { dir: string; port: number; clientDir: string }) => Promise<{ url: string; close: () => Promise<void> }>;
};
const mock = createServer((_q, res) => { res.writeHead(200, { "content-type": "application/json" }); res.end('{"id":1,"name":"Rex"}'); });
await new Promise<void>((r) => mock.listen(0, "127.0.0.1", () => r()));
const mockPort = (mock.address() as { port: number }).port;

const dir = mkdtempSync(join(tmpdir(), "tspec-e2e-"));
mkdirSync(join(dir, "environments"), { recursive: true });
writeFileSync(join(dir, "environments", "local.env.yaml"), `tspec: "0.1"\nname: local\nvariables: { baseUrl: "http://127.0.0.1:${mockPort}" }\n`);
writeFileSync(join(dir, "get.tspec.yaml"), 'tspec: "0.1"\nname: Get pet\nmethod: GET\nurl: "{{baseUrl}}/pets/1"\nspec: { operation: "GET /pets/{id}" }\nassertions: [ { type: status, equals: 200 } ]\n');
// XSS probe: a request name that WOULD execute if the UI didn't escape it.
writeFileSync(join(dir, "evil.tspec.yaml"), 'tspec: "0.1"\nname: "<img src=x onerror=\\"window.__xss=true\\">"\nmethod: GET\nurl: "{{baseUrl}}/x"\nassertions: []\n');
writeFileSync(join(dir, "openapi.yaml"), 'openapi: 3.0.3\ninfo: { title: T, version: "1" }\npaths:\n /pets/{id}: { get: { operationId: getPet, responses: { "200": {} } } }\n /other: { get: { responses: { "200": {} } } }\n');

const web = await startWebServer({ dir, port: 0, clientDir: `${ROOT}/packages/web/dist/client` });
await use({ url: web.url, dir });
await web.close();
await new Promise((r) => mock.close(() => r(undefined)));
rmSync(dir, { recursive: true, force: true });
},
});

export { expect } from "@playwright/test";
22 changes: 22 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineConfig } from "@playwright/test";

// E2E regression suite for the web UI — guards the browser-only bugs that unit/coverage testing can't
// reach (BUG-M/N CSRF, BUG-O keyboard a11y, BUG-P screen-reader a11y, XSS escaping).
// CI installs the browser via `playwright install --with-deps chromium`. Locally you can point at an
// existing chromium with PW_EXECUTABLE_PATH (+ LD_LIBRARY_PATH if needed).
export default defineConfig({
testDir: ".",
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: 0,
timeout: 30_000,
reporter: [["list"]],
use: {
launchOptions: {
executablePath: process.env.PW_EXECUTABLE_PATH || undefined,
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
},
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
});
53 changes: 53 additions & 0 deletions e2e/security.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createServer } from "node:http";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { test, expect } from "./fixtures";

// Guards the security bugs that only a real browser exposes.
test.describe("web UI security (real browser)", () => {
test("XSS: a malicious request name is escaped, never executed (BUG-P precursor / campaign 7)", async ({ app, page }) => {
await page.goto(`${app.url}/`, { waitUntil: "networkidle" });
await page.waitForSelector(".rname");
// the onerror payload must NOT have fired, and no live <img onerror> element exists
expect(await page.evaluate(() => (window as unknown as { __xss?: boolean }).__xss)).toBeFalsy();
expect(await page.locator("img[onerror]").count()).toBe(0);
// the payload text is present, but escaped (visible as text)
expect(await page.locator(".rname", { hasText: "onerror" }).count()).toBeGreaterThan(0);
});

test("clickjacking: every response carries X-Frame-Options: DENY (BUG-M)", async ({ app, request }) => {
const res = await request.get(`${app.url}/`);
expect(res.headers()["x-frame-options"]).toBe("DENY");
expect(res.headers()["content-security-policy"]).toMatch(/frame-ancestors 'none'/);
});

test("CSRF: a page on ANOTHER loopback port cannot make /api/request execute (BUG-N)", async ({ app, page }) => {
// Attacker origin on a different port — the exact cross-port-loopback vector BUG-N exposed.
const attacker = createServer((_q, res) => {
res.writeHead(200, { "content-type": "text/html" });
res.end(`<!doctype html><body><script>
window.__done = false;
fetch("${app.url}/api/request", { method:"POST", headers:{"content-type":"text/plain"},
body: JSON.stringify({ path:"csrf-proof.tspec.yaml", content:"name: pwned\\nurl: http://x\\nassertions: []" }) })
.then(()=>window.__done="sent").catch(()=>window.__done="blocked");
</script></body>`);
});
await new Promise<void>((r) => attacker.listen(0, "127.0.0.1", () => r()));
const attackerURL = `http://127.0.0.1:${(attacker.address() as { port: number }).port}`;
try {
await page.goto(`${attackerURL}/`, { waitUntil: "load" });
await page.waitForTimeout(1500);
// The browser fetch is CORS-blocked either way; the real assertion is SERVER-SIDE: no file written.
expect(existsSync(join(app.dir, "csrf-proof.tspec.yaml"))).toBe(false);
} finally {
await new Promise((r) => attacker.close(() => r(undefined)));
}
});

test("CSRF: the API rejects a cross-origin Origin and accepts same-origin (BUG-M/N)", async ({ app, request }) => {
const cross = await request.post(`${app.url}/api/run`, { headers: { origin: "http://evil.com", "content-type": "text/plain" }, data: "{}" });
expect(cross.status()).toBe(403);
const same = await request.post(`${app.url}/api/run`, { headers: { origin: app.url, "content-type": "application/json" }, data: "{}" });
expect(same.status()).toBe(200);
});
});
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
"gen:schema": "pnpm --filter @truspec/core build && pnpm --filter @truspec/core gen:schema",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
"docs:preview": "vitepress preview docs",
"test:e2e": "playwright test -c e2e/playwright.config.ts"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@playwright/test": "^1.61.0",
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@types/node": "^22.10.2",
Expand Down
12 changes: 8 additions & 4 deletions packages/core/test/fuzz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,12 @@ describe("seeded property fuzz (invariants)", () => {
expect(crashes).toBe(0);
});

it("INV-4 / no-ReDoS: mock pathToRegex matches a hostile path in bounded time; status always 200-599", () => {
let slowest = 0, badStatus = 0;
it("INV-4 / no-ReDoS: mock pathToRegex has no catastrophic-backtracking shape; status always 200-599", () => {
// Structural (deterministic, load-immune) ReDoS check: the generated regex must never contain two
// adjacent unbounded quantifiers over the same class (`[^/]+[^/]+`) — that's the O(n^k) shape the
// BUG-B fix collapsed away. We still `.test()` a 5000-char hostile path each iteration, so a real
// ReDoS would hang the loop and trip the vitest timeout; we just don't assert a flaky wall-clock.
let badStatus = 0, dangerousRegex = 0;
const hostile = "/" + "a".repeat(5000) + "/x/y/z";
for (const seed of SEEDS) {
const r = mulberry32(seed); const pick = <T>(a: T[]): T => a[Math.floor(r() * a.length)]!;
Expand All @@ -62,12 +66,12 @@ describe("seeded property fuzz (invariants)", () => {
const code = pick(["100", "200", "404", "600", "20000", "0", "default"]);
const spec = `openapi: 3.0.3\ninfo: { title: T, version: "1" }\npaths: ${JSON.stringify({ [path]: { get: { responses: { [code]: { content: { "application/json": { schema: {} } } } } } } })}\n`;
const re = buildRoutes(JSON.parse(`{"paths":${JSON.stringify({ [path]: { get: { responses: {} } } })}}`))[0]?.regex;
if (re) { const t0 = Date.now(); re.test(hostile); slowest = Math.max(slowest, Date.now() - t0); }
if (re) { re.test(hostile); if (/\[\^\/\]\+\[\^\/\]\+/.test(re.source)) dangerousRegex++; }
const res = createMockResponder(spec).respond("GET", path.replace(/\{[^}]+\}/g, "1"));
if (res && (res.status < 200 || res.status > 599)) badStatus++;
}
}
expect(slowest).toBeLessThan(100);
expect(dangerousRegex).toBe(0);
expect(badStatus).toBe(0);
});

Expand Down
16 changes: 12 additions & 4 deletions packages/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
--line: #23262d;
--line-2: #2d313a;
--text: #e8e9eb;
--dim: #888e98;
--dimmer: #565c66;
/* dim/dimmer raised to meet WCAG AA 4.5:1 on the darkest panel bg (was #888e98/#565c66, the latter
~2.6:1). Hierarchy preserved: dim is still clearly fainter than --text. */
--dim: #9499a3;
--dimmer: #868c96;

--lime: #c8ff32;
--green: #34d977;
Expand All @@ -45,8 +47,9 @@
--line: #ddd9cc;
--line-2: #cbc6b6;
--text: #1a1c20;
--dim: #6b6f63;
--dimmer: #9a9d92;
/* darkened to meet WCAG AA on the lightest panel bg (was #6b6f63/#9a9d92, the latter ~2.8:1). */
--dim: #5d6157;
--dimmer: #6b6f63;
--lime: #5f7a00;
--green: #1d9e57;
--red: #d63b3b;
Expand Down Expand Up @@ -201,6 +204,11 @@ select:focus {
border-color: var(--lime);
font-weight: 600;
}
/* In the light theme --lime is a dark green, so dark button text falls below WCAG AA (4:1).
Use light text on it instead (5:1). */
[data-theme="light"] .btn.run {
color: #ffffff;
}
.btn.run:hover {
box-shadow: 0 0 0 3px color-mix(in oklab, var(--lime) 30%, transparent);
}
Expand Down
Loading