@jantstack/adonis-authz 2.4.0-alpha.9 → 2.5.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +468 -30
- package/build/commands/authz_freeze.js +1 -1
- package/build/commands/authz_freeze.js.map +1 -1
- package/build/commands/authz_reconcile.d.ts +6 -2
- package/build/commands/authz_reconcile.d.ts.map +1 -1
- package/build/commands/authz_reconcile.js +129 -13
- package/build/commands/authz_reconcile.js.map +1 -1
- package/build/commands/authz_relations_reconcile.d.ts +16 -24
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +198 -49
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/commands/authz_scopes_relay.d.ts.map +1 -1
- package/build/commands/authz_scopes_relay.js +81 -1
- package/build/commands/authz_scopes_relay.js.map +1 -1
- package/build/commands/authz_unfreeze.d.ts +12 -4
- package/build/commands/authz_unfreeze.d.ts.map +1 -1
- package/build/commands/authz_unfreeze.js +74 -9
- package/build/commands/authz_unfreeze.js.map +1 -1
- package/build/index.d.ts +6 -4
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -3
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +1 -1
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +5 -1
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +41 -0
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js +120 -0
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +1 -0
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +18 -6
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +94 -46
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +46 -5
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +115 -29
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +82 -13
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +216 -55
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +86 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +86 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +63 -4
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +107 -18
- package/build/src/freeze.js.map +1 -1
- package/build/src/identity.d.ts +120 -9
- package/build/src/identity.d.ts.map +1 -1
- package/build/src/identity.js +164 -12
- package/build/src/identity.js.map +1 -1
- package/build/src/manager.d.ts +64 -2
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +267 -35
- package/build/src/manager.js.map +1 -1
- package/build/src/openfga.d.ts +2 -2
- package/build/src/openfga.d.ts.map +1 -1
- package/build/src/openfga.js +2 -2
- package/build/src/openfga.js.map +1 -1
- package/build/src/relations/check_many.d.ts +17 -0
- package/build/src/relations/check_many.d.ts.map +1 -0
- package/build/src/relations/check_many.js +57 -0
- package/build/src/relations/check_many.js.map +1 -0
- package/build/src/relations/define_relations_config.d.ts +74 -1
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +201 -7
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +20 -0
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +88 -15
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +134 -3
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +340 -36
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/relations/tuple_key.d.ts +25 -0
- package/build/src/relations/tuple_key.d.ts.map +1 -0
- package/build/src/relations/tuple_key.js +60 -0
- package/build/src/relations/tuple_key.js.map +1 -0
- package/build/src/relations_config_store.d.ts.map +1 -1
- package/build/src/relations_config_store.js +29 -7
- package/build/src/relations_config_store.js.map +1 -1
- package/build/src/relations_reconcile.d.ts +79 -0
- package/build/src/relations_reconcile.d.ts.map +1 -0
- package/build/src/relations_reconcile.js +85 -0
- package/build/src/relations_reconcile.js.map +1 -0
- package/build/src/scope_outbox.d.ts +9 -5
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +7 -4
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/backend_guard.d.ts +30 -11
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +45 -15
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/shared/sql_expiry.d.ts +2 -0
- package/build/src/shared/sql_expiry.d.ts.map +1 -1
- package/build/src/shared/sql_expiry.js +4 -0
- package/build/src/shared/sql_expiry.js.map +1 -1
- package/build/src/testing/main.d.ts +3 -1
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +2 -1
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/migration_contract.d.ts +9 -0
- package/build/src/testing/migration_contract.d.ts.map +1 -1
- package/build/src/testing/migration_contract.js +5 -2
- package/build/src/testing/migration_contract.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +14 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +367 -21
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/scope_outbox_contract.d.ts +14 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
- package/build/src/testing/scope_outbox_contract.js +126 -48
- package/build/src/testing/scope_outbox_contract.js.map +1 -1
- package/build/src/testing/test_clock.d.ts +15 -0
- package/build/src/testing/test_clock.d.ts.map +1 -0
- package/build/src/testing/test_clock.js +65 -0
- package/build/src/testing/test_clock.js.map +1 -0
- package/build/src/types.d.ts +115 -12
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js.map +1 -1
- package/package.json +6 -1
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
import { v7 as uuidv7 } from 'uuid';
|
|
21
21
|
import { test as japaTest } from '@japa/runner';
|
|
22
22
|
import db from '@adonisjs/lucid/services/db';
|
|
23
|
-
import { scopeKey } from '../identity.js';
|
|
23
|
+
import { partitionKey, scopeKey } from '../identity.js';
|
|
24
24
|
import { RelationsManager } from '../relations/manager.js';
|
|
25
|
-
import { defineRelationsConfig } from '../relations/define_relations_config.js';
|
|
25
|
+
import { defineRelationsConfig, assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject } from '../relations/define_relations_config.js';
|
|
26
|
+
import { assertCheckManyObjects, distinctObjects, expandByPosition, RELATIONS_CHECK_MANY_MAX } from '../relations/check_many.js';
|
|
26
27
|
import { systemClock } from '../clock.js';
|
|
27
28
|
import { resolveGrantExpiry } from '../expiry.js';
|
|
28
29
|
import { isRelUserset } from '../types.js';
|
|
@@ -33,18 +34,26 @@ export const RELATIONS_HOLDER_TYPES = ['user', 'admin', 'integration'];
|
|
|
33
34
|
* La config FIJA del contrato: un tipo `document` con `owner ⊆ editor ⊆
|
|
34
35
|
* viewer` (includes de un nivel) y `membersOf` habilitado en `database`.
|
|
35
36
|
*/
|
|
37
|
+
/**
|
|
38
|
+
* **Los dos tipos del contrato** (plan 2.5 · lote A, H10): `document` y un
|
|
39
|
+
* SEGUNDO tipo con las MISMAS relaciones, `workspace`, para que «tipos
|
|
40
|
+
* mezclados con la misma relación responden por ítem» (`checkMany`, P4-7)
|
|
41
|
+
* sea observable con la config fija —solo con `document` y el `group`
|
|
42
|
+
* built-in no comparten nombre de relación—. Cambio anunciado del export
|
|
43
|
+
* publicado: un harness de terceros que afirme sobre el modelo publicado de
|
|
44
|
+
* esta config ve un tipo más.
|
|
45
|
+
*/
|
|
46
|
+
export const RELATIONS_CONTRACT_OBJECT_TYPES = ['document', 'workspace'];
|
|
47
|
+
/**
|
|
48
|
+
* La config FIJA del contrato: `document` y `workspace`, cada uno con `owner ⊆
|
|
49
|
+
* editor ⊆ viewer` (includes de un nivel) y `membersOf` habilitado en `database`.
|
|
50
|
+
*/
|
|
36
51
|
export function contractRelationsConfig() {
|
|
37
52
|
return defineRelationsConfig({
|
|
38
|
-
objectTypes:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{ name: 'owner' },
|
|
43
|
-
{ name: 'editor', includes: ['owner'] },
|
|
44
|
-
{ name: 'viewer', includes: ['editor'] },
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
|
-
],
|
|
53
|
+
objectTypes: RELATIONS_CONTRACT_OBJECT_TYPES.map((type) => ({
|
|
54
|
+
type,
|
|
55
|
+
relations: [{ name: 'owner' }, { name: 'editor', includes: ['owner'] }, { name: 'viewer', includes: ['editor'] }],
|
|
56
|
+
})),
|
|
48
57
|
holderTypes: [...RELATIONS_HOLDER_TYPES],
|
|
49
58
|
database: { membersOf: true },
|
|
50
59
|
});
|
|
@@ -62,8 +71,15 @@ export function lucidRelationsContractTransactions() {
|
|
|
62
71
|
},
|
|
63
72
|
};
|
|
64
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* La clave de partición del doble pasa por `partitionKey` (`src/identity.ts`), la MISMA puerta que los dos
|
|
76
|
+
* drivers reales (plan 2.5 · lote 0, cierre ⚪ 11; lote A, D4): una partición mal formada (`A#B`, mayúsculas) es
|
|
77
|
+
* 422 `E_AUTHZ_INVALID_IDENTITY` también en el fixture, y un uuid de 32 hex SIN guiones también (D4). Hasta el
|
|
78
|
+
* cierre del lote 0 componía la clave por plantilla y aceptaba cualquier cosa, así que la columna `partition`
|
|
79
|
+
* de la guardia de superficie no podía decir nada del doble.
|
|
80
|
+
*/
|
|
65
81
|
function scopeKeyOf(scope) {
|
|
66
|
-
return scope
|
|
82
|
+
return partitionKey(scope);
|
|
67
83
|
}
|
|
68
84
|
function subjectKeyOf(subject) {
|
|
69
85
|
if (isRelUserset(subject))
|
|
@@ -85,9 +101,22 @@ export function makeRelationsDriver(options) {
|
|
|
85
101
|
*/
|
|
86
102
|
function buildRelationsDouble(options, tuples, now) {
|
|
87
103
|
const { config, capabilities, limits } = options;
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
/**
|
|
105
|
+
* **F-05 en el DOBLE** (plan 2.5 · lote 0, D13; hallazgo H8 del tester): hasta el lote 0 el doble
|
|
106
|
+
* publicado no re-validaba tipo/relación —solo el manager cortaba— y un driver de terceros podía
|
|
107
|
+
* calcar esa forma y pasar el contrato. El doble aplica las MISMAS dos funciones que los dos drivers
|
|
108
|
+
* reales (`assertRelationDeclared` estricta y `assertObjectTypeDeclared` de tipo), en las ocho
|
|
109
|
+
* operaciones y en el userset del sujeto. La guardia de superficie (`tests/guard_surface.spec.ts`,
|
|
110
|
+
* filas `double`) lo sondea en el doble y en su vista `withClock`.
|
|
111
|
+
*/
|
|
112
|
+
function assertSubjectDeclared(subject) {
|
|
113
|
+
// B3 (lote B): F-05 del par y solo `group#member`, la MISMA función que las tres capas reales.
|
|
114
|
+
if (isRelUserset(subject))
|
|
115
|
+
assertUsersetSubject(config, subject);
|
|
116
|
+
}
|
|
117
|
+
/** Caducidad ESTRICTA (R-15): la que vence AHORA ya no cuenta. `at` fija el instante (un `now` por lote en `checkMany`). */
|
|
118
|
+
function active(t, at = now) {
|
|
119
|
+
return t.expiresAt === null || t.expiresAt.getTime() > at().getTime();
|
|
91
120
|
}
|
|
92
121
|
/**
|
|
93
122
|
* `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
|
|
@@ -113,27 +142,27 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
113
142
|
return out;
|
|
114
143
|
}
|
|
115
144
|
/** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
|
|
116
|
-
function directSubjects(partition, objectType, objectId, relation) {
|
|
145
|
+
function directSubjects(partition, objectType, objectId, relation, at = now) {
|
|
117
146
|
return tuples.filter((t) => t.partition === partition &&
|
|
118
147
|
t.objectType === objectType &&
|
|
119
148
|
t.objectId === objectId &&
|
|
120
149
|
t.relation === relation &&
|
|
121
|
-
active(t));
|
|
150
|
+
active(t, at));
|
|
122
151
|
}
|
|
123
|
-
function resolves(subject, relation, object, partition, seen = new Set()) {
|
|
152
|
+
function resolves(subject, relation, object, partition, seen = new Set(), at = now) {
|
|
124
153
|
const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
|
|
125
154
|
if (seen.has(guardKey))
|
|
126
155
|
return false;
|
|
127
156
|
seen.add(guardKey);
|
|
128
157
|
const wantKey = subjectKeyOf(subject);
|
|
129
158
|
for (const rel of expandDown(object.type, relation)) {
|
|
130
|
-
for (const t of directSubjects(partition, object.type, object.id, rel)) {
|
|
159
|
+
for (const t of directSubjects(partition, object.type, object.id, rel, at)) {
|
|
131
160
|
if (t.subjectKey === wantKey)
|
|
132
161
|
return true;
|
|
133
162
|
// El sujeto directo es un userset (`group:g#member`): un HOLDER lo
|
|
134
163
|
// satisface si es MIEMBRO del userset (transitivo, grupos anidan).
|
|
135
164
|
if (isRelUserset(t.subject) && !isRelUserset(subject)) {
|
|
136
|
-
if (resolves(subject, t.subject.relation, t.subject.object, partition, seen))
|
|
165
|
+
if (resolves(subject, t.subject.relation, t.subject.object, partition, seen, at))
|
|
137
166
|
return true;
|
|
138
167
|
}
|
|
139
168
|
}
|
|
@@ -173,6 +202,8 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
173
202
|
const driver = {
|
|
174
203
|
capabilities,
|
|
175
204
|
async relate(subject, relation, object, partition, options) {
|
|
205
|
+
assertRelationDeclared(config, object, relation);
|
|
206
|
+
assertSubjectDeclared(subject);
|
|
176
207
|
const partitionKey = scopeKeyOf(partition);
|
|
177
208
|
const subjectKey = subjectKeyOf(subject);
|
|
178
209
|
const existing = tuples.find((t) => t.partition === partitionKey &&
|
|
@@ -198,6 +229,8 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
198
229
|
});
|
|
199
230
|
},
|
|
200
231
|
async unrelate(subject, relation, object, partition) {
|
|
232
|
+
assertRelationDeclared(config, object, relation);
|
|
233
|
+
assertSubjectDeclared(subject);
|
|
201
234
|
const partitionKey = scopeKeyOf(partition);
|
|
202
235
|
const subjectKey = subjectKeyOf(subject);
|
|
203
236
|
for (let i = tuples.length - 1; i >= 0; i--) {
|
|
@@ -212,9 +245,13 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
212
245
|
}
|
|
213
246
|
},
|
|
214
247
|
async check(subject, relation, object, partition) {
|
|
248
|
+
assertRelationDeclared(config, object, relation);
|
|
249
|
+
assertSubjectDeclared(subject);
|
|
215
250
|
return resolves(subject, relation, object, scopeKeyOf(partition));
|
|
216
251
|
},
|
|
217
252
|
async listObjects(subject, relation, objectType, partition, page) {
|
|
253
|
+
assertRelationDeclared(config, { type: objectType }, relation);
|
|
254
|
+
assertSubjectDeclared(subject);
|
|
218
255
|
const partitionKey = scopeKeyOf(partition);
|
|
219
256
|
const ids = new Set();
|
|
220
257
|
for (const t of tuples) {
|
|
@@ -237,6 +274,7 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
237
274
|
return { objects: matches };
|
|
238
275
|
},
|
|
239
276
|
async listSubjects(relation, object, partition, page) {
|
|
277
|
+
assertRelationDeclared(config, object, relation);
|
|
240
278
|
const partitionKey = scopeKeyOf(partition);
|
|
241
279
|
// DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
|
|
242
280
|
// `membersOf`) ni derivado. `usersetSubjects` decide si los usersets
|
|
@@ -248,6 +286,7 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
248
286
|
return { subjects: dedupeSubjects(subjects) };
|
|
249
287
|
},
|
|
250
288
|
async purgeObject(object, partition) {
|
|
289
|
+
assertObjectTypeDeclared(config, object);
|
|
251
290
|
const partitionKey = scopeKeyOf(partition);
|
|
252
291
|
for (let i = tuples.length - 1; i >= 0; i--) {
|
|
253
292
|
const t = tuples[i];
|
|
@@ -257,6 +296,7 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
257
296
|
}
|
|
258
297
|
},
|
|
259
298
|
async purgeSubject(subject, partition) {
|
|
299
|
+
assertSubjectDeclared(subject);
|
|
260
300
|
const partitionKey = scopeKeyOf(partition);
|
|
261
301
|
const subjectKey = subjectKeyOf(subject);
|
|
262
302
|
for (let i = tuples.length - 1; i >= 0; i--) {
|
|
@@ -266,8 +306,27 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
266
306
|
}
|
|
267
307
|
},
|
|
268
308
|
};
|
|
309
|
+
// `checkMany` (plan 2.5 · lote A): el doble lo trae solo cuando declara
|
|
310
|
+
// `singleCheckRelations` —el driver que resuelve en UNA llamada es el que
|
|
311
|
+
// tiene un lote nativo—; el MÍNIMO no lo trae y así el runner ejerce el
|
|
312
|
+
// respaldo del manager (N `check`, P4-9) sin una capacidad nueva.
|
|
313
|
+
if (capabilities.singleCheckRelations) {
|
|
314
|
+
driver.checkMany = async (subject, relation, objects, partition) => {
|
|
315
|
+
assertCheckManyObjects(objects);
|
|
316
|
+
for (const object of objects)
|
|
317
|
+
assertRelationDeclared(config, object, relation);
|
|
318
|
+
assertSubjectDeclared(subject);
|
|
319
|
+
const key = scopeKeyOf(partition);
|
|
320
|
+
const { distinct, index } = distinctObjects(objects);
|
|
321
|
+
// UN solo `now` para todo el lote (A-18): se fija aquí y `resolves` lo lee.
|
|
322
|
+
const at = now();
|
|
323
|
+
const answers = distinct.map((object) => resolves(subject, relation, object, key, new Set(), () => at));
|
|
324
|
+
return expandByPosition(index, answers);
|
|
325
|
+
};
|
|
326
|
+
}
|
|
269
327
|
if (capabilities.membersOfNative) {
|
|
270
328
|
driver.membersOf = async (object, relation, partition) => {
|
|
329
|
+
assertRelationDeclared(config, object, relation);
|
|
271
330
|
return { subjects: collectMembers(object, relation, scopeKeyOf(partition)) };
|
|
272
331
|
};
|
|
273
332
|
}
|
|
@@ -720,6 +779,267 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
720
779
|
assert.lengthOf(strictEvents, 1);
|
|
721
780
|
assert.deepEqual(strictEvents[0].actor, actor);
|
|
722
781
|
});
|
|
782
|
+
/* ── P4 · checkMany (plan 2.5 · lote A; A-9…A-14 del tester) ────────── */
|
|
783
|
+
/**
|
|
784
|
+
* **`checkMany` ≡ N `check`, posición a posición** (A-9, P4-1): allow directo,
|
|
785
|
+
* includes (`editor` ⊆ `viewer`), userset ANIDADO, caducado en el pasado y
|
|
786
|
+
* «nada». Y el orden de la RESPUESTA es el de la PETICIÓN (A-10, P4-2): los
|
|
787
|
+
* ids del lote van en orden inverso al de escritura y el patrón esperado
|
|
788
|
+
* es F,T,F,T — un driver que mapease por orden de fila de SQL o por el
|
|
789
|
+
* orden en que llegan los resultados del backend se pone rojo.
|
|
790
|
+
*/
|
|
791
|
+
test('P4-1/P4-2 · checkMany ≡ N check posición a posición (allow, includes, userset anidado, caducado, nada) y responde en el ORDEN de la petición (F,T,F,T con ids en orden inverso)', async ({ assert, }) => {
|
|
792
|
+
const u = holder();
|
|
793
|
+
const p = partition();
|
|
794
|
+
const direct = { type: 'document', id: docId() };
|
|
795
|
+
const viaInclude = { type: 'document', id: docId() };
|
|
796
|
+
const viaNested = { type: 'document', id: docId() };
|
|
797
|
+
const expired = { type: 'document', id: docId() };
|
|
798
|
+
const nothing = { type: 'document', id: docId() };
|
|
799
|
+
const g1 = { type: 'group', id: docId() };
|
|
800
|
+
const g2 = { type: 'group', id: docId() };
|
|
801
|
+
await state.manager.relate(u, 'viewer', direct, p);
|
|
802
|
+
await state.manager.relate(u, 'editor', viaInclude, p);
|
|
803
|
+
await state.manager.relate(u, 'member', g1, p);
|
|
804
|
+
await state.manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
|
|
805
|
+
await state.manager.relate({ object: g2, relation: 'member' }, 'viewer', viaNested, p);
|
|
806
|
+
await state.manager.relate(u, 'viewer', expired, p, { expiresAt: new Date(Date.now() - 60_000) });
|
|
807
|
+
const objects = [direct, viaInclude, viaNested, expired, nothing];
|
|
808
|
+
const one = [];
|
|
809
|
+
for (const object of objects)
|
|
810
|
+
one.push(await state.manager.check(u, 'viewer', object, p));
|
|
811
|
+
assert.deepEqual(one, [true, true, true, false, false], 'precondición: N check');
|
|
812
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', objects, p), one, 'checkMany ≡ N check, posición a posición');
|
|
813
|
+
// P4-2: ids en orden INVERSO al de escritura y respuesta F,T,F,T.
|
|
814
|
+
const a = { type: 'document', id: docId() };
|
|
815
|
+
const b = { type: 'document', id: docId() };
|
|
816
|
+
const c = { type: 'document', id: docId() };
|
|
817
|
+
const d = { type: 'document', id: docId() };
|
|
818
|
+
await state.manager.relate(u, 'viewer', c, p);
|
|
819
|
+
await state.manager.relate(u, 'viewer', a, p);
|
|
820
|
+
const sorted = [a, b, c, d].sort((x, y) => (x.id < y.id ? -1 : 1));
|
|
821
|
+
// [d', c', b', a'] con T solo en los que se escribieron (c y a): el patrón
|
|
822
|
+
// depende del orden de ids, así que se calcula con N check y se exige igual.
|
|
823
|
+
const request = [sorted[3], sorted[2], sorted[1], sorted[0]];
|
|
824
|
+
const expected = [];
|
|
825
|
+
for (const object of request)
|
|
826
|
+
expected.push(await state.manager.check(u, 'viewer', object, p));
|
|
827
|
+
assert.sameMembers(expected, [true, true, false, false]);
|
|
828
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', request, p), expected, 'el orden de la respuesta es el de la petición');
|
|
829
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', [...request].reverse(), p), [...expected].reverse());
|
|
830
|
+
});
|
|
831
|
+
/** A-11 (P4-4a): duplicados permitidos y respondidos por POSICIÓN. */
|
|
832
|
+
test('P4-4a · checkMany con duplicados: [a, a, b] ⇒ [x, x, y] (por posición, sin deduplicar la respuesta)', async ({ assert }) => {
|
|
833
|
+
const u = holder();
|
|
834
|
+
const p = partition();
|
|
835
|
+
const a = { type: 'document', id: docId() };
|
|
836
|
+
const b = { type: 'document', id: docId() };
|
|
837
|
+
await state.manager.relate(u, 'viewer', a, p);
|
|
838
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', [a, a, b], p), [true, true, false]);
|
|
839
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', [b, a, b, a], p), [false, true, false, true]);
|
|
840
|
+
});
|
|
841
|
+
/** A-12 (P4-5): el tope 50 responde; 51 y [] son 422 con CERO llamadas al driver. */
|
|
842
|
+
test(`P4-5 · checkMany con ${RELATIONS_CHECK_MANY_MAX} objetos responde; ${RELATIONS_CHECK_MANY_MAX + 1} ⇒ 422 E_AUTHZ_CHECK_MANY_BATCH y [] ⇒ 422, ambos con CERO llamadas al driver`, async ({ assert, }) => {
|
|
843
|
+
const u = holder();
|
|
844
|
+
const p = partition();
|
|
845
|
+
const fifty = [];
|
|
846
|
+
for (let i = 0; i < RELATIONS_CHECK_MANY_MAX; i++)
|
|
847
|
+
fifty.push({ type: 'document', id: docId() });
|
|
848
|
+
await state.manager.relate(u, 'viewer', fifty[RELATIONS_CHECK_MANY_MAX - 1], p);
|
|
849
|
+
const answers = await state.manager.checkMany(u, 'viewer', fifty, p);
|
|
850
|
+
assert.lengthOf(answers, RELATIONS_CHECK_MANY_MAX);
|
|
851
|
+
assert.isTrue(answers[RELATIONS_CHECK_MANY_MAX - 1]);
|
|
852
|
+
assert.isFalse(answers[0]);
|
|
853
|
+
const before = state.calls();
|
|
854
|
+
for (const [label, objects] of [
|
|
855
|
+
['51', [...fifty, { type: 'document', id: docId() }]],
|
|
856
|
+
['[]', []],
|
|
857
|
+
]) {
|
|
858
|
+
const caught = await state.manager.checkMany(u, 'viewer', objects, p).then(() => null, (e) => e);
|
|
859
|
+
assert.isNotNull(caught, `ROJO (${label}): checkMany respondió en vez de rechazar`);
|
|
860
|
+
assert.equal(caught.status, 422, `${label}: ${caught.message}`);
|
|
861
|
+
assert.equal(caught.code, 'E_AUTHZ_CHECK_MANY_BATCH', label);
|
|
862
|
+
}
|
|
863
|
+
assert.equal(state.calls(), before, 'CERO llamadas al driver: el tope y el lote vacío se juzgan antes');
|
|
864
|
+
});
|
|
865
|
+
/** A-13 (P4-6): F-05 por ÍTEM — un `role_binding` en la ÚLTIMA posición es 422 con cero llamadas. */
|
|
866
|
+
test('P4-6 · checkMany · F-05 por ítem: un role_binding en la posición 21 de 21 ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN con CERO llamadas al driver; un id inválido en la última posición ⇒ 422 E_AUTHZ_INVALID_IDENTITY', async ({ assert, }) => {
|
|
867
|
+
const u = holder();
|
|
868
|
+
const p = partition();
|
|
869
|
+
const twenty = [];
|
|
870
|
+
for (let i = 0; i < 20; i++)
|
|
871
|
+
twenty.push({ type: 'document', id: docId() });
|
|
872
|
+
const before = state.calls();
|
|
873
|
+
const evil = await state.manager.checkMany(u, 'viewer', [...twenty, { type: 'role_binding', id: docId() }], p).then(() => null, (e) => e);
|
|
874
|
+
assert.isNotNull(evil, 'ROJO: un role_binding en la posición 21 entró');
|
|
875
|
+
assert.equal(evil.status, 422, String(evil?.message));
|
|
876
|
+
assert.equal(evil.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN');
|
|
877
|
+
const bad = await state.manager.checkMany(u, 'viewer', [...twenty, { type: 'document', id: 'A#B' }], p).then(() => null, (e) => e);
|
|
878
|
+
assert.equal(bad?.status, 422, String(bad?.message));
|
|
879
|
+
assert.equal(bad?.code, 'E_AUTHZ_INVALID_IDENTITY');
|
|
880
|
+
const undeclared = await state.manager.checkMany(u, 'assignee', twenty, p).then(() => null, (e) => e);
|
|
881
|
+
assert.equal(undeclared?.code, 'E_AUTHZ_RELATION_UNKNOWN', 'la relación no declarada también es 422');
|
|
882
|
+
assert.equal(state.calls(), before, 'CERO llamadas al driver');
|
|
883
|
+
});
|
|
884
|
+
/** A-14 (P4-7, H10): tipos MEZCLADOS con la misma relación responden por ítem (`document` y `workspace`). */
|
|
885
|
+
test('P4-7 · checkMany con tipos mezclados (document y workspace) y la misma relación responde por ítem, con los includes de CADA tipo', async ({ assert, }) => {
|
|
886
|
+
const u = holder();
|
|
887
|
+
const p = partition();
|
|
888
|
+
const doc = { type: 'document', id: docId() };
|
|
889
|
+
const ws = { type: 'workspace', id: docId() };
|
|
890
|
+
const otherWs = { type: 'workspace', id: docId() };
|
|
891
|
+
const otherDoc = { type: 'document', id: docId() };
|
|
892
|
+
await state.manager.relate(u, 'viewer', doc, p);
|
|
893
|
+
await state.manager.relate(u, 'editor', ws, p); // includes del tipo workspace: editor ⊆ viewer
|
|
894
|
+
// El mismo id en los DOS tipos: el tipo forma parte de la identidad (no se cruzan).
|
|
895
|
+
const twin = { type: 'workspace', id: doc.id };
|
|
896
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', [doc, ws, otherWs, otherDoc, twin, ws, doc], p), [true, true, false, false, false, true, true]);
|
|
897
|
+
assert.deepEqual(await state.manager.checkMany(u, 'owner', [doc, ws], p), [false, false], 'owner no se deriva de editor/viewer');
|
|
898
|
+
});
|
|
899
|
+
/* ── D13 · el DRIVER llamado directamente también corta F-05 (plan 2.5 · lote A) ── */
|
|
900
|
+
/**
|
|
901
|
+
* **F-05 vive también en el driver** (D13, decisión del dueño 2026-10-14;
|
|
902
|
+
* tester H8): hasta el lote A el runner solo exigía el corte en el MANAGER,
|
|
903
|
+
* así que un driver de terceros podía no re-validar y pasar el contrato —
|
|
904
|
+
* y por `manager.driver()` o por `reconcileRelations` (que escribe por el
|
|
905
|
+
* driver del destino) componer el id de un binding real en un store
|
|
906
|
+
* compartido. Ahora se llama al driver del harness EN DIRECTO, sin manager:
|
|
907
|
+
* las ocho operaciones (y `checkMany`, y `membersOf` cuando el driver lo
|
|
908
|
+
* trae nativo: 🟡 A7) rechazan un tipo no declarado con el MISMO 422 que
|
|
909
|
+
* el manager, y el `checkMany` directo corta el tope y el lote vacío (⚪ A12). **Ruptura declarada** para un harness de
|
|
910
|
+
* terceros que sembrara tipos no declarados por el driver.
|
|
911
|
+
*/
|
|
912
|
+
test('D13 · F-05 en el DRIVER llamado directamente (sin manager): relate/unrelate/check/checkMany/listObjects/listSubjects/purgeObject/purgeSubject(userset) —y membersOf si es nativo (A7)— contra role_binding ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN, una relación no declarada ⇒ 422 E_AUTHZ_RELATION_UNKNOWN, y checkMany directo corta 51 y [] (A12)', async ({ assert, }) => {
|
|
913
|
+
const driver = state.base;
|
|
914
|
+
const u = holder();
|
|
915
|
+
const S = partition();
|
|
916
|
+
const binding = { type: 'role_binding', id: docId() };
|
|
917
|
+
const doc = { type: 'document', id: docId() };
|
|
918
|
+
const typeAttempts = [
|
|
919
|
+
['relate', () => driver.relate(u, 'assignee', binding, S)],
|
|
920
|
+
['unrelate', () => driver.unrelate(u, 'assignee', binding, S)],
|
|
921
|
+
['check', () => driver.check(u, 'assignee', binding, S)],
|
|
922
|
+
// `viewer` está declarada para `doc` ([0]): lo ÚNICO que falla es el role_binding de [1] (F-05 por ítem).
|
|
923
|
+
['checkMany', () => (typeof driver.checkMany === 'function' ? driver.checkMany(u, 'viewer', [doc, binding], S) : driver.check(u, 'assignee', binding, S))],
|
|
924
|
+
['listObjects', () => driver.listObjects(u, 'assignee', 'role_binding', S)],
|
|
925
|
+
['listSubjects', () => driver.listSubjects('assignee', binding, S)],
|
|
926
|
+
['purgeObject', () => driver.purgeObject(binding, S)],
|
|
927
|
+
['purgeSubject(userset)', () => driver.purgeSubject({ object: binding, relation: 'role' }, S)],
|
|
928
|
+
// 🟡 A7 (auditor del lote A): la NOVENA operación, cuando el driver la trae nativa — un `membersOf`
|
|
929
|
+
// sin F-05 enumera los asignados de un rol del store compartido (la clase del 🟠 2 de alpha.3).
|
|
930
|
+
...(harness.capabilities.membersOfNative && typeof driver.membersOf === 'function'
|
|
931
|
+
? [['membersOf', () => driver.membersOf(binding, 'assignee', S)]]
|
|
932
|
+
: []),
|
|
933
|
+
];
|
|
934
|
+
assert.equal(typeAttempts.length, harness.capabilities.membersOfNative && typeof driver.membersOf === 'function' ? 9 : 8, 'las nueve operaciones del puerto (ocho sin membersOf nativo)');
|
|
935
|
+
for (const [label, run] of typeAttempts) {
|
|
936
|
+
const caught = await run().then(() => null, (e) => e);
|
|
937
|
+
assert.isNotNull(caught, `ROJO (D13 · driver.${label}): el driver ENTRÓ contra role_binding sin el manager delante`);
|
|
938
|
+
assert.equal(caught.status, 422, `driver.${label}: ${caught.message}`);
|
|
939
|
+
assert.equal(caught.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN', `driver.${label}`);
|
|
940
|
+
}
|
|
941
|
+
for (const [label, run] of [
|
|
942
|
+
['relate', () => driver.relate(u, 'assignee', doc, S)],
|
|
943
|
+
['check', () => driver.check(u, 'assignee', doc, S)],
|
|
944
|
+
['listSubjects', () => driver.listSubjects('assignee', doc, S)],
|
|
945
|
+
]) {
|
|
946
|
+
const caught = await run().then(() => null, (e) => e);
|
|
947
|
+
assert.equal(caught?.status, 422, `driver.${label}: ${caught?.message}`);
|
|
948
|
+
assert.equal(caught?.code, 'E_AUTHZ_RELATION_UNKNOWN', `driver.${label}`);
|
|
949
|
+
}
|
|
950
|
+
// ⚪ A12 (auditor del lote A): el tope y el lote vacío también se cortan en el DRIVER directo.
|
|
951
|
+
if (typeof driver.checkMany === 'function') {
|
|
952
|
+
const many = [];
|
|
953
|
+
for (let i = 0; i <= RELATIONS_CHECK_MANY_MAX; i++)
|
|
954
|
+
many.push({ type: 'document', id: docId() });
|
|
955
|
+
for (const [label, objects] of [
|
|
956
|
+
[`${RELATIONS_CHECK_MANY_MAX + 1}`, many],
|
|
957
|
+
['[]', []],
|
|
958
|
+
]) {
|
|
959
|
+
const caught = await driver.checkMany(u, 'viewer', objects, S).then(() => null, (e) => e);
|
|
960
|
+
assert.isNotNull(caught, `ROJO (A12 · driver.checkMany ${label}): el driver directo no corta el lote`);
|
|
961
|
+
assert.equal(caught.status, 422, `driver.checkMany ${label}: ${caught.message}`);
|
|
962
|
+
assert.equal(caught.code, 'E_AUTHZ_CHECK_MANY_BATCH', `driver.checkMany ${label}`);
|
|
963
|
+
}
|
|
964
|
+
assert.lengthOf(await driver.checkMany(u, 'viewer', many.slice(0, RELATIONS_CHECK_MANY_MAX), S), RELATIONS_CHECK_MANY_MAX, 'el tope exacto responde');
|
|
965
|
+
}
|
|
966
|
+
// Control: lo declarado entra por el driver y el store no tiene ni rastro del binding.
|
|
967
|
+
await driver.relate(u, 'viewer', doc, S);
|
|
968
|
+
assert.isTrue(await driver.check(u, 'viewer', doc, S));
|
|
969
|
+
if (typeof driver.enumerateRelations === 'function' && harness.capabilities.enumerateRelations) {
|
|
970
|
+
const page = await driver.enumerateRelations(S);
|
|
971
|
+
assert.isFalse(page.tuples.some((t) => t.object.type === 'role_binding'), 'cero tuplas role_binding');
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
/* ── B3 · el userset del SUJETO: `group#member` y solo `group#member` (plan 2.5 · lote B; cierre 1 · 🟡 5) ── */
|
|
975
|
+
/**
|
|
976
|
+
* **B3 en el runner PUBLICADO** (cierre 1 del lote B · 🟡 5 del auditor):
|
|
977
|
+
* hasta el cierre la variante `usersetUnsupported` solo vivía en la guardia
|
|
978
|
+
* de superficie del paquete, así que un driver de terceros que guardara la
|
|
979
|
+
* fila inerte (`document#viewer` como sujeto) —o respondiera 503— pasaba
|
|
980
|
+
* `runRelationsDriverContract` entero, y el reconcile hacia él y
|
|
981
|
+
* `manager.driver()` repetían los 🟠 1/🟠 2. Es «variante de F-05» (D13):
|
|
982
|
+
* se exige por el MANAGER y por el DRIVER llamado en directo, en las seis
|
|
983
|
+
* operaciones con sujeto, con el `code` propio; por el manager con CERO
|
|
984
|
+
* llamadas al driver (espía) y CERO eventos. El userset legítimo
|
|
985
|
+
* (`group#member`) sigue entrando por los mismos caminos (control).
|
|
986
|
+
*/
|
|
987
|
+
test('B3 · un userset ≠ group#member como SUJETO (document#viewer, declarado) ⇒ 422 E_AUTHZ_RELATION_USERSET_UNSUPPORTED en relate/unrelate/check/checkMany/listObjects/purgeSubject, por el MANAGER (CERO llamadas al driver, CERO eventos) y por el DRIVER en directo; group#member entra (control)', async ({ assert, }) => {
|
|
988
|
+
const S = partition();
|
|
989
|
+
const doc = { type: 'document', id: docId() };
|
|
990
|
+
const other = { type: 'document', id: docId() };
|
|
991
|
+
const stale = { object: other, relation: 'viewer' };
|
|
992
|
+
const attemptsOn = (driver) => [
|
|
993
|
+
['relate', () => driver.relate(stale, 'viewer', doc, S)],
|
|
994
|
+
['unrelate', () => driver.unrelate(stale, 'viewer', doc, S)],
|
|
995
|
+
['check', () => driver.check(stale, 'viewer', doc, S)],
|
|
996
|
+
['checkMany', () => (typeof driver.checkMany === 'function' ? driver.checkMany(stale, 'viewer', [doc], S) : driver.check(stale, 'viewer', doc, S))],
|
|
997
|
+
['listObjects', () => driver.listObjects(stale, 'viewer', 'document', S)],
|
|
998
|
+
['purgeSubject', () => driver.purgeSubject(stale, S)],
|
|
999
|
+
];
|
|
1000
|
+
for (const [path, target] of [
|
|
1001
|
+
['manager', state.manager],
|
|
1002
|
+
['driver', state.base],
|
|
1003
|
+
]) {
|
|
1004
|
+
for (const [label, run] of attemptsOn(target)) {
|
|
1005
|
+
const caught = await run().then(() => null, (e) => e);
|
|
1006
|
+
assert.isNotNull(caught, `ROJO (B3 · ${path}.${label}): el userset document#viewer ENTRÓ como sujeto`);
|
|
1007
|
+
assert.equal(caught.status, 422, `${path}.${label}: ${caught.message}`);
|
|
1008
|
+
assert.equal(caught.code, 'E_AUTHZ_RELATION_USERSET_UNSUPPORTED', `${path}.${label}`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
// Por el manager, «cero llamadas» se observa con el espía.
|
|
1012
|
+
assert.equal(state.calls(), 0, 'por el manager: CERO llamadas al driver');
|
|
1013
|
+
assert.lengthOf(state.events, 0, 'CERO eventos');
|
|
1014
|
+
// Cierre 2 del lote B · 🟡 N4: por el driver en directo el runner SÍ ve su backend — por el PUERTO. Un
|
|
1015
|
+
// tercero que valida DESPUÉS de escribir (`relate` guarda la fila inerte y luego lanza el 422) pasaba el
|
|
1016
|
+
// contrato entero (medido: 58/58 verdes con ese mutante sobre el doble). Se afirma por `listSubjects`
|
|
1017
|
+
// (invariante 7: hechos directos; un userset guardado saldría ahí con `usersetSubjects: true`) y, con la
|
|
1018
|
+
// capacidad, por `enumerateRelations` (la partición entera): la fila NO quedó.
|
|
1019
|
+
// **Límite declarado (cierre 3 · ⚪ N11)**: el runner solo ve el backend por los puertos que el harness
|
|
1020
|
+
// DECLARA. Con `usersetSubjects: false` (`listSubjects` no devuelve usersets) y `enumerateRelations: false`
|
|
1021
|
+
// no hay puerto por el que observar la fila inerte, así que un tercero con ese harness mínimo que guarde y
|
|
1022
|
+
// DESPUÉS lance pasa este caso (medido con el mutante «guarda y luego lanza» sobre el doble mínimo). Declara
|
|
1023
|
+
// las capacidades que tu driver tiene; el runner no puede probar lo que no se le deja ver (README «Third-party
|
|
1024
|
+
// drivers»).
|
|
1025
|
+
const staleKey = `${other.type}:${other.id}#viewer`;
|
|
1026
|
+
const describeSubject = (s) => (isRelUserset(s) ? `${s.object.type}:${s.object.id}#${s.relation}` : `${s.type}:${s.uuid}`);
|
|
1027
|
+
const listed = await state.base.listSubjects('viewer', doc, S);
|
|
1028
|
+
assert.notInclude(listed.subjects.map(describeSubject), staleKey, 'ROJO (N4): el driver GUARDÓ la fila inerte antes de lanzar el 422 — listSubjects la enumera');
|
|
1029
|
+
if (typeof state.base.enumerateRelations === 'function' && harness.capabilities.enumerateRelations) {
|
|
1030
|
+
const page = await state.base.enumerateRelations(S);
|
|
1031
|
+
assert.isFalse(page.tuples.some((t) => describeSubject(t.subject) === staleKey), 'ROJO (N4): la partición contiene la fila inerte que el 422 decía no haber escrito');
|
|
1032
|
+
}
|
|
1033
|
+
// Control: el userset legítimo entra por los dos caminos y concede.
|
|
1034
|
+
const u = holder();
|
|
1035
|
+
const g = { type: 'group', id: docId() };
|
|
1036
|
+
await state.manager.relate(u, 'member', g, S);
|
|
1037
|
+
await state.manager.relate({ object: g, relation: 'member' }, 'viewer', doc, S);
|
|
1038
|
+
assert.isTrue(await state.manager.check(u, 'viewer', doc, S));
|
|
1039
|
+
assert.isTrue(await state.base.check({ object: g, relation: 'member' }, 'viewer', doc, S));
|
|
1040
|
+
await state.base.purgeSubject({ object: g, relation: 'member' }, S);
|
|
1041
|
+
assert.isFalse(await state.manager.check(u, 'viewer', doc, S));
|
|
1042
|
+
});
|
|
723
1043
|
/* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
|
|
724
1044
|
test('R-15 · relate con expiresAt pasado NO concede (check/listObjects/listSubjects); futuro sí; expiresAt inválido ⇒ 422 sin tocar el driver', async ({ assert, }) => {
|
|
725
1045
|
const u = holder();
|
|
@@ -972,6 +1292,32 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
972
1292
|
// El driver del harness (sin reloj inyectado) no se ha tocado: para él
|
|
973
1293
|
// (hoy) T2 —2030— es futuro y la relación renovada concede.
|
|
974
1294
|
assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
|
|
1295
|
+
// **A-18 · un solo `now` por lote** (plan 2.5 · lote A): un reloj que
|
|
1296
|
+
// AVANZA 1 ms por lectura y tres tuplas que caducan en T3+1, T3+2 y
|
|
1297
|
+
// T3+3. `checkMany` lee el reloj UNA vez (T3) y las tres conceden;
|
|
1298
|
+
// un driver que leyera `now()` por ítem vería caducar la segunda o
|
|
1299
|
+
// la tercera entre ítems (mutante: `now()` por ítem ⇒ rojo). Solo
|
|
1300
|
+
// donde el driver trae `checkMany`: el respaldo del manager son N
|
|
1301
|
+
// `check`, cada uno con su instante (y así va escrito en el README).
|
|
1302
|
+
if (typeof state.base.checkMany === 'function') {
|
|
1303
|
+
const T3 = new Date('2031-01-01T00:00:00.000Z');
|
|
1304
|
+
const tickClock = fixedClock(T3);
|
|
1305
|
+
const batch = holder();
|
|
1306
|
+
const b1 = { type: 'document', id: docId() };
|
|
1307
|
+
const b2 = { type: 'document', id: docId() };
|
|
1308
|
+
const b3 = { type: 'document', id: docId() };
|
|
1309
|
+
const ticking = new RelationsManager(state.base.withClock(tickClock.now), contractRelationsConfig());
|
|
1310
|
+
await ticking.relate(batch, 'viewer', b1, p, { expiresAt: new Date(T3.getTime() + 1) });
|
|
1311
|
+
await ticking.relate(batch, 'viewer', b2, p, { expiresAt: new Date(T3.getTime() + 2) });
|
|
1312
|
+
await ticking.relate(batch, 'viewer', b3, p, { expiresAt: new Date(T3.getTime() + 3) });
|
|
1313
|
+
let reads = 0;
|
|
1314
|
+
const advancing = new RelationsManager(state.base.withClock(() => new Date(T3.getTime() + reads++)), contractRelationsConfig());
|
|
1315
|
+
assert.deepEqual(await advancing.checkMany(batch, 'viewer', [b1, b2, b3, b1, b2, b3], p), [true, true, true, true, true, true], 'un solo now por lote: las tres conceden en T3');
|
|
1316
|
+
assert.equal(reads, 1, 'checkMany leyó el reloj EXACTAMENTE una vez para todo el lote');
|
|
1317
|
+
// Y con el reloj ya en T3+3 ninguna concede (estricta): el lote decide con UN instante, no con el primero cacheado.
|
|
1318
|
+
reads = 3;
|
|
1319
|
+
assert.deepEqual(await advancing.checkMany(batch, 'viewer', [b1, b2, b3], p), [false, false, false]);
|
|
1320
|
+
}
|
|
975
1321
|
}),
|
|
976
1322
|
whenFalse: () => test('injectableClock:false · expiresAt en tres estados en tiempo real (1,5 s): omitido preserva, null quita, caducada revive; sin withClock', async ({ assert, }) => {
|
|
977
1323
|
assert.notTypeOf(state.base.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');
|