Skip to content
Open
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
2 changes: 1 addition & 1 deletion infrastructure/eid-wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 128 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
};

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();
});
});
68 changes: 23 additions & 45 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly(
ownerEname: string,
): Promise<LoadedPersonalBindings> {
type Resp = { bindingDocuments: { edges: BindingDocEdge[] } };
const empty: Resp = { bindingDocuments: { edges: [] } };

const [photosResult, paramsResult, securityResult] =
await Promise.allSettled([
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_PHOTO_IDS_QUERY,
{ type: "photograph" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
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<Resp>(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, {
type: "photograph",
}),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

const photographs: LoadedPhotograph[] = (
photosResp.bindingDocuments?.edges ?? []
Expand Down
96 changes: 96 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
47 changes: 42 additions & 5 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;
const VAULT_URI_TTL_MS = 5 * 60_000;
const vaultUriCache = new Map<string, { uri: string; at: number }>();
const vaultUriInFlight = new Map<string, Promise<string>>();

/** Drop the memoised registry lookups (sign-out, tests). */
export function clearVaultUriCache(): void {
vaultUriCache.clear();
vaultUriInFlight.clear();
}

async function fetchVaultUri(normalized: string): Promise<string> {
const url = new URL(
`resolve?w3id=${encodeURIComponent(normalized)}`,
PUBLIC_REGISTRY_URL,
Expand All @@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise<string> {
: 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<string> {
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
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading