From bbad7326120802f7e4d8718a87de1cab540bfc8f Mon Sep 17 00:00:00 2001 From: Chris van der Loo Date: Sat, 5 Sep 2026 16:34:39 -0400 Subject: [PATCH] test: stop safely on rejected manual PDS sign-in --- playwright.config.ts | 3 +++ tests/browser/live-pds.pw.ts | 21 ++++++++++---------- tests/browser/manual-login.test.ts | 31 +++++++++++++++++++++++++++++ tests/browser/manual-login.ts | 32 ++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 tests/browser/manual-login.test.ts create mode 100644 tests/browser/manual-login.ts diff --git a/playwright.config.ts b/playwright.config.ts index 6575733..3d1cc35 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,8 @@ import { defineConfig, devices } from "@playwright/test" +// Playwright's failure-page snapshots are independent of screenshot and trace settings. +if (process.env.CRATE_LIVE_PDS === "1") process.env.PLAYWRIGHT_NO_COPY_PROMPT = "1" + export default defineConfig({ testDir: "./tests/browser", testMatch: "**/*.pw.ts", diff --git a/tests/browser/live-pds.pw.ts b/tests/browser/live-pds.pw.ts index d0044fb..3d3c42a 100644 --- a/tests/browser/live-pds.pw.ts +++ b/tests/browser/live-pds.pw.ts @@ -1,23 +1,22 @@ -import { errors, expect as baseExpect, test, type Page } from "@playwright/test" +import { expect as baseExpect, test, type Page } from "@playwright/test" +import { waitForManualSignIn } from "./manual-login" const expect = baseExpect.configure({ timeout: 30_000 }) test.use({ trace: "off", screenshot: "off", video: "off" }) -test("user-authorized private PDS file lifecycle", async ({ page }) => { +test("user-authorized private PDS file lifecycle", async ({ page, baseURL }) => { test.skip(process.env.CRATE_LIVE_PDS !== "1", "Requires the user's interactive PDS sign-in") test.setTimeout(15 * 60_000) await page.goto("/login") console.log("Please sign in in the browser window; credentials are not recorded.") - try { - await page.getByRole("heading", { name: "Files", exact: true }).waitFor({ - state: "visible", - timeout: 10 * 60_000, - }) - } catch (error) { - if (!(error instanceof errors.TimeoutError)) throw error - test.skip(true, "Manual sign-in was not completed; no file operations ran") - } + const outcome = await waitForManualSignIn(page, new URL(baseURL!).origin, 10 * 60_000) + test.skip( + outcome.status === "timeout", + "Manual sign-in was not completed; no file operations ran", + ) + if (outcome.status === "rejected") throw new Error(outcome.message) + await expect(page.getByRole("heading", { name: "Files", exact: true })).toBeVisible() await expect(page.getByText("Loading files…", { exact: true })).toBeHidden() await expect(page.getByRole("alert")).toHaveCount(0) diff --git a/tests/browser/manual-login.test.ts b/tests/browser/manual-login.test.ts new file mode 100644 index 0000000..e259f5e --- /dev/null +++ b/tests/browser/manual-login.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" +import { signInOutcome } from "./manual-login" + +const origin = "http://127.0.0.1:5173" + +describe("manual PDS sign-in observation", () => { + it("accepts only the application's file page", () => { + expect(signInOutcome(`${origin}/files`, origin)).toEqual({ status: "ready" }) + for (const address of ["about:blank", `${origin}/login`, "https://pds.example/files"]) { + expect(signInOutcome(address, origin)).toEqual({ status: "pending" }) + } + }) + + it("does not treat a success callback as a restored session", () => { + expect( + signInOutcome("http://127.0.0.1:3030/oauth/callback?code=secret&state=secret", origin), + ).toEqual({ status: "pending" }) + }) + + it.each(["invalid_scope", "access_denied", "unrecognized-secret"])( + "reports %s without copying provider parameters or the callback URL", + (code) => { + const result = signInOutcome( + `http://127.0.0.1:3030/oauth/callback?error=${code}&state=secret&error_description=secret`, + origin, + ) + expect(result.status).toBe("rejected") + expect(JSON.stringify(result)).not.toMatch(/secret|http|callback/) + }, + ) +}) diff --git a/tests/browser/manual-login.ts b/tests/browser/manual-login.ts new file mode 100644 index 0000000..95a8c2c --- /dev/null +++ b/tests/browser/manual-login.ts @@ -0,0 +1,32 @@ +import { setTimeout } from "node:timers/promises" +import type { Page } from "@playwright/test" + +type Outcome = { status: "pending" | "ready" | "timeout" } | { status: "rejected"; message: string } + +export function signInOutcome(address: string, appOrigin: string): Outcome { + const location = new URL(address) + if (location.pathname === "/oauth/callback" && location.searchParams.has("error")) { + const code = location.searchParams.get("error") + const message = + code === "invalid_scope" + ? "The PDS rejected the requested permissions. Check the space declaration and OAuth scopes." + : code === "access_denied" + ? "The PDS sign-in was denied." + : "The PDS rejected the sign-in request." + return { status: "rejected", message } + } + return { + status: location.origin === appOrigin && location.pathname === "/files" ? "ready" : "pending", + } +} + +export async function waitForManualSignIn(page: Page, appOrigin: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs + while (!page.isClosed() && Date.now() < deadline) { + const result = signInOutcome(page.url(), appOrigin) + if (result.status !== "pending") return result + // Do not leave a locator pending on the provider page: cancellation logs its full URL. + await setTimeout(250) + } + return { status: "timeout" } as const +} -- 2.54.0