fix: ce qui n-est pas DECIDE est refuse, et les deux vues portent la meme regle
LE PRINCIPE ETAIT FAUX, pas un nom manquant. "Un primitif ne peut atteindre aucun objet, donc il passe" : or un COMPTE est un primitif qui DECRIT les objets. Le fichier le savait deja, puisque `size` et `length` sont calcules - par une liste de deux noms, c-est-a-dire la liste de noms interdite reapparue en repli des que le principe a cede. Tout ce qui s-epelle autrement passait : mesure, un porteur qui peut lire 1 objet sur 3 lisait `totalCount` a 3. Et LA SUITE EPINGLAIT LA REPONSE QUI FUIT COMME CORRECTE, six lignes au-dessus d-un test dont le commentaire appelle ce meme compte non filtre le defaut. Aucun test de TYPE ne separe un primitif qui est une valeur d-un primitif derive de la collection : un nombre est un nombre. Dit clairement, parce que la reponse precedente pretendait le contraire. Le defaut s-inverse donc : ce que ce fichier a DECIDE, il le sert ; tout le reste refuse, primitif ou non. C-est sur pour une forme que personne n-a vue PAR CONSTRUCTION. Deux exceptions, aucune n-etant une epellation du SDK : un membre que la cible ne porte pas rend `undefined`, et les cles de protocole du LANGAGE, fermees par la specification. LES DEUX VUES, PAS UNE. L-enveloppe avait deux pieges contre neuf : `delete resultat.data`, `resultat.isLoading = x`, `setPrototypeOf` et `preventExtensions` atteignaient l-objet du SDK - dont les deux derniers avaient ete fermes sur la collection au round precedent. Les cinq pieges d-ecriture vivent maintenant dans UN objet etale dans les deux. `add` demandait `keep`, qui repond vrai pour un objet ne nommant AUCUN document : juste pour une lecture, vide de sens pour une ecriture. L-objet atterrissait dans la collection partagee, et seul l-amont l-arretait - la garantie empruntee que ce fichier avait justement retiree un round plus tot. Enfin `copyWithin`, que la liste amont classe non mutant a tort et que son propre `readOnlyArray` refuse : il n-est plus servi.
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
ITERATOR_HELPERS,
|
||||
NON_MUTATING_ARRAY_ONLY,
|
||||
NON_MUTATING_SET_ONLY,
|
||||
UPSTREAM_MISLABELLED_AS_NON_MUTATING,
|
||||
filterReadable,
|
||||
makeReadFilteredView,
|
||||
} from "../src/emulated-verifier/read-filter";
|
||||
@@ -271,8 +272,16 @@ test("the declared non-mutating surface still matches what upstream ships", () =
|
||||
expect(members("iteratorFnKeys")).toEqual([...ITERATOR_HELPERS].sort());
|
||||
// Upstream's array and set lists are declared as their own entries `.union(iteratorFnKeys)`,
|
||||
// and are restated here the same way - so the comparison is against the literal, not the union.
|
||||
expect(members("nonMutatingArrayFnKeys").filter((k) => !ITERATOR_HELPERS.has(k)))
|
||||
.toEqual([...NON_MUTATING_ARRAY_ONLY].sort());
|
||||
// ...minus the one member upstream mislabels, which is named in the source rather than
|
||||
// dropped quietly, so this gate still goes red on any OTHER change to the list.
|
||||
expect(
|
||||
members("nonMutatingArrayFnKeys").filter(
|
||||
(k) => !ITERATOR_HELPERS.has(k) && !UPSTREAM_MISLABELLED_AS_NON_MUTATING.has(k),
|
||||
),
|
||||
).toEqual([...NON_MUTATING_ARRAY_ONLY].sort());
|
||||
expect([...UPSTREAM_MISLABELLED_AS_NON_MUTATING].every((k) =>
|
||||
members("nonMutatingArrayFnKeys").includes(k),
|
||||
)).toBe(true);
|
||||
expect(members("nonMutatingSetFnKeys").filter((k) => !ITERATOR_HELPERS.has(k)))
|
||||
.toEqual([...NON_MUTATING_SET_ONLY].sort());
|
||||
});
|
||||
@@ -315,11 +324,12 @@ test("every declared non-mutating member answers for the holder, on both forms",
|
||||
expect(ordered.toSorted((x, y) => x.id.localeCompare(y.id))).toEqual([MINE, LINKED]);
|
||||
expect(ordered.toSpliced(0, 1)).toEqual([LINKED]);
|
||||
expect(ordered.with(0, NOGRAPH)).toEqual([NOGRAPH, LINKED]);
|
||||
// `copyWithin` mutates its receiver and upstream declares it non-mutating anyway. It
|
||||
// runs on the snapshot, and because it hands its receiver back it comes back as the
|
||||
// VIEW - so what a caller keeps hold of is the live filtered collection, and the one
|
||||
// thing that must not happen, a write reaching the collection underneath, does not.
|
||||
expect([...ordered.copyWithin(0, 1)]).toEqual([MINE, LINKED]);
|
||||
// `copyWithin` is the one member upstream MISLABELS as non-mutating: it writes, and
|
||||
// upstream's own `readOnlyArray` throws on so much as reading it. Serving it over the
|
||||
// snapshot made it succeed and discard in silence, so it is refused instead - against a
|
||||
// real ordered collection that refusal is upstream's own.
|
||||
expect(() => (ordered as unknown as { copyWithin(a: number, b: number): unknown })
|
||||
.copyWithin(0, 1)).toThrow();
|
||||
expect([...ordered]).toEqual([MINE, LINKED]);
|
||||
|
||||
const asSet = makeReadFilteredView(new Set<Item>([MINE, FOREIGN, LINKED]), caps) as
|
||||
@@ -378,3 +388,30 @@ test("a write to the view itself is refused, whatever the collection underneath
|
||||
expect(Object.getPrototypeOf(items)).toBe(Array.prototype);
|
||||
expect(Object.isExtensible(items)).toBe(true);
|
||||
});
|
||||
|
||||
// REGRESSION (fourth adversarial pass, 2026-09-24). The default was "a primitive cannot
|
||||
// reach an item, so it passes" - and a COUNT is a primitive that describes the items. The
|
||||
// file already knew it, because `size` and `length` are computed; anything spelled
|
||||
// otherwise went through. No type-level test separates a derived count from a page size,
|
||||
// so the default inverted: what this file has decided it serves, everything else refuses.
|
||||
test("a member this file has not decided is refused, primitive or not", () => {
|
||||
const set = new Set<Item>([MINE, FOREIGN, LINKED]) as Set<Item> & { count?: number };
|
||||
Object.defineProperty(set, "count", { value: 3, configurable: true });
|
||||
const { caps, become } = setup("alice");
|
||||
const view = makeReadFilteredView(set, caps) as Set<Item> & {
|
||||
count?: number;
|
||||
neverShipped?: unknown;
|
||||
};
|
||||
become("bob"); // holds nothing of the three
|
||||
|
||||
// The two spellings this file DECIDED answer what may be read,
|
||||
expect(view.size).toBe(0);
|
||||
// and the third spelling of the same number is refused rather than forwarded.
|
||||
expect(() => view.count).toThrow(/not filtered/i);
|
||||
// A member the collection does not carry answers `undefined`: nothing is behind it to
|
||||
// derive anything from, so refusing would refuse a question nobody asked.
|
||||
expect(view.neverShipped).toBeUndefined();
|
||||
// The language's own primitive keys still answer, and they are closed by the spec rather
|
||||
// than by any package, so no version bump adds to them.
|
||||
expect(Object.prototype.toString.call(view)).toBe("[object Set]");
|
||||
});
|
||||
|
||||
@@ -388,19 +388,24 @@ test("useShape: an accessor member is read through the view, not around it", asy
|
||||
|
||||
// -- SAFE FOR A SHAPE NOBODY HAS SEEN --------------------------------------
|
||||
|
||||
/** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus two
|
||||
* nobody has declared - one holding the collection, one holding a count. */
|
||||
/** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus three
|
||||
* nobody has declared - one holding the collection, one COUNTING it, one yielding an item. */
|
||||
interface LaterResult extends ShapeResult {
|
||||
readonly rows: unknown;
|
||||
readonly totalCount: number;
|
||||
readonly sample: () => unknown;
|
||||
}
|
||||
|
||||
// The whole point of deciding by TYPE rather than by a list of names. A name list is what
|
||||
// breaks silently on the next bump - which is exactly how this defect arrived - so the
|
||||
// question asked of an unnamed member is what no version number can change: can the value
|
||||
// behind it reach an item at all?
|
||||
test("useShape: a member no version has declared yet cannot leak, while a scalar one still passes", async () => {
|
||||
// WHAT A MEMBER NOBODY HAS DECIDED ANSWERS, and the third version of this test, because the
|
||||
// first two were wrong in the same way. It asserted that `totalCount` PASSING was correct,
|
||||
// on the ground that "a primitive cannot hold a reference to anything" - six lines above a
|
||||
// test whose own comment calls an unfiltered count the defect. The suite asserted both that
|
||||
// the leak was correct and that it was the defect, which is worse than the leak: it would
|
||||
// have kept it green for ever.
|
||||
//
|
||||
// A count IS a primitive and DOES describe the items, and no type-level test separates it
|
||||
// from a page size. So nothing undecided passes now, whatever its type.
|
||||
test("useShape: no member this package has not decided is answered, whatever its type", async () => {
|
||||
const base = makeUseShapeHook();
|
||||
const later: FakeShapeHook = {
|
||||
...base,
|
||||
@@ -422,16 +427,21 @@ test("useShape: a member no version has declared yet cannot leak, while a scalar
|
||||
setCurrentUser("bob");
|
||||
const result = useShape(NoteShape, EVERYTHING) as LaterResult;
|
||||
|
||||
// An OBJECT member nobody named holds the collection, so it is refused at the read -
|
||||
// there is no later moment to refuse it at.
|
||||
// An OBJECT member holds the collection, so it is refused at the read - there is no
|
||||
// later moment to refuse it at.
|
||||
expect(() => result.rows).toThrow(/not filtered/i);
|
||||
// A FUNCTION member nobody named is refused at the CALL, so reading it (a logger, a
|
||||
// `typeof` guard) is not itself refused.
|
||||
// A FUNCTION member is refused at the CALL, so reading it (a logger, a `typeof` guard)
|
||||
// is not itself refused.
|
||||
expect(typeof result.sample).toBe("function");
|
||||
expect(() => result.sample()).toThrow(/not filtered/i);
|
||||
// A PRIMITIVE cannot hold a reference to anything, so it passes with no entry of its
|
||||
// own - which is why `isLoading` needs none either.
|
||||
expect(result.totalCount).toBe(7);
|
||||
// A PRIMITIVE is refused too, and this is the case the earlier version of this test had
|
||||
// backwards: `totalCount` counts items the holder may not read. Three exist, one may be
|
||||
// read, and the number that may not be published is 3.
|
||||
expect([...(result.data as Iterable<BaseType>)]).toHaveLength(1);
|
||||
expect(() => result.totalCount).toThrow(/not filtered/i);
|
||||
// A member the SDK does not carry at all answers `undefined`: there is nothing behind it
|
||||
// to derive anything from, so refusing would refuse a question nobody asked.
|
||||
expect((result as unknown as { neverShipped?: unknown }).neverShipped).toBeUndefined();
|
||||
});
|
||||
|
||||
// An `orderBy` config makes the collection an ARRAY (upstream `SubscriptionData`), whose
|
||||
@@ -483,3 +493,53 @@ test("useShape: the descriptor of a member answers exactly what reading it answe
|
||||
/RdfOrmSubscription/,
|
||||
);
|
||||
});
|
||||
|
||||
// REGRESSION (fourth adversarial pass). The envelope view had TWO traps where the
|
||||
// collection view had nine, so the SDK's own result object could be written to through it -
|
||||
// the same fix applied in one of two places, which is the shape of every defect this file
|
||||
// has had. Both views now take the write traps from ONE object, so one cannot be repaired
|
||||
// without the other.
|
||||
test("useShape: the result object cannot be written to through the view either", async () => {
|
||||
const hook = makeUseShapeHook();
|
||||
inject(hook);
|
||||
const hers = await documentOf("alice");
|
||||
await documentOf("bob");
|
||||
hook.items.push(noteIn(hers, "a1"));
|
||||
|
||||
setCurrentUser("bob");
|
||||
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
||||
const asRecord = result as unknown as Record<string, unknown>;
|
||||
expect(() => {
|
||||
delete asRecord["data"];
|
||||
}).toThrow(/not filtered/i);
|
||||
expect(() => {
|
||||
asRecord["isLoading"] = true;
|
||||
}).toThrow(/not filtered/i);
|
||||
expect(() => Object.setPrototypeOf(result, { stolen: 1 })).toThrow(/not filtered/i);
|
||||
expect(() => Object.preventExtensions(result)).toThrow(/not filtered/i);
|
||||
// The request state still reads, because it is DECIDED rather than passed: it describes
|
||||
// the request, not the items.
|
||||
expect(typeof result.isLoading).toBe("boolean");
|
||||
});
|
||||
|
||||
// REGRESSION (fourth adversarial pass). The write gate asked `keep`, which answers `true`
|
||||
// for an object naming NO document - right for a read, where there is no cap to ask about,
|
||||
// and VACUOUS for a write: the object landed in the collection every identity shares. The
|
||||
// only thing stopping it was upstream raising "@graph is missing", which is a guarantee
|
||||
// borrowed from the target - the very thing removed for `set`/`deleteProperty` one round
|
||||
// earlier and left standing here.
|
||||
test("useShape: adding an object that names no document at all is refused", async () => {
|
||||
const hook = makeUseShapeHook();
|
||||
inject(hook);
|
||||
const hers = await documentOf("alice");
|
||||
const his = await documentOf("bob");
|
||||
hook.items.push(noteIn(hers, "a1"));
|
||||
|
||||
setCurrentUser("bob");
|
||||
const data = useShape(NoteShape, EVERYTHING).data as { add(item: BaseType): unknown };
|
||||
expect(() => data.add({ "@id": "PLANT" } as BaseType)).toThrow(/names no document/i);
|
||||
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(1);
|
||||
// A write that names a document this identity holds still goes through.
|
||||
data.add(noteIn(his, "b1"));
|
||||
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user