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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>) {
const store = { get } as unknown as ConstructorParameters<
typeof VaultController
>[0];
const userController = {
user: Promise.resolve(undefined),
} as unknown as ConstructorParameters<typeof VaultController>[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);
});
});
59 changes: 49 additions & 10 deletions infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,19 +580,58 @@ export class VaultController {
}
}

#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
null;

get vault() {
return this.#store
.get<Record<string, string>>("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<Record<string, string> | 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<Record<string, string>>("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
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/eid-wallet/src/test/env-static-public.ts
Original file line number Diff line number Diff line change
@@ -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 = "";
20 changes: 20 additions & 0 deletions infrastructure/eid-wallet/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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"],
},
});
Loading