diff --git a/e2e/cli/session.test.ts b/e2e/cli/session.test.ts index a275b0e..f3b5adf 100644 --- a/e2e/cli/session.test.ts +++ b/e2e/cli/session.test.ts @@ -24,6 +24,7 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { createTestSession } from "@parity/product-sdk-terminal/testing"; import { dot } from "./helpers/dot.js"; import { fixturePath } from "./fixtures/templates.js"; @@ -103,4 +104,36 @@ describe("session management", () => { // console.log(" No account is signed in.\n"); expect(result.stdout).toContain("No account is signed in"); }); + + test("logout clears local session files left by a previous login", async () => { + // Synthesize a session on disk as if QR pairing had completed. + // `createTestSession` is a "dev utility, not a stable contract" per the + // SDK — it hand-rolls the on-disk SCALE codec — so a minor bump of + // `@parity/product-sdk-terminal` could break this test if the format + // drifts. The SDK's own `testing.interop.test.ts` round-trip catches + // that upstream first. + const storageDir = join(tempHome, ".polkadot-apps"); + await createTestSession({ appId: "dot-cli", storageDir }); + const before = getSessionFiles(storageDir); + expect(before.length, "createTestSession should write at least one dot-cli_* file").toBeGreaterThan(0); + + // `waitForLogout` runs `clearLocalAppStorage()` on both the success and + // failure paths of `adapter.sessions.disconnect()` — so this test + // passes regardless of whether the disconnect statement actually + // round-trips on the testnet. The synthesized session has no real + // mobile peer, so the disconnect call's behaviour is implementation + // defined (statement-store may accept it as a fire-and-forget, + // or reject if Bulletin allowance is missing). What we're locking + // in here is the local-cleanup invariant: after a clean logout no + // `${DAPP_ID}_*` files remain in `~/.polkadot-apps/`, regardless of + // whether the user's phone is reachable. + const result = await dot(["logout"], { home: tempHome, timeout: 90_000 }); + expect( + result.exitCode, + `logout exited non-zero: ${result.stdout}\n${result.stderr}`, + ).toBe(0); + + const after = getSessionFiles(storageDir); + expect(after, "logout should remove all dot-cli_* files").toEqual([]); + }); }); diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts index ec7111e..7f9e9e7 100644 --- a/src/utils/auth.test.ts +++ b/src/utils/auth.test.ts @@ -220,6 +220,41 @@ describe("waitForLogout", () => { expect(adapter.destroyCalls).toBe(1); }); + it("clears local DAPP_ID files on the happy path too, not just the failure path", async () => { + // Regression catcher: the SDK's `disconnect()` filters the session + // out of its in-memory list and writes the (now-empty) list back to + // `${DAPP_ID}_SsoSessions.json` — but doesn't unlink the file. Before + // this fix, the happy path returned `success` with the empty file + // still on disk, so `~/.polkadot-apps/` accumulated leftovers across + // login → logout cycles. We now run clearLocalAppStorage() on success + // too, so the file is gone after a clean logout. + const staleDir = join(appsDir, ".polkadot-apps"); + const { mkdirSync } = await import("node:fs"); + mkdirSync(staleDir, { recursive: true }); + const sessionsFile = join(staleDir, `${DAPP_ID}_SsoSessions.json`); + const secretsFile = join(staleDir, `${DAPP_ID}_UserSecrets_abc.json`); + const foreignFile = join(staleDir, "other-app_SsoSessions.json"); + writeFileSync(sessionsFile, "[]"); + writeFileSync(secretsFile, "{}"); + writeFileSync(foreignFile, "leave-me-alone"); + + const adapter = fakeAdapter(() => Promise.resolve(okResult(undefined))); + const handle = { + adapter, + address: "5Gxyz", + session: fakeSession(), + } as unknown as LogoutHandle; + const events: LogoutStatus[] = []; + + await waitForLogout(handle, (s) => events.push(s)); + + expect(events.at(-1)).toEqual({ step: "success", address: "5Gxyz" }); + expect(existsSync(sessionsFile)).toBe(false); + expect(existsSync(secretsFile)).toBe(false); + // Foreign app's files MUST remain untouched. + expect(existsSync(foreignFile)).toBe(true); + }); + it("falls back to local clear and emits partial when disconnect returns err", async () => { // Seed a stale session file so we can verify the fallback actually deletes it. const staleDir = join(appsDir, ".polkadot-apps"); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index c05cc98..139cf0a 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -418,14 +418,17 @@ export async function findSession(): Promise { * Disconnect the given session. Reports progress via callback. * * Happy path: `adapter.sessions.disconnect()` sends a `Disconnected` statement - * so the paired mobile app drops its side of the connection, then clears the - * local session + user-secret files. + * so the paired mobile app drops its side of the connection, then we run + * `clearLocalAppStorage()` to unlink the `${DAPP_ID}_*` files. The SDK's + * `disconnect()` itself only filters the session out of the in-memory list + * and writes the (possibly-empty) list back to disk — without the explicit + * cleanup the SsoSessions file would linger as `[]`. * * If the remote notification fails (statement store unreachable, WebSocket - * torn down, …) we fall back to deleting the `${DAPP_ID}_*` files in - * `~/.polkadot-apps/` directly — strictly narrower than `rm -rf ~/.polkadot-apps` - * and keeps the user unblocked. The mobile app will show a stale pairing - * until it reconnects, which we surface via `partial`. + * torn down, …) we still run `clearLocalAppStorage()` — strictly narrower + * than `rm -rf ~/.polkadot-apps` and keeps the user unblocked. The mobile + * app will show a stale pairing until it reconnects, which we surface via + * `partial`. * * Always releases the adapter before returning. */ @@ -442,6 +445,14 @@ export async function waitForLogout( onStatus({ step: "disconnecting", address }); const result = await adapter.sessions.disconnect(session); if (result.isOk()) { + // Run the local cleanup pass on success too. The SDK's + // `disconnect()` filters the session out of `ssoSessionRepository` + // and writes the (now-empty) list back to disk, but it doesn't + // unlink the file — so `${DAPP_ID}_SsoSessions.json` lingers as + // `[]` on the filesystem. `clearLocalAppStorage()` removes it + // outright so `~/.polkadot-apps/` ends up tidy regardless of + // whether the mobile notification round-tripped. + await clearLocalAppStorage(); onStatus({ step: "success", address }); return; }