import { type BrowserContext, expect, test } from "@playwright/test"; import { readFileSync } from "node:fs"; import { type Account, authFile, config, seeded } from "../harness/config"; import { bobbin, component, type Ctx, flow, hydrant, knots, loginThen, page, pds, starId, target, testid, through, where, within } from "../harness/flow"; import { parseBody, stamp } from "../harness/net"; import { signInHere } from "../harness/signin"; import { forgetTheLogin, recall, remember } from "../harness/shared"; const menuOf = (who: (ctx: Ctx) => Account) => target("the account menu", (tab, ctx) => tab.getByRole("banner").getByRole("button", { name: who(ctx).handle }) ); const actorMenu = menuOf((ctx) => ctx.actor); const logout = testid("signOut"); const otherOf = (ctx: Ctx) => recall(ctx, "other"); const signInFromHere = (who: (ctx: Ctx) => Account) => (ctx: Ctx) => signInHere(ctx.page, who(ctx)); const signInAsActor = signInFromHere((ctx) => ctx.actor); const browserState = (ctx: Ctx) => ctx.page.evaluate(() => ({ current: localStorage.getItem("tangled.currentDid"), sessions: localStorage.getItem("atcute-oauth:sessions") ?? "", accounts: localStorage.getItem("tangled.accounts") ?? "" })); // dids become "did" so two logins compare equal const dropDids = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(dropDids); if (!value || typeof value !== "object") return typeof value; return Object.fromEntries( Object.entries(value).map(([key, inner]) => [ key.startsWith("did:") ? "did" : key, dropDids(inner) ]) ); }; const kept = (state: Awaited>) => ({ cookies: state.cookies .map( (c) => `${c.domain} ${c.name} ${c.httpOnly} ${c.secure} ${c.sameSite} ${c.expires === -1}` ) .sort(), storage: state.origins.map(({ origin, localStorage }) => ({ origin, items: Object.fromEntries( localStorage.map(({ name, value }) => [name, dropDids(parseBody(value))]) ) })) }); const keptLikeALogin = async (ctx: Ctx) => { const login = JSON.parse(readFileSync(authFile(seeded.alice), "utf8")); expect(kept(await ctx.page.context().storageState())).toEqual(kept(login)); }; const firstStar = (name: string, options: { login?: "ui" } = {}) => flow(name) .actor("owner") .repo() .actor("fan", options) .open(page.repo) .sees(actorMenu) .step("the browser keeps the session the way a login does", keptLikeALogin) .click(testid("star", { starred: "false" })) .sends( starId, pds, (ctx) => ({ record: { subject: { identity: ctx.repo.did } } }), "create" ) .trace(starId, through(pds, hydrant, bobbin)) .reload() .sees(testid("star", { starred: "true" })); firstStar("a new account logged in through the login page stars a repo", { login: "ui" }); firstStar("a new account with a session minted from node stars a repo"); flow("logging out of one of two accounts in a browser keeps the other signed in") .actor("second") .step("remember the second account", remember("other")) .actor("first", { login: "ui" }) .open(knots) .click(actorMenu) .click(testid("addAccount")) .at(loginThen(() => "/settings/knots")) .step("the second account signs in beside the first", signInFromHere(otherOf)) .at(knots) .sees(menuOf(otherOf)) .click(menuOf(otherOf)) .sees(logout) .click(logout) .sees(actorMenu) .step("the browser keeps the first account and has let go of the second", async (ctx) => { const saw = await browserState(ctx); expect(saw.current).toBe(ctx.actor.did); expect(saw.sessions).toContain(ctx.actor.did); expect(saw.sessions).not.toContain(otherOf(ctx).did); expect(saw.accounts).not.toContain(otherOf(ctx).did); }) .reload() .sees(actorMenu) .open(knots) .at(knots); flow("logging out ends the session and a settings page asks for a login again") .actor("leaver", { login: "ui" }) .open(page.profile) .step("the page is signed in", async (ctx) => { expect((await browserState(ctx)).current).toBe(ctx.actor.did); }) .click(actorMenu) .click(logout) .sees(within(component("Topbar"), testid("login"))) .step("the browser has forgotten the account", async (ctx) => { const saw = await browserState(ctx); expect(saw.current).toBeNull(); expect(saw.sessions).not.toContain(ctx.actor.did); const cookies = await ctx.page.context().cookies(); expect(cookies.find((cookie) => cookie.name === "tangled.currentDid")).toBeUndefined(); }) .open(knots) .at(loginThen(() => "/settings/knots")); flow("switching to the other account in the menu changes who the page is signed in as") .actor("second") .step("remember the second account", remember("other")) .actor("first", { login: "ui" }) .open(knots) .click(actorMenu) .click(testid("addAccount")) .step("the second account signs in beside the first", signInFromHere(otherOf)) .at(knots) .sees(menuOf(otherOf)) .click(menuOf(otherOf)) .click( target("the first account in the menu", (tab, ctx) => tab.getByRole("menuitem").filter({ hasText: ctx.actor.handle }) ) ) .sees(actorMenu) .step( "the browser says the first account is the one in use and still has both", async (ctx) => { const saw = await browserState(ctx); expect(saw.current).toBe(ctx.actor.did); expect(saw.sessions).toContain(otherOf(ctx).did); const cookies = await ctx.page.context().cookies(); expect(cookies.find((cookie) => cookie.name === "tangled.currentDid")?.value).toContain( ctx.actor.did.replace(/:/g, "%3A") ); } ) .reload() .sees(actorMenu); const followeeProfile = where("the followee's profile", (ctx) => `/${otherOf(ctx).handle}`); flow("a visitor who clicks Follow is sent to log in and comes back to that profile") .actor("star") .step("remember who is followed", remember("other")) .actor("fan", { login: "ui" }) .open(followeeProfile) .step("signs out", forgetTheLogin) .open(followeeProfile) .click(testid("follow", { following: "false" })) .at(loginThen((ctx) => `/${otherOf(ctx).handle}`)) .step("signs back in", signInAsActor) .at(followeeProfile) .sees(testid("follow", { following: "false" })); flow("a visitor who clicks Star is sent to log in and comes back to that repo") .actor("owner") .repo() .actor("reader", { login: "ui" }) .open(page.repo) .step("signs out", forgetTheLogin) .open(page.repo) .click(testid("star", { starred: "false" })) .at(loginThen((ctx) => ctx.repo.path)) .step("signs back in", signInAsActor) .at(page.repo) .sees(testid("star", { starred: "false" })); flow("a visitor who opens a repo's settings is sent to log in and comes back to them") .actor("owner", { login: "ui" }) .repo() .open(page.repo) .step("signs out", forgetTheLogin) .open(page.settings) .at(loginThen((ctx) => `${ctx.repo.path}/settings`)) .step("signs back in", signInAsActor) .at(page.settings) .sees(testid("settingsSectionTitle", { name: "danger" })); const knotsWithQuery = where("the knots settings with a query", () => "/settings/knots?from=email"); flow("a visitor who opens the knot settings is sent to log in and comes back to them") .actor("reader", { login: "ui" }) .open(knots) .step("signs out", forgetTheLogin) .open(knotsWithQuery) .at(loginThen(() => "/settings/knots?from=email")) .step("signs back in", signInAsActor) .at(knotsWithQuery) .sees(testid("settingsHeaderTitle", { name: "knots" })); const inviteHost = "knot.invited.example.test"; const invite = where("the invite page", (ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`); const codes = new WeakMap(); // the page only reads the code, so a made up one is fine until Join const pickCode = async (ctx: Ctx) => { codes.set(ctx, `e2e-${stamp()}`); }; const code = (ctx: Ctx) => codes.get(ctx)!; flow("an invite link opened signed out logs you in and lands on the join page") .actor("guest", { login: "ui" }) .step("pick a code", pickCode) .open(page.profile) .step("signs out", forgetTheLogin) .open(invite) .at(loginThen((ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`)) .step("signs in", signInAsActor) .at(invite) .sees(testid("joinHeading", { knot: inviteHost })) .sees(testid("join")); test("the login page shows an error when it is opened with one", async ({ page: tab }) => { await tab.goto("/login?error=session", { waitUntil: "networkidle" }); await expect(tab.locator('[data-testid="loginErrorHeading"]')).toBeVisible(); }); test("a handle that does not exist is told so on the login page and stays there", async ({ page: tab }) => { await tab.goto("/login", { waitUntil: "networkidle" }); const handle = tab.getByTestId("identifier"); await handle.fill(`nobody-${stamp()}.pds.tngl.boltless.dev`); await handle.press("Enter"); await expect(tab.locator('[data-testid="loginErrorHeading"]')).toBeVisible(); await expect(tab.locator('[data-testid="loginErrorMessage"]')).toBeVisible(); await expect(tab).toHaveURL(`${config.webUrl}/login`); }); const guarded = [ "/welcome", "/strings/new", "/organizations/new", "/settings/profile/delete?step=1", "/settings/keys/new" ]; for (const path of guarded) { test(`a visitor opening ${path} is sent to log in with that page to come back to`, async ({ page: tab }) => { await tab.goto(path, { waitUntil: "networkidle" }); await expect(tab).toHaveURL( `${config.webUrl}/login?return_url=${encodeURIComponent(path)}` ); }); }