Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions e2e/cli/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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([]);
});
});
35 changes: 35 additions & 0 deletions src/utils/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
23 changes: 17 additions & 6 deletions src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,14 +418,17 @@ export async function findSession(): Promise<LogoutHandle | null> {
* 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.
*/
Expand All @@ -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;
}
Expand Down