From f07a8d7f834199fff61db8a232ffd06109a953d0 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 26 Aug 2026 14:53:13 +0200 Subject: [PATCH 1/6] test(fe): run the session-ending scenarios in a browser --- .../app-sessions/ending-a-session.spec.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts diff --git a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts new file mode 100644 index 0000000000..8315c064c7 --- /dev/null +++ b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts @@ -0,0 +1,158 @@ +import { expect } from "@playwright/test"; +import { test } from "../../../fixtures"; +import { TEST_APP_CANONICAL_URL } from "../../../utils"; +import { + continueAs, + openSettings, + SESSION_SIGN_IN, + signInAsFirstIdentity, +} from "./helpers"; + +/** + * Access that can be ended is the point of the design, so these are the scenarios + * about ending it: by the app signing out, and by the identity's owner signing a + * whole browser out from settings. Ending it reaches an app that is running, + * leaves nothing to come back from, and touches nothing else. + * + * Runs the "Ending a session" scenarios of + * `docs/ongoing/session-test-scenarios.md` — EXIT-1, EXIT-3, EXIT-5 and EXIT-6 — + * and the silent re-issue that has nothing left to answer from. + */ +test.describe("ending a session", () => { + test.use({ authorizeConfig: SESSION_SIGN_IN }); + + test.describe("signing out leaves nothing behind, across a reload", () => { + test.afterEach(async ({ signedInApp }) => { + await signedInApp.signOut(); + await expect(signedInApp.state).toHaveText("no session"); + + await signedInApp.reload(); + await expect(signedInApp.state).toHaveText("no session"); + }); + + test("picks an identity and continues", signInAsFirstIdentity); + }); + + test.describe("a silent re-issue with nothing to answer from fails one way", () => { + test.afterEach(async ({ signedInApp }) => { + await signedInApp.signOut(); + await expect(signedInApp.state).toHaveText("no session"); + + await signedInApp.silentReauth(); + + // FAIL-1 and SIL-2: one outcome, reported without asking the user + // anything, and nothing created. Windows are not counted: the window + // transport opens a channel either way, and SIL-1 is about screens, which + // the redirect transport the silent design targets is what makes + // checkable. + await expect(signedInApp.log).toContainText("error", { timeout: 30_000 }); + await expect(signedInApp.state).toHaveText("no session"); + }); + + test("picks an identity and continues", signInAsFirstIdentity); + }); + + test.describe("signing the browser out from settings ends the app's access", () => { + test.afterEach( + async ({ signedInApp, context, identities, signInWithIdentity }) => { + const settings = await openSettings( + context, + identities[0].identityNumber, + signInWithIdentity, + ); + await settings + .getByRole("button", { name: "Sign out" }) + .first() + .click(); + await expect(settings.getByText("Signed out")).toBeVisible(); + await settings.close(); + + // END-5 allows the app to keep working until the delegation it holds + // expires, so nothing shows until one is due. It is the mint that then + // discovers the session is gone. + await signedInApp.focus(); + await signedInApp.ageDelegation(); + await signedInApp.replaceDelegation(); + + await expect(signedInApp.state).toHaveText("no session", { + timeout: 30_000, + }); + }, + ); + + test("picks an identity and continues", signInAsFirstIdentity); + }); + + // The two below sign in twice, which `authorizePage` does not do, so they drive + // the app themselves. + + test("signing out of one app leaves the other alone", async ({ + testApp, + openTestApp, + context, + identities, + signInWithIdentity, + }) => { + const authenticate = continueAs( + identities[0].identityNumber, + signInWithIdentity, + ); + await testApp.open(); + await testApp.signIn(authenticate); + + const other = openTestApp(await context.newPage()); + await other.open({ url: TEST_APP_CANONICAL_URL }); + await other.signIn(authenticate); + + await testApp.focus(); + await testApp.signOut(); + await expect(testApp.state).toHaveText("no session"); + + // Another origin is another account and another session. + await expect(other.state).toHaveText("signed in"); + await other.close(); + }); + + test("a browser signed out is still the same browser", async ({ + testApp, + context, + identities, + signInWithIdentity, + }) => { + const authenticate = continueAs( + identities[0].identityNumber, + signInWithIdentity, + ); + await testApp.open(); + await testApp.signIn(authenticate); + + const settings = await openSettings( + context, + identities[0].identityNumber, + signInWithIdentity, + ); + const listed = await settings + .getByRole("button", { name: "Sign out" }) + .count(); + await settings.getByRole("button", { name: "Sign out" }).first().click(); + await expect(settings.getByText("Signed out")).toBeVisible(); + + // DEV-18: the entry stays, so signing in again reuses it. + await testApp.focus(); + await testApp.signIn(authenticate); + + // The page showing the list shared the browser it signed out, so reading the + // list again means signing in again — which is the same browser, and + // therefore the same entry. + await settings.close(); + const listedAgain = await openSettings( + context, + identities[0].identityNumber, + signInWithIdentity, + ); + await expect( + listedAgain.getByRole("button", { name: "Sign out" }), + ).toHaveCount(listed); + await listedAgain.close(); + }); +}); From f44789b2e0936790ff600bd647aeb67dca24b162 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 26 Aug 2026 15:05:38 +0200 Subject: [PATCH 2/6] test(fe): ask the app what it holds, in the session-ending scenarios --- .../app-sessions/ending-a-session.spec.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts index 8315c064c7..7b29f7a6c4 100644 --- a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts +++ b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts @@ -24,10 +24,10 @@ test.describe("ending a session", () => { test.describe("signing out leaves nothing behind, across a reload", () => { test.afterEach(async ({ signedInApp }) => { await signedInApp.signOut(); - await expect(signedInApp.state).toHaveText("no session"); + await signedInApp.expectSignedOut(); await signedInApp.reload(); - await expect(signedInApp.state).toHaveText("no session"); + await signedInApp.expectSignedOut(); }); test("picks an identity and continues", signInAsFirstIdentity); @@ -36,7 +36,7 @@ test.describe("ending a session", () => { test.describe("a silent re-issue with nothing to answer from fails one way", () => { test.afterEach(async ({ signedInApp }) => { await signedInApp.signOut(); - await expect(signedInApp.state).toHaveText("no session"); + await signedInApp.expectSignedOut(); await signedInApp.silentReauth(); @@ -45,8 +45,7 @@ test.describe("ending a session", () => { // transport opens a channel either way, and SIL-1 is about screens, which // the redirect transport the silent design targets is what makes // checkable. - await expect(signedInApp.log).toContainText("error", { timeout: 30_000 }); - await expect(signedInApp.state).toHaveText("no session"); + await signedInApp.expectSilentReauthFailed(); }); test("picks an identity and continues", signInAsFirstIdentity); @@ -74,9 +73,7 @@ test.describe("ending a session", () => { await signedInApp.ageDelegation(); await signedInApp.replaceDelegation(); - await expect(signedInApp.state).toHaveText("no session", { - timeout: 30_000, - }); + await signedInApp.expectSignedOut(); }, ); @@ -106,10 +103,10 @@ test.describe("ending a session", () => { await testApp.focus(); await testApp.signOut(); - await expect(testApp.state).toHaveText("no session"); + await testApp.expectSignedOut(); // Another origin is another account and another session. - await expect(other.state).toHaveText("signed in"); + await other.waitUntilSignedIn(); await other.close(); }); From 98be925cd6d6d3ce12e3532ffe2df87408bda530 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 26 Aug 2026 15:35:27 +0200 Subject: [PATCH 3/6] test(fe): say the protocol in the ending-a-session scenarios --- .../authorize/app-sessions/ending-a-session.spec.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts index 7b29f7a6c4..a6f7e8d18f 100644 --- a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts +++ b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts @@ -1,12 +1,7 @@ import { expect } from "@playwright/test"; import { test } from "../../../fixtures"; import { TEST_APP_CANONICAL_URL } from "../../../utils"; -import { - continueAs, - openSettings, - SESSION_SIGN_IN, - signInAsFirstIdentity, -} from "./helpers"; +import { continueAs, openSettings, signInAsFirstIdentity } from "./helpers"; /** * Access that can be ended is the point of the design, so these are the scenarios @@ -19,7 +14,7 @@ import { * and the silent re-issue that has nothing left to answer from. */ test.describe("ending a session", () => { - test.use({ authorizeConfig: SESSION_SIGN_IN }); + test.use({ authorizeConfig: { protocol: "icrc25" } }); test.describe("signing out leaves nothing behind, across a reload", () => { test.afterEach(async ({ signedInApp }) => { From aeb6fd64e4e6b6b5e8c1c03bf2004892d9a0dbf9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 26 Aug 2026 15:52:21 +0200 Subject: [PATCH 4/6] test(fe): wait long enough for a browser sign-out to land --- .../app-sessions/ending-a-session.spec.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts index a6f7e8d18f..19c1132c57 100644 --- a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts +++ b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts @@ -1,7 +1,13 @@ import { expect } from "@playwright/test"; import { test } from "../../../fixtures"; import { TEST_APP_CANONICAL_URL } from "../../../utils"; -import { continueAs, openSettings, signInAsFirstIdentity } from "./helpers"; +import { + continueAs, + listedBrowsers, + openSettings, + signInAsFirstIdentity, + signOutFirstBrowser, +} from "./helpers"; /** * Access that can be ended is the point of the design, so these are the scenarios @@ -123,11 +129,8 @@ test.describe("ending a session", () => { identities[0].identityNumber, signInWithIdentity, ); - const listed = await settings - .getByRole("button", { name: "Sign out" }) - .count(); - await settings.getByRole("button", { name: "Sign out" }).first().click(); - await expect(settings.getByText("Signed out")).toBeVisible(); + const listed = await listedBrowsers(settings).count(); + await signOutFirstBrowser(settings); // DEV-18: the entry stays, so signing in again reuses it. await testApp.focus(); @@ -142,9 +145,7 @@ test.describe("ending a session", () => { identities[0].identityNumber, signInWithIdentity, ); - await expect( - listedAgain.getByRole("button", { name: "Sign out" }), - ).toHaveCount(listed); + await expect(listedBrowsers(listedAgain)).toHaveCount(listed); await listedAgain.close(); }); }); From f414632bfbfdb184dde74758c0119fff0cff55ae Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 02:52:55 +0200 Subject: [PATCH 5/6] test: answer the sign-out dialog Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../routes/authorize/app-sessions/ending-a-session.spec.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts index 19c1132c57..99605657bb 100644 --- a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts +++ b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts @@ -2,6 +2,7 @@ import { expect } from "@playwright/test"; import { test } from "../../../fixtures"; import { TEST_APP_CANONICAL_URL } from "../../../utils"; import { + confirmSignOut, continueAs, listedBrowsers, openSettings, @@ -60,10 +61,8 @@ test.describe("ending a session", () => { identities[0].identityNumber, signInWithIdentity, ); - await settings - .getByRole("button", { name: "Sign out" }) - .first() - .click(); + await listedBrowsers(settings).first().click(); + await confirmSignOut(settings); await expect(settings.getByText("Signed out")).toBeVisible(); await settings.close(); From 5da8ce2fc08764f7939bac24b20fc09cb7bf0ad1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 11 Sep 2026 18:05:35 +0200 Subject: [PATCH 6/6] test(fe): EXIT-3 covers both browsers, and several apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One app only proves one session was ended; what the browser-wide action claims is that it reaches every app that browser signed into. So two are signed in before it, and both are checked. It also splits in two, because the two cases are reached differently. The list offers no button for the browser reading it — signing out the browser in front of you goes through the identity's own sign-out, where forgetting is what revokes — so there is one scenario for this browser through that route, and one for another browser through the list, read from a second context where the entry does carry a button. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../app-sessions/ending-a-session.spec.ts | 159 ++++++++++++------ 1 file changed, 110 insertions(+), 49 deletions(-) diff --git a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts index 99605657bb..25343113ec 100644 --- a/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts +++ b/src/frontend/tests/e2e-playwright/routes/authorize/app-sessions/ending-a-session.spec.ts @@ -2,8 +2,8 @@ import { expect } from "@playwright/test"; import { test } from "../../../fixtures"; import { TEST_APP_CANONICAL_URL } from "../../../utils"; import { - confirmSignOut, continueAs, + forgetThisBrowser, listedBrowsers, openSettings, signInAsFirstIdentity, @@ -53,31 +53,84 @@ test.describe("ending a session", () => { test("picks an identity and continues", signInAsFirstIdentity); }); - test.describe("signing the browser out from settings ends the app's access", () => { - test.afterEach( - async ({ signedInApp, context, identities, signInWithIdentity }) => { - const settings = await openSettings( - context, - identities[0].identityNumber, - signInWithIdentity, - ); - await listedBrowsers(settings).first().click(); - await confirmSignOut(settings); - await expect(settings.getByText("Signed out")).toBeVisible(); - await settings.close(); - - // END-5 allows the app to keep working until the delegation it holds - // expires, so nothing shows until one is due. It is the mint that then - // discovers the session is gone. - await signedInApp.focus(); - await signedInApp.ageDelegation(); - await signedInApp.replaceDelegation(); - - await signedInApp.expectSignedOut(); - }, + test("signing this browser out ends every app it signed into", async ({ + testApp, + openTestApp, + context, + identities, + signInWithIdentity, + }) => { + const authenticate = continueAs( + identities[0].identityNumber, + signInWithIdentity, ); + // EXIT-3 says several apps, because one app only proves one session was + // ended: what the browser-wide action claims is that it reaches all of them. + await testApp.open(); + await testApp.signIn(authenticate); - test("picks an identity and continues", signInAsFirstIdentity); + const other = openTestApp(await context.newPage()); + await other.open({ url: TEST_APP_CANONICAL_URL }); + await other.signIn(authenticate); + + // This browser is the one that signed in, and a browser cannot sign itself + // out from the list — that row carries no button. Its route is the identity's + // own sign-out, where forgetting revokes rather than merely leaving. + const settings = await openSettings( + context, + identities[0].identityNumber, + signInWithIdentity, + ); + await forgetThisBrowser(settings); + await settings.close(); + + // END-5 allows an app to keep working until the delegation it holds expires, + // so nothing shows until one is due. It is the mint that then discovers the + // session is gone. + for (const app of [testApp, other]) { + await app.focus(); + await app.ageDelegation(); + await app.replaceDelegation(); + await app.expectSignedOut(); + } + await other.close(); + }); + + test("signing another browser out from the list ends its access alone", async ({ + testApp, + browser, + identities, + signInWithIdentity, + }) => { + const authenticate = continueAs( + identities[0].identityNumber, + signInWithIdentity, + ); + await testApp.open(); + await testApp.signIn(authenticate); + await testApp.waitUntilSignedIn(); + + // The other half of EXIT-3: the owner looking at their list from somewhere + // else, where the browser that signed in is another browser and does carry a + // button. + const onlooker = await browser.newContext({ ignoreHTTPSErrors: true }); + try { + const settings = await openSettings( + onlooker, + identities[0].identityNumber, + signInWithIdentity, + ); + await expect(listedBrowsers(settings)).toHaveCount(1); + await signOutFirstBrowser(settings); + await settings.close(); + } finally { + await onlooker.close(); + } + + await testApp.focus(); + await testApp.ageDelegation(); + await testApp.replaceDelegation(); + await testApp.expectSignedOut(); }); // The two below sign in twice, which `authorizePage` does not do, so they drive @@ -112,7 +165,7 @@ test.describe("ending a session", () => { test("a browser signed out is still the same browser", async ({ testApp, - context, + browser, identities, signInWithIdentity, }) => { @@ -122,29 +175,37 @@ test.describe("ending a session", () => { ); await testApp.open(); await testApp.signIn(authenticate); - - const settings = await openSettings( - context, - identities[0].identityNumber, - signInWithIdentity, - ); - const listed = await listedBrowsers(settings).count(); - await signOutFirstBrowser(settings); - - // DEV-18: the entry stays, so signing in again reuses it. - await testApp.focus(); - await testApp.signIn(authenticate); - - // The page showing the list shared the browser it signed out, so reading the - // list again means signing in again — which is the same browser, and - // therefore the same entry. - await settings.close(); - const listedAgain = await openSettings( - context, - identities[0].identityNumber, - signInWithIdentity, - ); - await expect(listedBrowsers(listedAgain)).toHaveCount(listed); - await listedAgain.close(); + await testApp.waitUntilSignedIn(); + + // Read and signed out from elsewhere, because the list offers no button for + // the browser reading it: signing out the browser in front of you goes + // through the identity's own sign-out instead. + const onlooker = await browser.newContext({ ignoreHTTPSErrors: true }); + try { + const settings = await openSettings( + onlooker, + identities[0].identityNumber, + signInWithIdentity, + ); + await expect(listedBrowsers(settings)).toHaveCount(1); + await signOutFirstBrowser(settings); + await settings.close(); + + // DEV-18: the entry stays, so signing in again reuses it rather than + // adding a second. + await testApp.focus(); + await testApp.signIn(authenticate); + await testApp.waitUntilSignedIn(); + + const again = await openSettings( + onlooker, + identities[0].identityNumber, + signInWithIdentity, + ); + await expect(listedBrowsers(again)).toHaveCount(1); + await again.close(); + } finally { + await onlooker.close(); + } }); });