who_need_help/e2e/tests/support-legal.spec.ts

562 lines
19 KiB
TypeScript

import { APIRequestContext, expect, Page, test } from "@playwright/test";
import {
captureBrowserFailures,
gotoLiveView,
latestMessageID,
loginWithMagicLink,
loginWithPassword,
newIsolatedContext,
projectEmail,
registerAndConfirm,
waitForApplicationEmailLink,
} from "./helpers";
async function waitForNewEmail(
request: APIRequestContext,
email: string,
previousMessageID: string | undefined,
expectedText: string,
): Promise<void> {
let messageID: string | undefined;
await expect
.poll(
async () => {
messageID = await latestMessageID(request, email);
return messageID && messageID !== previousMessageID
? messageID
: undefined;
},
{
message: `waiting for a new application email for ${email}`,
timeout: 15_000,
},
)
.toBeTruthy();
const response = await request.get(
`${process.env.MAILPIT_URL}/api/v1/message/${messageID}`,
);
expect(response.ok()).toBeTruthy();
const message = (await response.json()) as { Text?: string; HTML?: string };
expect(`${message.Text ?? ""}\n${message.HTML ?? ""}`).toContain(
expectedText,
);
}
async function submitAuthenticatedSupportRequest(
page: Page,
requesterEmail: string,
kind: "privacy_request" | "data_export",
subject: string,
details: string,
): Promise<void> {
await page.goto("/support");
await page.getByLabel("What do you need help with?").selectOption(kind);
await expect(page.getByLabel("Contact email")).toHaveValue(requesterEmail);
await expect(page.getByLabel("Contact email")).toHaveAttribute(
"readonly",
"",
);
await page.getByLabel("Subject").fill(subject);
await page.getByLabel("Describe the problem").fill(details);
await page.getByRole("button", { name: "Send support request" }).click();
await expect(page).toHaveURL(/\/support\/received\?reference=SUP-/);
await expect(
page.getByRole("heading", { name: "Support request created" }),
).toBeVisible();
}
test("support confirmation explains a missing protected fragment", async ({
browser,
}) => {
const context = await newIsolatedContext(browser);
const page = await context.newPage();
const assertBrowserClean = captureBrowserFailures(page);
await page.goto(
"/support/cases/00000000-0000-4000-8000-000000000001/verify",
);
await expect(page.locator("#support-confirmation-fragment-form")).toBeHidden();
await expect(
page.locator("#support-confirmation-fragment-invalid"),
).toBeVisible();
await expect(
page.getByText("The link is invalid or it has expired."),
).toBeVisible();
await expect(page.getByRole("link", { name: "Back to support" })).toHaveAttribute(
"href",
"/support",
);
assertBrowserClean();
await context.close();
});
test("content-removal confirmation explains a missing protected fragment", async ({
browser,
}) => {
const context = await newIsolatedContext(browser);
const page = await context.newPage();
const assertBrowserClean = captureBrowserFailures(page);
await page.goto(
"/legal/content-removal/00000000-0000-4000-8000-000000000002/verify",
);
await expect(
page.locator("#content-removal-confirmation-fragment-form"),
).toBeHidden();
await expect(
page.locator("#content-removal-confirmation-fragment-invalid"),
).toBeVisible();
await expect(
page.getByText("The link is invalid or it has expired."),
).toBeVisible();
await expect(
page
.locator("#content-removal-confirmation-fragment-invalid")
.getByRole("link", { name: "Report content" }),
).toHaveAttribute("href", "/legal/content-removal");
assertBrowserClean();
await context.close();
});
test("anonymous support confirmation opens from the protected email fragment", async ({
browser,
request,
}, testInfo) => {
const email = projectEmail("anonymous-support", testInfo.project.name);
const subject = `Anonymous support confirmation [${testInfo.project.name}]`;
const context = await newIsolatedContext(browser);
const page = await context.newPage();
const assertBrowserClean = captureBrowserFailures(page);
const previousMessageID = await latestMessageID(request, email);
await page.goto("/support");
await page
.getByLabel("What do you need help with?")
.selectOption("technical_issue");
await page.getByLabel("Contact email").fill(email);
await page.getByLabel("Subject").fill(subject);
await page
.getByLabel("Describe the problem")
.fill("Browser E2E verifies the protected Phoenix.Token fragment before support sees the request.");
await page.getByRole("button", { name: "Send support request" }).click();
await expect(page).toHaveURL(/\/support\/received\?reference=SUP-/);
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
const confirmationLink = await waitForApplicationEmailLink(
request,
email,
"/support/cases/",
previousMessageID,
);
const confirmationURL = new URL(confirmationLink);
expect(confirmationURL.pathname).toMatch(/\/support\/cases\/[0-9a-f-]+\/verify$/);
expect(confirmationURL.search).toBe("");
expect(confirmationURL.hash).toMatch(/^#token=SFMyNTY\./);
await page.goto(confirmationLink);
const confirmationForm = page.locator("#support-confirmation-fragment-form");
await expect(confirmationForm).toBeVisible();
await expect(
page.locator("#support-confirmation-fragment-invalid"),
).toBeHidden();
await expect(page.locator("#support-confirmation-fragment-token")).toHaveValue(
/^SFMyNTY\./,
);
await confirmationForm
.getByRole("button", { name: "Confirm support request" })
.click();
await expect(page).toHaveURL(/\/support\/cases\/[0-9a-f-]+\?token=/);
await expect(
page.getByText("Your email was confirmed and the request was sent to support."),
).toBeVisible();
await expect(page.getByRole("heading", { name: subject })).toBeVisible();
assertBrowserClean();
await context.close();
});
test("anonymous content-removal confirmation opens from the protected email fragment", async ({
browser,
request,
}, testInfo) => {
const email = projectEmail("anonymous-removal", testInfo.project.name);
const context = await newIsolatedContext(browser);
const page = await context.newPage();
const assertBrowserClean = captureBrowserFailures(page);
const previousMessageID = await latestMessageID(request, email);
await page.goto("/legal/content-removal");
await page.getByLabel("Reason").selectOption("privacy_violation");
await page
.getByLabel("Your name or organisation")
.fill("Anonymous removal E2E");
await page.getByLabel("Contact email").fill(email);
await page
.getByLabel("Your relationship to the affected person or rights holder")
.selectOption("self");
await page
.getByLabel("Exact content URLs — one per line")
.fill(`${process.env.BASE_URL}/requests/anonymous-removal-e2e`);
await page
.getByLabel("Why do you believe this content should be removed?")
.fill("Browser E2E verifies explicit confirmation before legal review.");
await page
.getByLabel("Electronic signature (type your full name)")
.fill("Anonymous removal E2E");
await page
.getByLabel(/I believe in good faith that the identified content/)
.check();
await page
.getByLabel(/I confirm that this notice is accurate and complete/)
.check();
await page.getByRole("button", { name: "Submit removal notice" }).click();
await expect(page).toHaveURL(
/\/legal\/content-removal\/received\?reference=REM-/,
);
const confirmationLink = await waitForApplicationEmailLink(
request,
email,
"/legal/content-removal/",
previousMessageID,
);
const confirmationURL = new URL(confirmationLink);
expect(confirmationURL.pathname).toMatch(
/\/legal\/content-removal\/[0-9a-f-]+\/verify$/,
);
expect(confirmationURL.search).toBe("");
expect(confirmationURL.hash).toMatch(/^#token=SFMyNTY\./);
await page.goto(confirmationLink);
const confirmationForm = page.locator(
"#content-removal-confirmation-fragment-form",
);
await expect(confirmationForm).toBeVisible();
await expect(
page.locator("#content-removal-confirmation-fragment-invalid"),
).toBeHidden();
await expect(
page.locator("#content-removal-confirmation-fragment-token"),
).toHaveValue(/^SFMyNTY\./);
await confirmationForm
.getByRole("button", { name: "Confirm content-removal notice" })
.click();
await expect(page).toHaveURL(
/\/legal\/content-removal\/[0-9a-f-]+\?token=/,
);
await expect(
page.getByText("Your email was confirmed and the notice was sent for review."),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Content-removal notice" }),
).toBeVisible();
assertBrowserClean();
await context.close();
});
test("authenticated support and legal notices reach the scoped staff queues", async ({
browser,
request,
}, testInfo) => {
const projectName = testInfo.project.name;
const requesterEmail = projectEmail("requester", projectName);
const fixturePassword = process.env.E2E_FIXTURE_PASSWORD;
const adminEmail = process.env.E2E_ADMIN_EMAIL ?? "e2e-admin@example.invalid";
const supportSubject = `E2E support delivery [${projectName}]`;
const supportDetails =
"Browser E2E verifies the authenticated private case without a duplicate receipt email.";
const supportReply = `E2E support reply [${projectName}]`;
const supportFollowUp = `E2E support inbox follow-up [${projectName}]`;
const privacySubject = `E2E privacy request [${projectName}]`;
const dataExportSubject = `E2E data export [${projectName}]`;
const accountDeletionSubject = "Delete my Who Need Help account";
const generalExplanation =
"Browser E2E verifies that a signed-in general removal notice reaches the legal queue.";
const urgentExplanation =
"Browser E2E verifies the urgent workflow without reproducing or uploading any material.";
const legalResolution = `E2E legal review completed [${projectName}]`;
const requester = fixturePassword
? await loginWithPassword(browser, requesterEmail, fixturePassword)
: await registerAndConfirm(
browser,
request,
requesterEmail,
"E2E Support Requester",
);
const admin = fixturePassword
? await loginWithPassword(browser, adminEmail, fixturePassword)
: await loginWithMagicLink(browser, request, adminEmail);
const assertRequesterClean = captureBrowserFailures(requester.page);
const assertAdminClean = captureBrowserFailures(admin.page);
const supportEmailBefore = await latestMessageID(request, requesterEmail);
await requester.page.goto("/support");
await requester.page
.getByLabel("What do you need help with?")
.selectOption("technical_issue");
await expect(requester.page.getByLabel("Contact email")).toHaveValue(
requesterEmail,
);
await expect(requester.page.getByLabel("Contact email")).toHaveAttribute(
"readonly",
"",
);
await requester.page.getByLabel("Subject").fill(supportSubject);
await requester.page.getByLabel("Describe the problem").fill(supportDetails);
await requester.page
.getByRole("button", { name: "Send support request" })
.click();
await expect(requester.page).toHaveURL(/\/support\/received\?reference=SUP-/);
await expect(
requester.page.getByRole("heading", { name: "Support request created" }),
).toBeVisible();
await requester.page.waitForTimeout(1_000);
expect(await latestMessageID(request, requesterEmail)).toBe(
supportEmailBefore,
);
await gotoLiveView(admin.page, "/support/operations?queue=support");
await admin.page
.locator("#support-case-filters")
.getByLabel("Search")
.fill(supportSubject);
const supportRow = admin.page
.locator("main tbody tr")
.filter({ hasText: supportSubject });
await expect(supportRow).toHaveCount(1);
await supportRow.getByRole("link", { name: "Open" }).click();
await expect(
admin.page.getByRole("heading", { name: supportSubject }),
).toBeVisible();
await admin.page.getByLabel("Case status").selectOption("resolved");
await admin.page
.getByLabel("Reply to requester (optional)")
.fill(supportReply);
const replyEmailBefore = await latestMessageID(request, requesterEmail);
await admin.page.getByRole("button", { name: "Save and notify" }).click();
await expect(admin.page.getByText("Support request updated.")).toBeVisible();
await waitForNewEmail(
request,
requesterEmail,
replyEmailBefore,
"Status: resolved",
);
const followUpEmailBefore = await latestMessageID(request, requesterEmail);
await admin.page
.getByLabel("Reply to requester (optional)")
.fill(supportFollowUp);
await admin.page.getByRole("button", { name: "Save and notify" }).click();
await expect(admin.page.getByText("Support request updated.")).toBeVisible();
await admin.page.waitForTimeout(1_000);
expect(await latestMessageID(request, requesterEmail)).toBe(
followUpEmailBefore,
);
await requester.page.goto("/support/requests");
const requesterCase = requester.page
.locator("main")
.getByRole("link")
.filter({ hasText: supportSubject });
await expect(requesterCase).toHaveCount(1);
await requesterCase.click();
await expect(
requester.page.getByText(supportReply, { exact: true }),
).toBeVisible();
await expect(
requester.page.getByText(supportFollowUp, { exact: true }),
).toBeVisible();
await submitAuthenticatedSupportRequest(
requester.page,
requesterEmail,
"privacy_request",
privacySubject,
"Browser E2E verifies that an authenticated privacy request remains private and reaches the scoped support queue.",
);
await submitAuthenticatedSupportRequest(
requester.page,
requesterEmail,
"data_export",
dataExportSubject,
"Browser E2E verifies that an authenticated data-export request reaches the scoped support queue.",
);
await requester.page.goto("/account/delete");
await expect(requester.page.getByLabel("Account email")).toHaveValue(
requesterEmail,
);
await expect(requester.page.getByLabel("Account email")).toHaveAttribute(
"readonly",
"",
);
await requester.page
.getByLabel("Additional information")
.fill(
"Browser E2E verifies that account deletion enters the dedicated support workflow without deleting the fixture account immediately.",
);
await requester.page
.getByRole("button", { name: "Request account deletion" })
.click();
await expect(requester.page).toHaveURL(/\/support\/received\?reference=SUP-/);
await expect(
requester.page.getByRole("heading", { name: "Support request created" }),
).toBeVisible();
await gotoLiveView(admin.page, "/support/operations?queue=support");
await admin.page
.locator("#support-case-filters")
.getByLabel("Search")
.fill(requesterEmail);
const requesterSupportRows = admin.page.locator("main tbody tr");
await expect(requesterSupportRows).toHaveCount(4);
await expect(
requesterSupportRows.filter({ hasText: privacySubject }),
).toHaveCount(1);
await expect(
requesterSupportRows.filter({ hasText: dataExportSubject }),
).toHaveCount(1);
await expect(
requesterSupportRows.filter({ hasText: accountDeletionSubject }),
).toHaveCount(1);
await requester.page.goto("/support/requests");
await expect(
requester.page.getByRole("link").filter({ hasText: privacySubject }),
).toHaveCount(1);
await expect(
requester.page.getByRole("link").filter({ hasText: dataExportSubject }),
).toHaveCount(1);
await expect(
requester.page.getByRole("link").filter({ hasText: accountDeletionSubject }),
).toHaveCount(1);
const generalEmailBefore = await latestMessageID(request, requesterEmail);
await requester.page.goto("/legal/content-removal");
await requester.page.getByLabel("Reason").selectOption("privacy_violation");
await requester.page
.getByLabel("Your name or organisation")
.fill("E2E Requester");
await requester.page
.getByLabel("Your relationship to the affected person or rights holder")
.selectOption("self");
await requester.page
.getByLabel("Exact content URLs — one per line")
.fill(`${process.env.BASE_URL}/requests/e2e-reported-content`);
await requester.page
.getByLabel("Why do you believe this content should be removed?")
.fill(generalExplanation);
await requester.page
.getByLabel("Law, right, or policy involved, if known")
.fill("Privacy review requested by the affected account holder.");
await requester.page
.getByLabel("Electronic signature (type your full name)")
.fill("E2E Requester");
await requester.page
.getByLabel(/I believe in good faith that the identified content/)
.check();
await requester.page
.getByLabel(/I confirm that this notice is accurate and complete/)
.check();
await requester.page
.getByRole("button", { name: "Submit removal notice" })
.click();
await expect(requester.page).toHaveURL(
/\/legal\/content-removal\/received\?reference=REM-/,
);
await waitForNewEmail(
request,
requesterEmail,
generalEmailBefore,
"Status: open",
);
const urgentEmailBefore = await latestMessageID(request, requesterEmail);
await requester.page.goto("/legal/take-it-down");
await requester.page
.getByLabel("Material involved")
.selectOption("non_consensual_intimate_media");
await requester.page
.getByRole("textbox", { name: "Your full name", exact: true })
.fill("E2E Requester");
await requester.page
.getByLabel("Who are you submitting for?")
.selectOption("self");
await requester.page
.getByLabel("Exact content URLs — one per line")
.fill(`${process.env.BASE_URL}/requests/e2e-urgent-reported-content`);
await requester.page
.getByLabel(/Identify the material without reproducing it/)
.fill(urgentExplanation);
await requester.page
.getByLabel("Electronic signature (type your full name)")
.fill("E2E Requester");
await requester.page
.getByLabel(/I have a good-faith belief that this intimate visual material/)
.check();
await requester.page
.getByLabel(/I confirm that the information in this request is accurate/)
.check();
await requester.page
.getByRole("button", { name: "Submit urgent removal request" })
.click();
await expect(requester.page).toHaveURL(
/\/legal\/content-removal\/received\?reference=REM-/,
);
await waitForNewEmail(
request,
requesterEmail,
urgentEmailBefore,
"Status: urgent_review",
);
await gotoLiveView(admin.page, "/support/operations?queue=legal");
await admin.page
.locator("#legal-case-filters")
.getByLabel("Search")
.fill(requesterEmail);
await expect(admin.page.locator("main tbody tr")).toHaveCount(2);
const urgentRow = admin.page
.locator("main tbody tr")
.filter({ hasText: "take it down" });
await expect(urgentRow).toHaveCount(1);
await urgentRow.getByRole("link", { name: "Open" }).click();
await expect(
admin.page.getByText(urgentExplanation, { exact: true }),
).toBeVisible();
await admin.page.getByLabel("Case status").selectOption("actioned");
await admin.page
.getByLabel("Decision or information request")
.fill(legalResolution);
const legalDecisionEmailBefore = await latestMessageID(
request,
requesterEmail,
);
await admin.page.getByRole("button", { name: "Save and notify" }).click();
await expect(admin.page.getByText("Removal notice updated.")).toBeVisible();
await waitForNewEmail(
request,
requesterEmail,
legalDecisionEmailBefore,
legalResolution,
);
assertRequesterClean();
assertAdminClean();
await requester.context.close();
await admin.context.close();
});