d35e735c8b
Le harnais e2e parlait à un sac de méthodes posé sur `window.__sdk`. Il prouvait que les fonctions s'exécutaient, jamais qu'on pouvait écrire une application avec — et cet écart a livré un vrai défaut : l'inbox d'un document était verte en test et inutilisable en vrai, parce que le harnais faisait traverser une adresse d'une identité à l'autre par une variable, ce qu'aucune application ne peut faire. `examples/notebook` est une application minimale en DOM natif, qui résout `@ng-eventually/client` comme un consommateur externe (workspace, dépendance déclarée, aucun import privilégié). Elle ne peut faire que ce qu'une application peut faire. Elle s'est déjà payée deux fois pendant son écriture : - `UnionSubject.subject` et `.graph` étaient typés `string` alors que ce sont toujours des références de document. Un consommateur devait donc caster ce qu'il venait de lire avant de le repasser — un cast à cet endroit précis rouvre la confusion que les types template literal existent pour fermer. - l'écran d'accès normalisait ce que l'utilisateur SAISIT mais pas ce que l'URL porte, si bien qu'un lien `?ng-id=@Erin` ouvrait un espace différent de celui de la même personne tapant `erin`. Une seule normalisation désormais, celle du registre. Le domaine est volontairement mince — des notes — mais suffit à exercer le placement par scope, la possession de caps, le partage dirigé, les inbox par document et la lecture réactive. 170 tests unitaires, typecheck vert sur la lib, l'exemple et le harnais.
125 lines
4.6 KiB
TypeScript
125 lines
4.6 KiB
TypeScript
/**
|
|
* The access gate's identity resolution.
|
|
*
|
|
* This is the piece whose failure is SILENT: get the order wrong and the broker iframe
|
|
* reads an empty identity, provisions a second virtual user, and the returning user
|
|
* lands in an empty space with no error anywhere. So the order is pinned, not trusted.
|
|
*/
|
|
import { test, expect, afterEach } from "bun:test";
|
|
import {
|
|
configure,
|
|
configureStoreRegistry,
|
|
resetConfig,
|
|
resetStoreRegistry,
|
|
setCurrentUser,
|
|
getCurrentUser,
|
|
} from "../src/polyfill";
|
|
import { ensureIdentity } from "../src/shared-wallet/access-gate";
|
|
|
|
const KEY = "ng-eventually:identity";
|
|
|
|
/** A localStorage double — the real one is absent in `bun test`. */
|
|
function fakeStorage(initial: Record<string, string> = {}) {
|
|
const map = new Map(Object.entries(initial));
|
|
return {
|
|
getItem: (k: string) => map.get(k) ?? null,
|
|
setItem: (k: string, v: string) => void map.set(k, v),
|
|
removeItem: (k: string) => void map.delete(k),
|
|
get size() { return map.size; },
|
|
};
|
|
}
|
|
|
|
/** Put the page in a given URL + storage state, as the browser would. */
|
|
function inPage(search: string, storage: ReturnType<typeof fakeStorage>) {
|
|
(globalThis as any).location = { search, href: "https://app.example" + search };
|
|
(globalThis as any).localStorage = storage;
|
|
(globalThis as any).history = { replaceState: () => {} };
|
|
}
|
|
|
|
afterEach(() => {
|
|
setCurrentUser(null);
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
delete (globalThis as any).location;
|
|
delete (globalThis as any).localStorage;
|
|
delete (globalThis as any).history;
|
|
});
|
|
|
|
function configured() {
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
|
|
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
|
});
|
|
configure({
|
|
ng: {} as never,
|
|
useShape: (() => {}) as never,
|
|
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
|
|
});
|
|
}
|
|
|
|
test("an identity already set is left alone — the gate never re-asks", async () => {
|
|
configured();
|
|
inPage("", fakeStorage());
|
|
setCurrentUser("alice");
|
|
await ensureIdentity();
|
|
expect(getCurrentUser()).toBe("alice");
|
|
});
|
|
|
|
test("the URL parameter WINS over storage — it is the only thing that crosses the frontier", async () => {
|
|
// The top-level page and the broker iframe have separate localStorage partitions, so a
|
|
// value written on one side is not the value the other reads. The URL survives the
|
|
// round-trip; storage does not. If storage won here, a user entering a second
|
|
// identifier would keep being sent back to the first one's space.
|
|
configured();
|
|
inPage("?ng-id=fromurl", fakeStorage({ [KEY]: "fromstorage" }));
|
|
await ensureIdentity();
|
|
expect(getCurrentUser()).toBe("fromurl");
|
|
});
|
|
|
|
test("the URL parameter is copied into THIS partition, so a plain reload still knows", async () => {
|
|
configured();
|
|
const storage = fakeStorage();
|
|
inPage("?ng-id=carol", storage);
|
|
await ensureIdentity();
|
|
expect(storage.getItem(KEY)).toBe("carol");
|
|
});
|
|
|
|
test("with no parameter, storage answers — a reload does not re-ask", async () => {
|
|
configured();
|
|
inPage("", fakeStorage({ [KEY]: "dana" }));
|
|
await ensureIdentity();
|
|
expect(getCurrentUser()).toBe("dana");
|
|
});
|
|
|
|
test("nothing known and no DOM to ask on → it refuses loudly", async () => {
|
|
// Continuing silently would provision an anonymous virtual space, which is the failure
|
|
// this module exists to prevent. The error names what the caller must do.
|
|
configured();
|
|
inPage("", fakeStorage());
|
|
await expect(ensureIdentity()).rejects.toThrow(/no DOM to ask on/i);
|
|
});
|
|
|
|
test("no shared wallet configured → it refuses, rather than inventing a space", async () => {
|
|
configure({ ng: {} as never, useShape: (() => {}) as never });
|
|
inPage("", fakeStorage());
|
|
await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i);
|
|
});
|
|
|
|
test("the URL value is NORMALIZED on the way in — `@Erin` and `erin` are one space", async () => {
|
|
// Ported from the consumer's `identifiant-resolution` feature, and it caught a real
|
|
// defect here: the gate normalized what a user TYPED but not what the URL carried, so
|
|
// a link with `?ng-id=@Erin` keyed onto a different virtual user than the same person
|
|
// typing `erin`. One normalizer — the injected one — for all three entry paths.
|
|
configured();
|
|
inPage("?ng-id=@Erin", fakeStorage());
|
|
await ensureIdentity();
|
|
expect(getCurrentUser()).toBe("erin");
|
|
});
|
|
|
|
test("a stored value is normalized too — an old entry cannot key onto a second space", async () => {
|
|
configured();
|
|
inPage("", fakeStorage({ [KEY]: "@Frank" }));
|
|
await ensureIdentity();
|
|
expect(getCurrentUser()).toBe("frank");
|
|
});
|