Files
ng-eventually/packages/polyfill/test/ng-proxy.test.ts
T
Sylvain Duchesne 3a8941159f fix: le filtre de lecture couvre l-enveloppe que le SDK renvoie vraiment
Le `useShape` publie enveloppait le resultat du hook dans le filtre ecrit pour
une COLLECTION. Depuis que `@ng-org/orm` renvoie un `UseShapeResult` autour du
set, `[...resultat]` et `.size` levaient, et `data`, `promise` et `subscription`
revenaient BRUTS - chacun tous les objets de toutes les identites, sans que rien
ne casse. Aucun faux de ce depot ne renvoyait la forme livree, donc aucune garde
ne pouvait le voir.

Deux vues au lieu d-une : celle de la collection, et celle de l-enveloppe que
`surface/use-shape.ts` applique. `data` et `promise` sont filtres, `subscription`
est refuse (c-est le handle de l-ORM, que cette surface ne publie pas), les deux
commandes de page passent.

Le defaut par TYPE et non par une liste de noms : un primitif ne peut atteindre
aucun objet et passe, un objet ou une fonction refuse tant qu-il n-existe pas de
forme filtree ici. C-est pourquoi `isLoading` n-est nomme nulle part, et pourquoi
un membre qu-une version suivante ajoutera est deja tranche. `size` et `length`
comptent ce que le porteur peut lire.

`UseShapeLike` etait `(...args: any[]) => any`, ce qui est la raison pour
laquelle les deux typechecks restaient a 0 pendant que la fuite etait ouverte :
aucune declaration ne disait ce que le hook rend. Il rend `ShapeResult`, releve
sur le `.d.ts` installe, et le vrai `reactUseShape` y est assignable sans cast.

Le faux partage `test/use-shape-fake.ts` renvoie la forme livree et c-est celui
que les deux gardes injectent - unitaire et e2e. La garantie du contrat et le
test qui l-epingle arrivent ensemble ; `1.0.0-dev.11`, majeur.
2026-09-24 18:27:02 +02:00

153 lines
6.4 KiB
TypeScript

import { getCaps } from "../src/shared-wallet/bootstrap";
import { makeUseShapeHook } from "./use-shape-fake";
import { test, expect, mock, afterEach } from "bun:test";
import { makeNg } from "../src/surface/ng-proxy";
import { configure } from "../src/index";
import { setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig } from "../src/shared-wallet/bootstrap";
// This suite injects a fake `ng` via configure() and declares WRITE caps —
// which stay an authorization list on purpose: only READING is key possession
// (delivered by cap-surface). The write axis is decorative until
// cap-enforcement (every internal writer bypasses this proxy). Reset after each
// test so the docs.test.ts "not configured" guard still holds and no cap leaks
// into another suite.
afterEach(() => {
resetConfig();
resetCaps();
setCurrentUser(null);
});
function fakeNg() {
return { sparql_update: mock(async (..._a: unknown[]) => undefined) };
}
function inject() {
const ng = fakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
return ng;
}
const DOC = "did:ng:o:doc";
const UPDATE = `INSERT DATA { GRAPH <${DOC}> { <s> <p> <o> } }`;
test("write guard: passthrough when NO write policy is declared (no regression)", async () => {
const ng = inject();
setCurrentUser("bob"); // not a writer, but there's no policy at all
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: passthrough for an UNGOVERNED doc even when a policy exists elsewhere", async () => {
const ng = inject();
getCaps().grantWrite("did:ng:o:other", "alice"); // policy on another doc
setCurrentUser("bob");
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC); // DOC itself is ungoverned
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: REJECTS when the doc is governed and the user lacks the write cap", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice"); // alice holds the write cap
setCurrentUser("bob"); // bob does not
const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
/write denied/,
);
expect(ng.sparql_update).toHaveBeenCalledTimes(0); // never reached the real ng
});
test("write guard: REJECTS an anonymous (null) user on a governed doc", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice");
setCurrentUser(null);
const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
/write denied/,
);
expect(ng.sparql_update).toHaveBeenCalledTimes(0);
});
test("write guard: ALLOWS the write-cap holder", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice");
setCurrentUser("alice"); // owner always holds the write cap
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: passthrough when anchor is omitted (cannot scope the guard)", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice");
setCurrentUser("bob");
const proxy = makeNg();
// Anchor explicitly `undefined` — upstream declares all three parameters (`index.d.ts:297`),
// and the guard reads `args[2]`, which is `undefined` whether the argument is omitted or
// passed as such. Same branch, same passthrough.
await proxy.sparql_update("sid", "INSERT DATA {}", undefined); // no anchor → passthrough
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
// --- the passthrough, against the shape the REAL `ng` actually has ----------
/**
* A stand-in for `@ng-org/web`'s `ng`, faithful in the one way every fake above is not:
* it is a PROXY, not a property bag.
*
* Read from the installed `@ng-org/web@0.1.2-alpha.14` (`dist/ngweb.js`, the `l(e = [])`
* factory): every key - including `bind`, `length`, `name` - answers with a fresh
* path-carrying CALLABLE, calling one posts a remote request named after the first path
* segment and returns a PROMISE, and the reflection traps throw outright.
*
* Every other fake in this suite is a plain object, on which `bind` behaves. That is why
* a passthrough that could not survive the real thing stayed green here for months.
*/
function asyncProxyNg(calls: string[]): Record<string, unknown> {
const make = (path: string[]): unknown =>
new Proxy(function (): void {} as object, {
get: (_t, k) => make([...path, String(k)]),
apply: (_t, _this, args: unknown[]) => {
calls.push(`${path.join(".")}/${args.length}`);
return Promise.resolve("ok");
},
has() { throw new Error('Async proxy does not support "has" handler.'); },
getOwnPropertyDescriptor() { throw new Error('Async proxy does not support "getOwnPropertyDescriptor" handler.'); },
ownKeys() { throw new Error('Async proxy does not support "ownKeys" handler.'); },
});
return make([]) as Record<string, unknown>;
}
// THE regression. `Reflect.get(ng, prop).bind(ng)` looks like a passthrough and is not:
// against the real `ng`, `bind` is just another key, so what came back was a PROMISE and
// a caller got `<member> is not a function`. The library's own internals never go through
// this proxy, so nothing here noticed until the ORM was handed it.
test("passthrough: a member of the REAL (proxy-shaped) `ng` comes back callable", async () => {
const calls: string[] = [];
const ng = asyncProxyNg(calls);
configure({ ng, useShape: makeUseShapeHook().hook });
const proxy = makeNg();
expect(typeof proxy.orm_start_graph).toBe("function");
// Upstream's own arity and argument shapes (`index.d.ts:orm_start_graph`).
await proxy.orm_start_graph!(["did:ng:i"], [], {}, "sid", {}, () => {});
// ...and exactly ONE remote request went out, named after the member - not a `bind` one.
expect(calls).toEqual(["orm_start_graph/6"]);
});
// A plain-object `ng` still gets its receiver: forwarding must not cost what binding
// bought. The unit fakes are all of this kind, and so is any `ng` that is a real object.
test("passthrough: a plain-object `ng` keeps its receiver", async () => {
const ng = {
mark: "the real ng",
whoAmI(this: { mark: string }) {
return this.mark;
},
};
configure({ ng, useShape: makeUseShapeHook().hook });
const proxy = makeNg() as unknown as { whoAmI: () => string };
expect(proxy.whoAmI()).toBe("the real ng");
});