diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index 2c830ea8b..a7107cb23 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -14,7 +14,7 @@ "lint": "npx @biomejs/biome lint --write ./src", "check-lint": "npx @biomejs/biome lint ./src", "tauri": "tauri", - "test": "vitest run", + "test": "vitest run --project eid-wallet", "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/utils/personalBinding.spec.ts b/infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts new file mode 100644 index 000000000..3e26223a3 --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { loadPersonalBindings } from "./personalBinding"; + +const GQL_URL = "http://vault.test:4000/graphql"; +const ENAME = "@ada-0000-0000"; + +type StubResponse = { + ok: boolean; + status?: number; + json: () => Promise; +}; + +function docs(edges: unknown[]): StubResponse { + return { + ok: true, + json: async () => ({ data: { bindingDocuments: { edges } } }), + }; +} + +const PHOTO_EDGES = [{ node: { id: "photo-1", parsed: null } }]; +// pickLatestEdge orders by signatures[0].timestamp and ignores anything without +// one, so these fixtures must carry a signature to be seen at all. +const PARAM_EDGES = [ + { + node: { + id: "param-1", + parsed: { + type: "personal_parameters", + data: { text: "5'9\", brown eyes" }, + signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }], + }, + }, + }, +]; +const SECURITY_EDGES = [ + { + node: { + id: "sec-1", + parsed: { + type: "security_question", + data: { question: "First pet?" }, + signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }], + }, + }, + }, +]; + +/** Route each stubbed request by the `type` variable the caller sent. */ +function stubVault(byType: (type: string) => StubResponse) { + const mock = vi.fn(async (_url: string, init: { body: string }) => { + const body = JSON.parse(init.body) as { + variables?: { type?: string }; + }; + return byType(body.variables?.type ?? ""); + }); + vi.stubGlobal("fetch", mock); + return mock; +} + +const allGood = (type: string): StubResponse => { + if (type === "photograph") return docs(PHOTO_EDGES); + if (type === "personal_parameters") return docs(PARAM_EDGES); + return docs(SECURITY_EDGES); +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("loadPersonalBindings — count-only path (home screen)", () => { + it("returns every mark when all queries succeed", async () => { + stubVault(allGood); + + const loaded = await loadPersonalBindings(GQL_URL, ENAME, { + skipPhotoBlobs: true, + }); + + expect(loaded.photographs).toHaveLength(1); + expect(loaded.parameters?.text).toBe("5'9\", brown eyes"); + expect(loaded.securityQuestion?.question).toBe("First pet?"); + }); + + // The #1086 regression: these used to resolve with an empty result, which + // the caller then wrote over the store as a full replace — wiping marks the + // user still had. Rejecting is what lets the caller keep the last good state. + it("rejects when a query fails at the HTTP level, instead of reporting no marks", async () => { + stubVault((type) => + type === "photograph" + ? { ok: false, status: 503, json: async () => ({}) } + : allGood(type), + ); + + await expect( + loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }), + ).rejects.toThrow(/503/); + }); + + it("rejects on a GraphQL-level error (HTTP 200 + errors)", async () => { + stubVault((type) => + type === "security_question" + ? { + ok: true, + json: async () => ({ + errors: [{ message: "token expired" }], + data: null, + }), + } + : allGood(type), + ); + + await expect( + loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }), + ).rejects.toThrow(/token expired/); + }); + + it("still reports genuinely absent marks as empty, not as a failure", async () => { + stubVault(() => docs([])); + + const loaded = await loadPersonalBindings(GQL_URL, ENAME, { + skipPhotoBlobs: true, + }); + + expect(loaded.photographs).toHaveLength(0); + expect(loaded.parameters).toBeNull(); + expect(loaded.securityQuestion).toBeNull(); + }); +}); diff --git a/infrastructure/eid-wallet/src/lib/utils/personalBinding.ts b/infrastructure/eid-wallet/src/lib/utils/personalBinding.ts index f54607b05..dcc3e92c3 100644 --- a/infrastructure/eid-wallet/src/lib/utils/personalBinding.ts +++ b/infrastructure/eid-wallet/src/lib/utils/personalBinding.ts @@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly( ownerEname: string, ): Promise { type Resp = { bindingDocuments: { edges: BindingDocEdge[] } }; - const empty: Resp = { bindingDocuments: { edges: [] } }; - - const [photosResult, paramsResult, securityResult] = - await Promise.allSettled([ - vaultGqlRequest( - gqlUrl, - ownerEname, - PERSONAL_PHOTO_IDS_QUERY, - { type: "photograph" }, - ), - vaultGqlRequest( - gqlUrl, - ownerEname, - PERSONAL_BINDING_BY_TYPE_QUERY, - { type: "personal_parameters" }, - ), - vaultGqlRequest( - gqlUrl, - ownerEname, - PERSONAL_BINDING_BY_TYPE_QUERY, - { type: "security_question" }, - ), - ]); - - if (photosResult.status === "rejected") - console.warn( - "[personalBinding] photograph count failed:", - photosResult.reason, - ); - if (paramsResult.status === "rejected") - console.warn( - "[personalBinding] personal_parameters fetch failed:", - paramsResult.reason, - ); - if (securityResult.status === "rejected") - console.warn( - "[personalBinding] security_question fetch failed:", - securityResult.reason, - ); - const photosResp = - photosResult.status === "fulfilled" ? photosResult.value : empty; - const paramsResp = - paramsResult.status === "fulfilled" ? paramsResult.value : empty; - const securityResp = - securityResult.status === "fulfilled" ? securityResult.value : empty; + // Promise.all, not allSettled: callers feed this straight into a full-store + // replace, so substituting an empty result for a failed query is not + // graceful degradation — it erases marks the user still has. A partial + // result is no safer than none, since the replace overwrites either way. + // Rejecting lets the caller keep the last good state. Matches the sibling + // path in loadPersonalBindings above. + const [photosResp, paramsResp, securityResp] = await Promise.all([ + vaultGqlRequest(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, { + type: "photograph", + }), + vaultGqlRequest( + gqlUrl, + ownerEname, + PERSONAL_BINDING_BY_TYPE_QUERY, + { type: "personal_parameters" }, + ), + vaultGqlRequest( + gqlUrl, + ownerEname, + PERSONAL_BINDING_BY_TYPE_QUERY, + { type: "security_question" }, + ), + ]); const photographs: LoadedPhotograph[] = ( photosResp.bindingDocuments?.edges ?? [] diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts new file mode 100644 index 000000000..0faa03fcf --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { clearVaultUriCache, resolveVaultUri } from "./socialBinding"; + +const ENAME = "@ada-0000-0000"; +const OTHER = "@grace-1111-1111"; +const VAULT_URI = "http://vault.test:4000"; +const EXPECTED = "http://vault.test:4000/graphql"; + +/** Registry responds with a vault URI; counts how often it was actually hit. */ +function stubRegistry(uri = VAULT_URI) { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ uri }), + })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +beforeEach(() => { + clearVaultUriCache(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("resolveVaultUri — registry lookup memoisation", () => { + it("hits the registry once for repeated sequential lookups", async () => { + const fetchMock = stubRegistry(); + + expect(await resolveVaultUri(ENAME)).toBe(EXPECTED); + expect(await resolveVaultUri(ENAME)).toBe(EXPECTED); + expect(await resolveVaultUri(ENAME)).toBe(EXPECTED); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("coalesces concurrent lookups of the same eName into one request", async () => { + const fetchMock = stubRegistry(); + + // This is the real shape of the bug: the binding reconcile and the name + // lookup resolve the same counterparty at the same time, via Promise.all. + const results = await Promise.all([ + resolveVaultUri(ENAME), + resolveVaultUri(ENAME), + resolveVaultUri(ENAME), + resolveVaultUri(ENAME), + ]); + + expect(results).toEqual([EXPECTED, EXPECTED, EXPECTED, EXPECTED]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("treats a bare eName and an @-prefixed one as the same cache entry", async () => { + const fetchMock = stubRegistry(); + + await resolveVaultUri("ada-0000-0000"); + await resolveVaultUri("@ada-0000-0000"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("keeps distinct eNames independent", async () => { + const fetchMock = stubRegistry(); + + await resolveVaultUri(ENAME); + await resolveVaultUri(OTHER); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not cache failures — a later lookup retries the registry", async () => { + const failing = vi.fn(async () => ({ ok: false, status: 503 })); + vi.stubGlobal("fetch", failing); + + await expect(resolveVaultUri(ENAME)).rejects.toThrow( + /could not resolve/i, + ); + await expect(resolveVaultUri(ENAME)).rejects.toThrow( + /could not resolve/i, + ); + + expect(failing).toHaveBeenCalledTimes(2); + }); + + it("clearVaultUriCache forces the next lookup back to the registry", async () => { + const fetchMock = stubRegistry(); + + await resolveVaultUri(ENAME); + clearVaultUriCache(); + await resolveVaultUri(ENAME); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts index 4c3ee74ca..5c5e78948 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts @@ -29,11 +29,17 @@ export interface BindingDocEdge { // Registry resolution // --------------------------------------------------------------------------- -/** - * Resolve an eName to its eVault GraphQL endpoint via the registry. - */ -export async function resolveVaultUri(ename: string): Promise { - const normalized = ename.startsWith("@") ? ename : `@${ename}`; +const VAULT_URI_TTL_MS = 5 * 60_000; +const vaultUriCache = new Map(); +const vaultUriInFlight = new Map>(); + +/** Drop the memoised registry lookups (sign-out, tests). */ +export function clearVaultUriCache(): void { + vaultUriCache.clear(); + vaultUriInFlight.clear(); +} + +async function fetchVaultUri(normalized: string): Promise { const url = new URL( `resolve?w3id=${encodeURIComponent(normalized)}`, PUBLIC_REGISTRY_URL, @@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise { : new URL("/graphql", base).toString(); } +/** + * Resolve an eName to its eVault GraphQL endpoint via the registry. + * + * Memoised: an eName→URI mapping only changes when a vault is re-provisioned, + * but several layers resolve the same counterparty independently on one screen + * load (the binding reconcile and the name lookup, for a start), so the same + * round trip was being paid 3-4× per contact. Concurrent callers share one + * in-flight request; failures are not cached. + */ +export async function resolveVaultUri(ename: string): Promise { + const normalized = ename.startsWith("@") ? ename : `@${ename}`; + + const cached = vaultUriCache.get(normalized); + if (cached && Date.now() - cached.at < VAULT_URI_TTL_MS) return cached.uri; + + const pending = vaultUriInFlight.get(normalized); + if (pending) return pending; + + const request = fetchVaultUri(normalized) + .then((uri) => { + vaultUriCache.set(normalized, { uri, at: Date.now() }); + return uri; + }) + .finally(() => { + vaultUriInFlight.delete(normalized); + }); + + vaultUriInFlight.set(normalized, request); + return request; +} + // --------------------------------------------------------------------------- // Generic cross-vault GraphQL request // --------------------------------------------------------------------------- diff --git a/infrastructure/eid-wallet/src/routes/(app)/cache.ts b/infrastructure/eid-wallet/src/routes/(app)/cache.ts new file mode 100644 index 000000000..e9aaa968f --- /dev/null +++ b/infrastructure/eid-wallet/src/routes/(app)/cache.ts @@ -0,0 +1,28 @@ +/** + * Registry of the module-scope render caches that survive logout. + * + * Pages stash their last-known values at module scope so re-entering paints + * instantly instead of flashing a spinner. That state outlives logout: + * `performLogout` leaves with `goto("/")`, a client-side navigation, and + * SvelteKit never re-evaluates a module on client-side nav. So without an + * explicit reset, the next user to onboard on this device is shown the + * previous user's data. Same hazard `clearAllCachedPhotos` exists for. + * + * A page registers its own reset from ` +