/** * AI Software Factory — E2E: Sharing (Phase 10) * * Covers: publish a page → link works, password protection, expiry, * allowComments toggle, revoke, and public-page rendering without auth. * * Selector convention (tests/README.md): * data-testid="share-button" share/publish button (page header) * data-testid="share-dialog" share settings dialog * data-testid="share-copy-link" copy public link button * data-testid="share-password-toggle" "Require password" switch * data-testid="share-password" password field * data-testid="share-expiry" expiry picker * data-testid="share-allow-comments" "Allow comments" switch * data-testid="share-revoke" revoke share button * data-testid="public-page" public page render container */ import { test, expect } from "../fixtures"; import { createPage } from "../utils/api"; async function makePageWithContent( api: import("@playwright/test").APIRequestContext, workspaceId: string, title: string, ) { const page = await createPage(api, workspaceId, { title }); await api.post(`/api/v1/pages/${page.id}/blocks`, { data: { type: "PARAGRAPH", content: { text: "Shared content: the secret is 42." } }, }); return page; } test.describe("Sharing — Phase 10", () => { test("publish page → public link renders without auth", async ({ browser, api, freshWorkspace, }) => { const ws = await freshWorkspace("Share Space"); const page = await makePageWithContent(api, ws.id, "Public Report"); // Create the share via the API (the UI toggle calls the same endpoint). const shareRes = await api.post(`/api/v1/pages/${page.id}/share`, { data: { scope: "public" }, }); expect(shareRes.status()).toBe(201); const share = (await shareRes.json()) as { share: { token: string; url: string } }; expect(share.share.token).toBeTruthy(); // OPEN the public URL in a completely unauthenticated context. const anon = await browser.newContext(); const anonPage = await anon.newPage(); const publicUrl = share.share.url ?? `http://localhost:3000/s/${share.share.token}`; await anonPage.goto(publicUrl, { waitUntil: "domcontentloaded" }); await expect(anonPage.getByTestId("public-page")).toBeVisible({ timeout: 20_000 }); await expect(anonPage.getByText("the secret is 42")).toBeVisible(); // No auth affordances on the public surface. await expect(anonPage.getByTestId("sidebar")).toHaveCount(0); await expect(anonPage.getByTestId("user-menu")).toHaveCount(0); await anon.close(); }); test("password-protected share: wrong password blocked, correct passes", async ({ browser, api, freshWorkspace, }) => { const ws = await freshWorkspace("Password Space"); const page = await makePageWithContent(api, ws.id, "Locked Report"); const password = "unlock-me-42"; const shareRes = await api.post(`/api/v1/pages/${page.id}/share`, { data: { scope: "public", password }, }); expect(shareRes.status()).toBe(201); const share = (await shareRes.json()) as { share: { token: string } }; const anon = await browser.newContext(); const anonPage = await anon.newPage(); const publicUrl = `http://localhost:3000/s/${share.token}`; // 1) No password → locked view with a password field, content hidden. await anonPage.goto(publicUrl); const pw = anonPage.getByTestId("share-password-input"); await expect(pw).toBeVisible({ timeout: 20_000 }); await expect(anonPage.getByText("the secret is 42")).toHaveCount(0); // 2) Wrong password → error, still locked. await pw.fill("wrong-password"); await anonPage.getByTestId("share-password-submit").click(); await expect(anonPage.getByTestId("share-password-error")).toBeVisible({ timeout: 10_000, }); await expect(anonPage.getByText("the secret is 42")).toHaveCount(0); // 3) Correct password → content unlocks. await pw.fill(password); await anonPage.getByTestId("share-password-submit").click(); await expect(anonPage.getByText("the secret is 42")).toBeVisible({ timeout: 15_000, }); await anon.close(); }); test("expired share: link stops working after expiry", async ({ browser, api, freshWorkspace, }) => { const ws = await freshWorkspace("Expiry Space"); const page = await makePageWithContent(api, ws.id, "Ephemeral Report"); // Expire ~20s from now (the API accepts expiresAt as an ISO date). const expiresAt = new Date(Date.now() + 20_000).toISOString(); const shareRes = await api.post(`/api/v1/pages/${page.id}/share`, { data: { scope: "public", expiresAt }, }); expect(shareRes.status()).toBe(201); const share = (await shareRes.json()) as { share: { token: string } }; const publicUrl = `http://localhost:3000/s/${share.token}`; const anon = await browser.newContext(); const anonPage = await anon.newPage(); // Works BEFORE expiry. await anonPage.goto(publicUrl); await expect(anonPage.getByText("the secret is 42")).toBeVisible({ timeout: 20_000, }); // Wait for expiry (20s budget; generous timeout covers network). await anonPage.waitForTimeout(21_000); await anonPage.reload(); // After expiry: 410/404 state, no content. await expect(anonPage.getByText(/expired|no longer available/i)).toBeVisible({ timeout: 15_000, }); await expect(anonPage.getByText("the secret is 42")).toHaveCount(0); await anon.close(); }); test("allowComments=false hides the comment UI on the public page", async ({ browser, api, freshWorkspace, }) => { const ws = await freshWorkspace("NoComment Space"); const page = await makePageWithContent(api, ws.id, "Quiet Report"); const shareRes = await api.post(`/api/v1/pages/${page.id}/share`, { data: { scope: "public", allowComments: false }, }); const share = (await shareRes.json()) as { share: { token: string } }; const anon = await browser.newContext(); const anonPage = await anon.newPage(); await anonPage.goto(`http://localhost:3000/s/${share.token}`); await expect(anonPage.getByTestId("public-page")).toBeVisible({ timeout: 20_000 }); // No comment affordances anywhere on the public surface. await expect(anonPage.getByTestId("comment-popup")).toHaveCount(0); await expect(anonPage.getByTestId("comment-input")).toHaveCount(0); await expect(anonPage.getByText(/leave a comment|join the discussion/i)).toHaveCount(0); await anon.close(); }); test("revoke share: link returns 404", async ({ browser, api, freshWorkspace, }) => { const ws = await freshWorkspace("Revoke Space"); const page = await makePageWithContent(api, ws.id, "Revoked Report"); const shareRes = await api.post(`/api/v1/pages/${page.id}/share`, { data: { scope: "public" }, }); const share = (await shareRes.json()) as { share: { token: string; id?: string } }; const publicUrl = `http://localhost:3000/s/${share.token}`; // Revoke (API — the UI's "Revoke access" button calls this). const revokeRes = await api.delete(`/api/v1/shares/${share.token}`); expect([200, 204]).toContain(revokeRes.status()); const anon = await browser.newContext(); const anonPage = await anon.newPage(); const res = await anonPage.goto(publicUrl, { waitUntil: "domcontentloaded" }); // Either the server 404s or the client renders a not-found state. const status = res?.status() ?? 200; if (status === 404) { await expect(anonPage.getByText(/not found|no longer available/i)).toBeVisible(); } else { await expect(anonPage.getByTestId("public-page")).toHaveCount(0); await expect(anonPage.getByText(/not found|no longer available|revoked/i)).toBeVisible({ timeout: 10_000, }); } await anon.close(); }); test("only the owner/allowed roles can publish a page", async ({ api, secondUser, freshWorkspace, }) => { const ws = await freshWorkspace("Permissioned Share Space"); const page = await createPage(api, ws.id, { title: "Not For Outsiders" }); // The second user is NOT a member of this workspace. const { api: outsiderApi } = await secondUser(); const denied = await outsiderApi.post(`/api/v1/pages/${page.id}/share`, { data: { scope: "public" }, }); expect([403, 404]).toContain(denied.status()); }); });