SPB Git

Repository Public MIT

publisher.spec.ts 8.2 KB 131 lines typescript

1import AxeBuilder from "@axe-core/playwright";2import { expect, test, type Page } from "@playwright/test";3import { readFileSync } from "node:fs";4import { MANIFEST_FILE } from "@skills/registry-core";5import { packFiles } from "@skills/registry-core/node";67const state = JSON.parse(readFileSync("test-results/e2e-state.json", "utf8")) as { email: string; namespace: string; initialPassword: string; password: string; readonly?: boolean };8test.skip(!!state.readonly, "publisher flows mutate state");9test.describe.configure({ mode: "serial" });1011async function signIn(page: Page, password: string, expectSuccess = true) {12  await page.goto("/login");13  await page.getByLabel("Email").fill(state.email);14  await page.getByLabel("Password").fill(password);15  await page.getByRole("button", { name: "Sign in" }).click();16  if (expectSuccess) await page.waitForURL(/\/dashboard/);17}18let token = "";1920test.describe("publisher console", () => {21  test("rejects a wrong password without revealing whether the account exists", async ({ page }) => {22    await signIn(page, "definitely-the-wrong-password", false);23    await expect(page.getByRole("alert").filter({ hasText: "Invalid" })).toHaveText("Invalid email or password.");24    await expect(page).toHaveURL(/\/login$/);25  });2627  test("forces a password change on first sign-in", async ({ page }) => {28    await signIn(page, state.initialPassword);29    await expect(page).toHaveURL(/\/dashboard\/account\?first=1/);30    await page.goto("/dashboard/tokens");31    await expect(page).toHaveURL(/\/dashboard\/account/); // nothing else is reachable yet32    await page.getByLabel("Current password").fill(state.initialPassword);33    await page.getByLabel("New password", { exact: true }).fill(state.password);34    await page.getByLabel("Repeat new password").fill(state.password);35    await page.getByRole("button", { name: "Change password" }).click();36    await expect(page).toHaveURL(/\/dashboard\?password=changed/);37    const cookies = await page.context().cookies();38    const session = cookies.find((c) => c.name === "skills_session")!;39    expect(session.httpOnly).toBe(true);40    expect(session.sameSite).toBe("Lax");41  });4243  test("creates a scoped token shown exactly once, which then works against the API", async ({ page, request }) => {44    await signIn(page, state.password);45    await page.goto("/dashboard/tokens");46    await page.getByRole("textbox", { name: "Name" }).fill("e2e-publisher");47    await page.getByRole("button", { name: "Create token" }).click();48    const secret = page.getByTestId("one-time-secret").locator("code").first();49    await expect(secret).toHaveText(/^skr_[a-z0-9]{10}_[A-Za-z0-9_-]{43}$/);50    token = (await secret.innerText()).trim();51    const me = await (await request.get("/api/v1/me", { headers: { Authorization: `Bearer ${token}` } })).json();52    expect(me.credential).toMatchObject({ kind: "token", name: "e2e-publisher", namespaces: [state.namespace], elevated: false });53    await page.reload();54    await expect(page.getByTestId("one-time-secret")).toHaveCount(0);55    expect(await page.content()).not.toContain(token);56    await expect(page.getByRole("cell", { name: /e2e-publisher/ })).toBeVisible();57    const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze();58    expect(results.violations.map((v) => `${v.id}: ${v.nodes[0]?.target}`)).toEqual([]);59  });6061  test("creates an artifact and publishes a release from the browser", async ({ page, request }) => {62    await signIn(page, state.password);63    await page.goto("/dashboard/artifacts");64    await page.getByLabel("Slug").fill("browser-skill");65    await page.getByLabel("Summary").fill("Uploaded from the browser console by the end-to-end suite.");66    await page.getByLabel("Visibility").selectOption("public");67    await page.getByLabel(/I confirm this artifact may be publicly visible/).check();68    await page.getByRole("button", { name: "Create artifact" }).click();69    await expect(page).toHaveURL(/\/dashboard\/artifacts\/[0-9a-f-]{36}$/);7071    const name = `@${state.namespace}/browser-skill`;72    const files = (version: string, extra: { path: string; mode: number; data: Buffer }[] = []) => [73      { path: MANIFEST_FILE, mode: 0o644, data: Buffer.from(JSON.stringify({ schemaVersion: "1.0", name, version, type: "skill", description: "Uploaded from the browser console by the end-to-end suite.", license: "MIT", compatibility: ["claude-code"] })) },74      { path: "SKILL.md", mode: 0o644, data: Buffer.from("---\nname: browser-skill\ndescription: Demonstrates browser upload. Use in tests only.\n---\n\n# Browser skill\n\n<script>window.__xss = 1</script>\n\n[bad](javascript:alert(1))\n") },75      { path: "README.md", mode: 0o644, data: Buffer.from("# browser-skill\n") },76      ...extra,77    ];78    // A release that leaks a credential is refused, with the finding shown and nothing published.79    const leaky = packFiles(files("1.0.0", [{ path: "notes.txt", mode: 0o644, data: Buffer.from(`key = ghp_${"Q7".repeat(18)}\n`) }]));80    await page.getByLabel("Version").fill("1.0.0");81    await page.getByLabel(/Release archive/).setInputFiles({ name: "leaky.tar.gz", mimeType: "application/gzip", buffer: leaky.archive });82    await page.getByRole("button", { name: "Upload and publish" }).click();83    await expect(page.getByRole("alert").filter({ hasText: "rejected" })).toContainText("Nothing was published");84    await expect(page.getByLabel("Validation findings")).toContainText("possible GitHub token");85    expect((await request.get(`/api/v1/artifacts/${state.namespace}/browser-skill/versions`)).status()).toBe(404); // still a draft: invisible to the public8687    const good = packFiles(files("1.0.0"));88    await page.getByLabel(/Release archive/).setInputFiles({ name: "browser-skill-1.0.0.tar.gz", mimeType: "application/gzip", buffer: good.archive });89    await page.getByRole("button", { name: "Upload and publish" }).click();90    await expect(page.getByRole("status").filter({ hasText: "published" })).toContainText("Version 1.0.0 is published and immutable");9192    const errors: string[] = [];93    page.on("console", (m) => m.type() === "error" && errors.push(m.text()));94    await page.goto(`/@${state.namespace}/browser-skill`);95    await expect(page.getByTestId("sha256").first()).toHaveText(good.sha256);96    // Untrusted README content is inert: no script ran, no javascript: link survived.97    expect(await page.evaluate(() => (window as unknown as { __xss?: number }).__xss)).toBeUndefined();98    expect(await page.locator('a[href^="javascript:"]').count()).toBe(0);99    expect(await page.locator("#readme script").count()).toBe(0);100  });101102  test("the token is confined to its namespace, and revoking it in the console kills it", async ({ page, request }) => {103    const auth = { Authorization: `Bearer ${token}` };104    const foreign = await request.post("/api/v1/artifacts", { headers: auth, data: { namespace: "spboucher", slug: "intruder", type: "skill", summary: "Should be refused by namespace isolation." } });105    expect(foreign.status()).toBe(403);106    await signIn(page, state.password);107    await page.goto("/dashboard/tokens");108    page.on("dialog", (d) => d.accept());109    await page.getByRole("row", { name: /e2e-publisher/ }).getByRole("button", { name: "Revoke" }).click();110    await expect(page.getByRole("row", { name: /e2e-publisher/ })).toContainText("revoked");111    const after = await request.get("/api/v1/me", { headers: auth });112    expect(after.status()).toBe(401);113    expect((await after.json()).error.code).toBe("token_revoked");114    await page.goto("/dashboard/audit?action=token.");115    await expect(page.getByRole("cell", { name: "token.revoke" }).first()).toBeVisible();116    await page.goto("/dashboard/reports");117    await expect(page.getByRole("heading", { level: 1 })).toHaveText("This page does not exist."); // not an administrator118  });119120  test("sign-out ends the session server-side", async ({ page }) => {121    await signIn(page, state.password);122    const cookie = (await page.context().cookies()).find((c) => c.name === "skills_session")!;123    await page.goto("/dashboard");124    await page.getByRole("button", { name: "Sign out" }).click();125    await expect(page).toHaveURL(/\/$/);126    await page.context().addCookies([cookie]); // replaying the old cookie must not work127    await page.goto("/dashboard");128    await expect(page).toHaveURL(/\/login$/);129  });130});131
Files main
Menu

Theme