diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index e74e37030..2c830ea8b 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -14,6 +14,7 @@ "lint": "npx @biomejs/biome lint --write ./src", "check-lint": "npx @biomejs/biome lint ./src", "tauri": "tauri", + "test": "vitest run", "storybook": "svelte-kit sync && storybook dev -p 6006", "build-storybook": "storybook build", "build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7", diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/evault.spec.ts b/infrastructure/eid-wallet/src/lib/global/controllers/evault.spec.ts new file mode 100644 index 000000000..dcf6f92db --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/global/controllers/evault.spec.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// evault.ts imports these at runtime; mock them so the module loads in a plain +// Node test env without pulling Tauri / wallet-sdk internals. +vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() })); +vi.mock("../../services/NotificationService", () => ({ + default: { + getInstance: () => ({ registerDevice: vi.fn(async () => true) }), + }, +})); + +import { VaultController } from "./evault"; + +const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" }; + +/** Build a VaultController whose only exercised dependency is `store.get`. */ +function makeController(get: (key: string) => Promise) { + const store = { get } as unknown as ConstructorParameters< + typeof VaultController + >[0]; + const userController = { + user: Promise.resolve(undefined), + } as unknown as ConstructorParameters[1]; + return new VaultController(store, userController); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("VaultController.vault — #readVaultResilient retry behavior", () => { + it("returns immediately for a resolved undefined (genuine logout), no retry", async () => { + const get = vi.fn(async () => undefined); + const vc = makeController(get); + + await expect(vc.vault).resolves.toBeUndefined(); + expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent + }); + + it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => { + vi.useFakeTimers(); + const get = vi.fn(async () => { + throw new Error( + "Fetch API cannot load ipc://localhost/plugin:store|get", + ); + }); + const vc = makeController(get); + + const result = vc.vault; + await vi.runAllTimersAsync(); // skip the 200ms backoffs + + await expect(result).resolves.toBeUndefined(); + expect(get).toHaveBeenCalledTimes(10); // maxAttempts + }); + + it("recovers the vault when the store IPC comes back mid-retry", async () => { + vi.useFakeTimers(); + let calls = 0; + const get = vi.fn(async () => { + if (calls++ < 6) throw new Error("IPC custom protocol failed"); + return VAULT; + }); + const vc = makeController(get); + + const result = vc.vault; + await vi.runAllTimersAsync(); + + await expect(result).resolves.toEqual(VAULT); + expect(get).toHaveBeenCalledTimes(7); + }); + + it("coalesces concurrent reads into a single in-flight store read", async () => { + const get = vi.fn(async () => VAULT); + const vc = makeController(get); + + const [a, b] = await Promise.all([vc.vault, vc.vault]); + + expect(a).toEqual(VAULT); + expect(b).toEqual(VAULT); + expect(get).toHaveBeenCalledTimes(1); // both callers shared one read + + // ...and a later, independent access performs a fresh read. + await vc.vault; + expect(get).toHaveBeenCalledTimes(2); + }); +}); diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts index 0dd611f80..244274d8f 100644 --- a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts +++ b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts @@ -580,19 +580,58 @@ export class VaultController { } } + #vaultReadInFlight: Promise | undefined> | null = + null; + get vault() { - return this.#store - .get>("vault") - .then((vault) => { - if (!vault) { + // Coalesce concurrent reads so the resume window doesn't spin one + // retry loop per caller; cleared on settle so later reads are fresh. + if (this.#vaultReadInFlight) return this.#vaultReadInFlight; + const read = this.#readVaultResilient().finally(() => { + this.#vaultReadInFlight = null; + }); + this.#vaultReadInFlight = read; + return read; + } + + /** + * Read the persisted vault, distinguishing "no vault" (resolved `undefined` + * → genuine logout) from a transient store IPC failure (a throw). + * + * On iOS the `ipc://localhost` protocol is briefly torn down after the app + * resumes from background, so `plugin:store|get` throws. The old getter + * swallowed that into `undefined` — indistinguishable from "never logged + * in" — so route guards redirected to /login: the intermittent sign-out. + * + * We retry on THROW so the resume window passes before reporting "no vault". + * A genuine logout resolves `undefined` (no throw) and returns immediately. + * Still never throws, still returns `Record | undefined` — callers unchanged. + */ + async #readVaultResilient(): Promise | undefined> { + const maxAttempts = 10; + const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const vault = + await this.#store.get>("vault"); + return vault ?? undefined; + } catch (error) { + if (attempt === maxAttempts) { + console.error( + "Failed to get vault after retries (store IPC unavailable):", + error, + ); return undefined; } - return vault; - }) - .catch((error) => { - console.error("Failed to get vault:", error); - return undefined; - }); + console.warn( + `[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`, + ); + await new Promise((resolve) => + setTimeout(resolve, retryDelayMs), + ); + } + } + return undefined; } // Getters for internal properties diff --git a/infrastructure/eid-wallet/src/test/env-static-public.ts b/infrastructure/eid-wallet/src/test/env-static-public.ts new file mode 100644 index 000000000..cf0f58a65 --- /dev/null +++ b/infrastructure/eid-wallet/src/test/env-static-public.ts @@ -0,0 +1,6 @@ +// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts). +// Real values are injected by SvelteKit at build time; tests only need the +// symbols to exist so modules that import them can load. +export const PUBLIC_EID_WALLET_TOKEN = ""; +export const PUBLIC_PROVISIONER_URL = ""; +export const PUBLIC_REGISTRY_URL = ""; diff --git a/infrastructure/eid-wallet/vitest.config.ts b/infrastructure/eid-wallet/vitest.config.ts new file mode 100644 index 000000000..9116b0662 --- /dev/null +++ b/infrastructure/eid-wallet/vitest.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +// Dedicated, isolated test config — intentionally does NOT load the app's +// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node +// environment and stub the few runtime-only imports (see `$env` alias below; +// heavier deps are mocked per-spec with `vi.mock`). +export default defineConfig({ + resolve: { + alias: { + "$env/static/public": fileURLToPath( + new URL("./src/test/env-static-public.ts", import.meta.url), + ), + }, + }, + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + }, +});