@jantstack/adonis-authz 2.5.0-alpha.4 → 2.5.0-rc.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 +151 -56
- package/build/commands/authz_catalog_prune_orphans.d.ts +17 -2
- package/build/commands/authz_catalog_prune_orphans.js +28 -5
- package/build/commands/authz_scopes_relay.js +5 -1
- package/build/commands/authz_scopes_requeue.js +2 -1
- package/build/commands/authz_upgrade_1x.d.ts +216 -0
- package/build/commands/authz_upgrade_1x.js +903 -0
- package/build/commands/main.d.ts +2 -1
- package/build/commands/main.js +5 -3
- package/build/index.d.ts +2 -2
- package/build/src/catalog/catalog_cache.js +2 -1
- package/build/src/drivers/database_driver.d.ts +54 -2
- package/build/src/drivers/database_driver.js +138 -23
- package/build/src/drivers/database_relations_driver.js +25 -15
- package/build/src/errors.d.ts +39 -0
- package/build/src/errors.js +8 -0
- package/build/src/identity.d.ts +7 -1
- package/build/src/identity.js +29 -0
- package/build/src/manager.d.ts +24 -0
- package/build/src/manager.js +225 -57
- package/build/src/scope_outbox.d.ts +36 -2
- package/build/src/scope_outbox.js +78 -17
- package/build/src/shared/sql_expiry.d.ts +7 -3
- package/build/src/shared/sql_expiry.js +41 -7
- package/build/src/testing/contract.d.ts +37 -4
- package/build/src/testing/contract.js +280 -22
- package/build/src/testing/relations_contract.d.ts +11 -1
- package/build/src/testing/relations_contract.js +107 -18
- package/build/src/types.d.ts +49 -0
- package/build/src/types.js +2 -0
- package/package.json +1 -1
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import '@japa/assert';
|
|
2
2
|
import { test } from '@japa/runner';
|
|
3
3
|
import { v7 as uuidv7 } from 'uuid';
|
|
4
|
-
import { APP_SCOPE } from '../types.js';
|
|
4
|
+
import { APP_SCOPE, normalizeAuthorizationCapabilities } from '../types.js';
|
|
5
5
|
import { AuthorizationManager } from '../manager.js';
|
|
6
6
|
import db from '@adonisjs/lucid/services/db';
|
|
7
7
|
import { CatalogCache, GLOBAL_OWNER_KEY, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
|
|
8
8
|
import { scopeKey } from '../identity.js';
|
|
9
9
|
import { descendantsFrom, memoryScopeTree, resolveChainFrom } from './scope_tree.js';
|
|
10
|
+
const OPTIONAL_DRIVER_CAPABILITIES = Object.freeze({
|
|
11
|
+
experimental: true,
|
|
12
|
+
snapshotDecisions: true,
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* **Clave D15 OMITIDA ⇒ la declaración del driver** (ensayo rc.1 de COGNITIV, día 1, hallazgo 1; firma del dueño
|
|
16
|
+
* 2026-10-23). Una clave opcional que el harness no declara (`undefined`) no es `false`: el juez la toma de la
|
|
17
|
+
* declaración NORMALIZADA del driver (`normalizeAuthorizationCapabilities`) y juzga ESA cara. Si el harness la declara,
|
|
18
|
+
* manda el harness (y contradecir al driver sigue siendo rojo). Un harness publicado antes de que la clave existiera no
|
|
19
|
+
* puede declararla: tratarla como `false` ponía rojo a un harness verde contra un driver que la declara `true` (el propio
|
|
20
|
+
* `database` con `snapshotDecisions`), que es lo que D15 prohíbe.
|
|
21
|
+
*/
|
|
22
|
+
export function isOmittedOptionalCapability(capabilities, capability) {
|
|
23
|
+
return Object.prototype.hasOwnProperty.call(OPTIONAL_DRIVER_CAPABILITIES, capability) && capabilities[capability] === undefined;
|
|
24
|
+
}
|
|
10
25
|
/** El default de `DriverContractHarness.transactions`: Lucid + `authz_*`. */
|
|
11
26
|
export function lucidContractTransactions() {
|
|
12
27
|
const count = async (table, subject) => {
|
|
@@ -243,6 +258,11 @@ async function rejectsWith(assert, fn, expected) {
|
|
|
243
258
|
}
|
|
244
259
|
assert.fail('debería haber rechazado');
|
|
245
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Presupuesto de un caso interno sin `timeout` propio cuando una cara se juzga dentro de UN caso (clave D15 omitida):
|
|
263
|
+
* el caso que la envuelve suma los de su cara. Solo acota un cuelgue; no es una medida.
|
|
264
|
+
*/
|
|
265
|
+
const OMITTED_CASE_TIMEOUT_MS = 30_000;
|
|
246
266
|
export function runAuthorizationDriverContract(harness) {
|
|
247
267
|
return registerAuthorizationDriverContract(harness, {
|
|
248
268
|
group: (title, define) => test.group(title, define),
|
|
@@ -259,7 +279,21 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
259
279
|
let tree;
|
|
260
280
|
/** Registra el caso solo si el harness pidió ese nivel o uno superior. */
|
|
261
281
|
let registered = 0;
|
|
282
|
+
/**
|
|
283
|
+
* Mientras no es `null`, `test` NO registra: guarda el caso aquí (las caras de una clave D15 omitida, que se juzgan
|
|
284
|
+
* dentro de un único caso cuando el driver ya existe; ver `caseFromDriver`).
|
|
285
|
+
*/
|
|
286
|
+
let capturing = null;
|
|
262
287
|
const test = (title, fn) => {
|
|
288
|
+
if (capturing) {
|
|
289
|
+
const entry = { title, fn };
|
|
290
|
+
capturing.push(entry);
|
|
291
|
+
return {
|
|
292
|
+
timeout(ms) {
|
|
293
|
+
entry.timeout = ms;
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
263
297
|
registered += 1;
|
|
264
298
|
return api.test(title, fn);
|
|
265
299
|
};
|
|
@@ -273,6 +307,8 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
273
307
|
* cara `whenTrue` queda sin cubrir y la suite lanza al cerrar el grupo.
|
|
274
308
|
*/
|
|
275
309
|
function caseFor(capability, pair) {
|
|
310
|
+
if (isOmittedOptionalCapability(harness.capabilities, capability))
|
|
311
|
+
return caseFromDriver(capability, pair);
|
|
276
312
|
const side = harness.capabilities[capability] ? pair.whenTrue : pair.whenFalse;
|
|
277
313
|
if (!side)
|
|
278
314
|
return;
|
|
@@ -284,7 +320,66 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
284
320
|
if (registered > before)
|
|
285
321
|
covered.add(capability);
|
|
286
322
|
}
|
|
287
|
-
|
|
323
|
+
/** Los casos de una cara, sin registrarlos. */
|
|
324
|
+
function captureFace(side) {
|
|
325
|
+
if (!side)
|
|
326
|
+
return [];
|
|
327
|
+
const previous = capturing;
|
|
328
|
+
const into = [];
|
|
329
|
+
capturing = into;
|
|
330
|
+
try {
|
|
331
|
+
side();
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
capturing = previous;
|
|
335
|
+
}
|
|
336
|
+
return into;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* **La cara de una clave D15 que el harness OMITE la elige el DRIVER** (ver `isOmittedOptionalCapability`). Al
|
|
340
|
+
* registrar no hay driver (lo construye `makeDriver` en cada caso, y puede ser asíncrono o tener efectos: crear un
|
|
341
|
+
* store), así que las DOS caras se capturan sin registrarse y se registra UN caso que, ya con el driver del caso,
|
|
342
|
+
* lee su declaración normalizada y corre EN SERIE todos los casos de esa cara, cada uno sobre un estado fresco
|
|
343
|
+
* (`prepareCase`, lo mismo que el `setup` de Japa). Un rojo dice la clave, la declaración del driver y el título del
|
|
344
|
+
* caso interno. Nada se relaja: con `true` se juzga la cara `true` entera, y si esa cara no tiene caso en este nivel
|
|
345
|
+
* es rojo como lo sería declararla (`uncoveredCapabilities`, la misma regla pura). Si ninguna cara tiene caso en este
|
|
346
|
+
* nivel no hay nada observable y no se registra nada (como antes).
|
|
347
|
+
*/
|
|
348
|
+
function caseFromDriver(capability, pair) {
|
|
349
|
+
const faces = { true: captureFace(pair.whenTrue), false: captureFace(pair.whenFalse) };
|
|
350
|
+
if (!faces.true.length && !faces.false.length)
|
|
351
|
+
return;
|
|
352
|
+
const budget = (cases) => cases.reduce((ms, c) => ms + (c.timeout ?? OMITTED_CASE_TIMEOUT_MS), 0);
|
|
353
|
+
const title = `${capability}:(omitida en el harness ⇒ la declaración del driver) · se juzga la cara que el driver declara ` +
|
|
354
|
+
`(true: ${faces.true.length} caso(s) · false: ${faces.false.length}); un harness que la declare explícita manda (D15)`;
|
|
355
|
+
test(title, async ({ assert }) => {
|
|
356
|
+
const declared = normalizeAuthorizationCapabilities(driver.capabilities)[capability] === true;
|
|
357
|
+
const cases = declared ? faces.true : faces.false;
|
|
358
|
+
const uncovered = uncoveredCapabilities({ [capability]: declared }, new Set(cases.length ? [capability] : []));
|
|
359
|
+
if (uncovered.length) {
|
|
360
|
+
throw new Error(`[contrato ${harness.name}] el harness omite '${capability}' y el driver declara '${capability}: ${declared}', pero el ` +
|
|
361
|
+
`contrato no tiene caso para ese valor en el nivel '${level}'. Declara la clave en el harness con lo observable hoy.`);
|
|
362
|
+
}
|
|
363
|
+
for (const [index, entry] of cases.entries()) {
|
|
364
|
+
if (index > 0)
|
|
365
|
+
await prepareCase();
|
|
366
|
+
try {
|
|
367
|
+
await entry.fn({ assert });
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
const prefix = `[${capability}: ${declared} — declaración del driver; el harness la omite] ${entry.title}\n`;
|
|
371
|
+
if (error && typeof error === 'object' && !Object.isFrozen(error)) {
|
|
372
|
+
error.message = prefix + String(error.message ?? '');
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
throw new Error(prefix + String(error?.message ?? error), { cause: error });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
})?.timeout(Math.max(budget(faces.true), budget(faces.false)));
|
|
379
|
+
covered.add(capability);
|
|
380
|
+
}
|
|
381
|
+
/** El estado fresco de cada caso (el `setup` del grupo; `caseFromDriver` lo repite entre sus casos internos). */
|
|
382
|
+
async function prepareCase() {
|
|
288
383
|
await harness.cleanup();
|
|
289
384
|
await harness.seedCatalog(CONTRACT_CATALOG);
|
|
290
385
|
tree = harness.makeTree ? await harness.makeTree() : memoryScopeTree();
|
|
@@ -341,7 +436,8 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
341
436
|
await mirror(() => driver.onScopeMoved?.(child, parent));
|
|
342
437
|
};
|
|
343
438
|
}
|
|
344
|
-
}
|
|
439
|
+
}
|
|
440
|
+
group.each.setup(prepareCase);
|
|
345
441
|
group.teardown(async () => {
|
|
346
442
|
await harness.cleanup();
|
|
347
443
|
});
|
|
@@ -3017,7 +3113,7 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3017
3113
|
// dejaba vivo un mutante que convertía la colisión en un 503.
|
|
3018
3114
|
const raceOfDefines = (serialized) => since('2.2', serialized
|
|
3019
3115
|
? 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: EXACTAMENTE uno gana y el perdedor es 422 E_AUTHZ_CATALOG_CONFLICT — la unicidad no es una carrera'
|
|
3020
|
-
: 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: nunca dos ganadores y el perdedor no escribe (sin escrituras de catálogo serializadas su transacción puede morir con 503)', async ({ assert }) => {
|
|
3116
|
+
: 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: nunca dos ganadores con el MISMO owner (el del ancestro puede ensombrecer al del descendiente) y el perdedor no escribe (sin escrituras de catálogo serializadas su transacción puede morir con 503)', async ({ assert }) => {
|
|
3021
3117
|
// 3D · M2 (auditor V2 🔴 A, reproducido en PG: los dos se insertaban
|
|
3022
3118
|
// porque el unique de la base es (slug, scope_type, owner_scope_key)
|
|
3023
3119
|
// y cada uno comprobaba la colisión contra SU foto del memo). Ahora
|
|
@@ -3060,11 +3156,21 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3060
3156
|
assert.oneOf(ok.length, [1, 2], `un ganador, o el del ancestro ensombreciendo al del descendiente (${estado})`);
|
|
3061
3157
|
}
|
|
3062
3158
|
else {
|
|
3063
|
-
// Sin serialización garantizada
|
|
3064
|
-
//
|
|
3065
|
-
//
|
|
3066
|
-
//
|
|
3067
|
-
|
|
3159
|
+
// Sin serialización garantizada cuántos confirman depende del
|
|
3160
|
+
// motor — SQLite bloquea la base entera y el segundo escritor
|
|
3161
|
+
// puede recibir `SQLITE_BUSY`, un fallo de backend legítimo (503)
|
|
3162
|
+
// porque su transacción NO se aplicó—, así que cero también vale.
|
|
3163
|
+
// Y los mismos DOS finales legales que la cara `true` (rotación 2 ·
|
|
3164
|
+
// cierre 2 · T-07): desde R2-05 el re-chequeo en la transacción
|
|
3165
|
+
// decide por AUTORIDAD, así que si el del descendiente confirma
|
|
3166
|
+
// mientras el del ancestro valida, el del ancestro ENTRA y lo
|
|
3167
|
+
// ensombrece (final 2 de 3G · Y1). Hasta alpha.4 aquí se afirmaba
|
|
3168
|
+
// «como mucho uno», que un backend sin el bloqueo de SQLite podía
|
|
3169
|
+
// poner rojo sin defecto (aserción flaky en el artefacto
|
|
3170
|
+
// publicado). Lo innegociable se afirma abajo igual que en la
|
|
3171
|
+
// cara `true`: nunca dos con el MISMO owner y, si son dos, el
|
|
3172
|
+
// extra es el del ANCESTRO.
|
|
3173
|
+
assert.isAtMost(ok.length, 2, `nunca dos ganadores con el MISMO owner (${estado})`);
|
|
3068
3174
|
}
|
|
3069
3175
|
for (const rejected of results.filter((r) => r.status === 'rejected')) {
|
|
3070
3176
|
const error = rejected.reason;
|
|
@@ -3161,22 +3267,18 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3161
3267
|
await insertRoleUnseen({ slug: 'sigiloso', scopeType: 'unit', owner: scopeKey(orgA) });
|
|
3162
3268
|
await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'sigiloso' }), conflicto);
|
|
3163
3269
|
assert.lengthOf(await rowsNamed('sigiloso', 'unit'), 1, 'no se escribe a ciegas sobre lo que el memo no vio');
|
|
3164
|
-
// Y la rama del re-chequeo que NO
|
|
3165
|
-
// U3, tester 3E · §4.2
|
|
3166
|
-
//
|
|
3167
|
-
//
|
|
3168
|
-
//
|
|
3169
|
-
//
|
|
3170
|
-
// Hasta 3F esa rama solo la observaba la carrera de arriba, y solo
|
|
3171
|
-
// en el harness cuyo árbol SQL ensancha la ventana: aquí va EN
|
|
3172
|
-
// SERIE (el memo se recalienta primero, así que la fila nueva le es
|
|
3173
|
-
// invisible y solo la relectura de la BASE la ve).
|
|
3270
|
+
// Y la rama del re-chequeo de un owner que NO es global ni ancestro-o-igual del owner pedido —una unit
|
|
3271
|
+
// HERMANA— (3F · U3, tester 3E · §4.2; volteada en la rotación 2 de M4 · R2-05). Hasta 2.5.0-alpha.4 se
|
|
3272
|
+
// rechazaba con un 422 «reintenta» que NOMBRABA el owner del hermano (otro tenant) y la colisión era falsa:
|
|
3273
|
+
// dos hermanos pueden compartir slug. Ahora se juzga con la MISMA regla de autoridad que la comprobación
|
|
3274
|
+
// previa y entra. EN SERIE: el memo se recalienta primero, así que la fila nueva le es invisible y solo la
|
|
3275
|
+
// relectura de la BASE la ve.
|
|
3174
3276
|
const unitA2 = await unitUnder(tree, orgA);
|
|
3175
3277
|
assert.deepEqual(await here.listRoles(admin, orgA), ['org-admin'], 'memo recalentado tras el fallo anterior');
|
|
3176
3278
|
await insertRoleUnseen({ slug: 'lateral', scopeType: 'unit', owner: scopeKey(unitA2) });
|
|
3177
|
-
const
|
|
3178
|
-
assert.
|
|
3179
|
-
assert.lengthOf(await rowsNamed('lateral', 'unit'),
|
|
3279
|
+
const lateral = await here.defineScopedRole(admin, unitA1, { ...spec, slug: 'lateral' });
|
|
3280
|
+
assert.equal(lateral.owner, scopeKey(unitA1), 'el homónimo de un HERMANO no es colisión: entra');
|
|
3281
|
+
assert.lengthOf(await rowsNamed('lateral', 'unit'), 2, 'los dos hermanos tienen su lateral@unit');
|
|
3180
3282
|
});
|
|
3181
3283
|
caseFor('serializedCatalogWrites', {
|
|
3182
3284
|
whenTrue: () => raceOfDefines(true),
|
|
@@ -3815,6 +3917,162 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3815
3917
|
});
|
|
3816
3918
|
},
|
|
3817
3919
|
});
|
|
3920
|
+
// ── Rotación 2 · cierre 3 · `snapshotDecisions` (regla D15, linaje L12) ─────────
|
|
3921
|
+
// Una decisión que combina lo que el holder TIENE con lo que tiene DENEGADO, leída en dos métodos del puerto sin
|
|
3922
|
+
// foto común, puede responder lo que ningún estado real responde (un offboarding entre las dos lecturas). La
|
|
3923
|
+
// capacidad dice que el driver lee las dos mitades de una vez; lo que el juez observa es el MÉTODO (`readSnapshot`):
|
|
3924
|
+
// su respuesta es la de los métodos homónimos, y el manager lo usa en vez de componer.
|
|
3925
|
+
const snapshotSeed = async () => {
|
|
3926
|
+
const alice = subject();
|
|
3927
|
+
const orgA = await orgUnder(tree, APP_SCOPE);
|
|
3928
|
+
const unitA1 = await unitUnder(tree, orgA);
|
|
3929
|
+
await driver.grant(alice, 'org-editor', orgA);
|
|
3930
|
+
await driver.grant(alice, 'unit-editor', unitA1);
|
|
3931
|
+
await driver.grant(alice, 'viewer', APP_SCOPE);
|
|
3932
|
+
await driver.deny(alice, 'docs:read', unitA1);
|
|
3933
|
+
await driver.deny(alice, 'billing:read', orgA);
|
|
3934
|
+
return { alice, orgA, unitA1 };
|
|
3935
|
+
};
|
|
3936
|
+
const canonical = (items) => items.map((item) => JSON.stringify(item)).sort();
|
|
3937
|
+
/**
|
|
3938
|
+
* El espía del par (rotación 2 · cierre 3b, tester 🟡 6): cuenta SOLO las llamadas que hace el MANAGER al driver. Cada
|
|
3939
|
+
* método se reenvía con `this` = el driver de verdad (no el proxy), así que lo que un `readSnapshot` llame por dentro
|
|
3940
|
+
* (`this.rolesInChain()` dentro de una transacción REPEATABLE READ, p. ej.) no se cuenta: el juez fija el contrato
|
|
3941
|
+
* (UNA llamada del manager), no la implementación.
|
|
3942
|
+
*/
|
|
3943
|
+
const watchManagerCalls = (target) => {
|
|
3944
|
+
const touched = [];
|
|
3945
|
+
// El proxy va sobre un objeto VACÍO (no sobre el driver): un driver congelado o con métodos no configurables no
|
|
3946
|
+
// choca con las invariantes de `Proxy`, y todo se lee del driver de verdad. Una VISTA que devuelve el driver
|
|
3947
|
+
// (`withChainResolver`, `withClock`: el manager las pide por request) se vigila igual, con la misma cuenta.
|
|
3948
|
+
const watch = (real) => new Proxy({}, {
|
|
3949
|
+
get: (_shell, prop) => {
|
|
3950
|
+
const value = Reflect.get(real, prop, real);
|
|
3951
|
+
if (typeof value === 'function' && typeof prop === 'string') {
|
|
3952
|
+
return (...args) => {
|
|
3953
|
+
touched.push(prop);
|
|
3954
|
+
const out = value.apply(real, args);
|
|
3955
|
+
const isView = out !== null && typeof out === 'object' && typeof out.then !== 'function' && typeof out.authorize === 'function';
|
|
3956
|
+
return isView ? watch(out) : out;
|
|
3957
|
+
};
|
|
3958
|
+
}
|
|
3959
|
+
return value;
|
|
3960
|
+
},
|
|
3961
|
+
has: (_shell, prop) => prop in real,
|
|
3962
|
+
});
|
|
3963
|
+
const watched = watch(target);
|
|
3964
|
+
const reads = () => touched.filter((name) => ['readSnapshot', 'rolesInChain', 'listScopes', 'listDenies', 'listRoles'].includes(name));
|
|
3965
|
+
return { watched, touched, reads };
|
|
3966
|
+
};
|
|
3967
|
+
/**
|
|
3968
|
+
* La parte de DELEGACIÓN de las dos caras (rotación 2 · cierre 3c, A/J1 del auditor y del tester): la policy de
|
|
3969
|
+
* `defineScopedRole` solo se juzga si el harness declara `purgeRole: true`. Sin `purgeRole` el manager no delega
|
|
3970
|
+
* (`defineScopedRole` es 500 `E_AUTHZ_UNSUPPORTED` ANTES de la policy, 3E · P4), y `purgeRole` es un par de `'2.2'`:
|
|
3971
|
+
* un tercero `'2.1'` con `listDenies` y sin `purgeRole` —legítimo, D15— salía ROJO con un caso nuevo. Decide la
|
|
3972
|
+
* DECLARACIÓN del harness, no la presencia del método, por el patrón del juez (como `listDenies` aquí mismo): una
|
|
3973
|
+
* declaración `true` solo es posible en `'2.2'` (en `'2.1'` el juez lanza al registrar un `true` sin caso) y ahí la
|
|
3974
|
+
* juzga su propio par —método presente y purga total, o ausente y 500—, mientras que un método presente en un
|
|
3975
|
+
* harness `'2.1'` no lo juzga nadie. El título de cada cara dice cuál de las dos se registró.
|
|
3976
|
+
*/
|
|
3977
|
+
const snapshotDelegation = harness.capabilities.purgeRole === true;
|
|
3978
|
+
caseFor('snapshotDecisions', {
|
|
3979
|
+
whenTrue: () => {
|
|
3980
|
+
since('2.1', 'snapshotDecisions:true · readSnapshot responde cada lectura pedida EXACTAMENTE como el método homónimo (rolesInChain, listScopes, listDenies), lo no pedido no viene, un permiso fuera del catálogo da listScopes [] y una pregunta mal formada es 422', async ({ assert, }) => {
|
|
3981
|
+
assert.typeOf(driver.readSnapshot, 'function', 'snapshotDecisions: true exige readSnapshot en el puerto');
|
|
3982
|
+
assert.strictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: true y el driver no');
|
|
3983
|
+
assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, true);
|
|
3984
|
+
const { alice, unitA1 } = await snapshotSeed();
|
|
3985
|
+
const chain = (await tree.chainOf(unitA1));
|
|
3986
|
+
const all = await driver.readSnapshot(alice, { rolesInChain: chain, listScopes: 'docs:write', listDenies: true });
|
|
3987
|
+
assert.sameMembers(Object.keys(all), ['rolesInChain', 'listScopes', 'listDenies']);
|
|
3988
|
+
assert.deepEqual(canonical(all.rolesInChain), canonical(await driver.rolesInChain(alice, chain)));
|
|
3989
|
+
assert.deepEqual(canonical(all.listScopes), canonical(await driver.listScopes(alice, 'docs:write')));
|
|
3990
|
+
assert.deepEqual(canonical(all.listDenies), canonical(await driver.listDenies(alice)));
|
|
3991
|
+
assert.lengthOf(all.listDenies, 2);
|
|
3992
|
+
assert.isAbove(all.rolesInChain.length, 0);
|
|
3993
|
+
// Con el deny de docs:read en la unit, listScopes de docs:read no lista la unit (ni lo que bloquea).
|
|
3994
|
+
const reads = await driver.readSnapshot(alice, { listScopes: 'docs:read' });
|
|
3995
|
+
assert.deepEqual(Object.keys(reads), ['listScopes'], 'lo no pedido no viene');
|
|
3996
|
+
assert.deepEqual(canonical(reads.listScopes), canonical(await driver.listScopes(alice, 'docs:read')));
|
|
3997
|
+
assert.deepEqual(await driver.readSnapshot(alice, { listScopes: 'no:existe' }), { listScopes: [] });
|
|
3998
|
+
for (const bad of [{}, { foo: true }, { listDenies: false }, { rolesInChain: 'x' }, null]) {
|
|
3999
|
+
await rejectsWith(assert, () => driver.readSnapshot(alice, bad), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
|
|
4000
|
+
}
|
|
4001
|
+
});
|
|
4002
|
+
since('2.1', snapshotDelegation
|
|
4003
|
+
? 'snapshotDecisions:true · el manager compone effectivePermissions, authorizedScopes y la policy de defineScopedRole con UNA llamada a readSnapshot y CERO a rolesInChain/listScopes/listDenies, con el mismo resultado'
|
|
4004
|
+
: 'snapshotDecisions:true · el manager compone effectivePermissions y authorizedScopes con UNA llamada a readSnapshot y CERO a rolesInChain/listScopes/listDenies, con el mismo resultado (sin purgeRole: el manager no delega y la policy de defineScopedRole no se juzga aquí)', async ({ assert, }) => {
|
|
4005
|
+
const { alice, orgA, unitA1 } = await snapshotSeed();
|
|
4006
|
+
const { watched: spied, touched, reads } = watchManagerCalls(driver);
|
|
4007
|
+
const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
|
|
4008
|
+
assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write']);
|
|
4009
|
+
assert.deepEqual(reads(), ['readSnapshot']);
|
|
4010
|
+
touched.length = 0;
|
|
4011
|
+
// docs:write: org-editor en la org y unit-editor en la unit (viewer en app solo da docs:read) ⇒ la unit.
|
|
4012
|
+
assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
|
|
4013
|
+
assert.deepEqual(reads(), ['readSnapshot']);
|
|
4014
|
+
touched.length = 0;
|
|
4015
|
+
assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
|
|
4016
|
+
assert.deepEqual(reads(), ['readSnapshot']);
|
|
4017
|
+
if (!snapshotDelegation)
|
|
4018
|
+
return;
|
|
4019
|
+
assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
|
|
4020
|
+
// La policy de delegación: el actor tiene docs:read DENEGADO en la unit (owner) ⇒ 422 con UNA foto.
|
|
4021
|
+
const admin = subject();
|
|
4022
|
+
await driver.grant(admin, 'org-admin', orgA);
|
|
4023
|
+
await driver.deny(admin, 'docs:read', orgA);
|
|
4024
|
+
touched.length = 0;
|
|
4025
|
+
await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
|
|
4026
|
+
status: 422,
|
|
4027
|
+
code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
|
|
4028
|
+
});
|
|
4029
|
+
assert.deepEqual(reads(), ['readSnapshot']);
|
|
4030
|
+
});
|
|
4031
|
+
},
|
|
4032
|
+
whenFalse: () => {
|
|
4033
|
+
// Rotación 2 · cierre 3b (H3 del auditor): el manager decide por la CAPACIDAD, no por la presencia del método, así
|
|
4034
|
+
// que esta cara NO exige que el puerto no traiga `readSnapshot` (un tercero puede tener un método con ese nombre
|
|
4035
|
+
// para otra cosa): exige que el manager NO lo llame. Sin `listDenies` no hay composición que juzgar y la cara
|
|
4036
|
+
// se queda en la declaración —lo dice su título (tester 🟡 6)—.
|
|
4037
|
+
if (!harness.capabilities.listDenies) {
|
|
4038
|
+
since('2.1', 'snapshotDecisions:false · (sin listDenies: solo la declaración) authorization.capabilities().snapshotDecisions es false y el driver no declara true; sin listDenies el manager no compone ninguna decisión con deny que juzgar', async ({ assert, }) => {
|
|
4039
|
+
assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
|
|
4040
|
+
assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
|
|
4041
|
+
});
|
|
4042
|
+
return;
|
|
4043
|
+
}
|
|
4044
|
+
since('2.1', snapshotDelegation
|
|
4045
|
+
? 'snapshotDecisions:false · authorization.capabilities().snapshotDecisions es false y el manager compone effectivePermissions, authorizedScopes y la policy de defineScopedRole con rolesInChain/listScopes + listDenies SIN llamar a readSnapshot (aunque el driver traiga un método con ese nombre: decide la capacidad), restando los denies (el hueco DECLARADO de un driver sin la capacidad)'
|
|
4046
|
+
: 'snapshotDecisions:false · authorization.capabilities().snapshotDecisions es false y el manager compone effectivePermissions y authorizedScopes con rolesInChain/listScopes + listDenies SIN llamar a readSnapshot (aunque el driver traiga un método con ese nombre: decide la capacidad), restando los denies (el hueco DECLARADO de un driver sin la capacidad; sin purgeRole: el manager no delega y la policy de defineScopedRole no se juzga aquí)', async ({ assert, }) => {
|
|
4047
|
+
assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
|
|
4048
|
+
assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
|
|
4049
|
+
const { alice, orgA, unitA1 } = await snapshotSeed();
|
|
4050
|
+
const { watched: spied, touched, reads } = watchManagerCalls(driver);
|
|
4051
|
+
const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
|
|
4052
|
+
assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write'], 'el deny de docs:read en la unit se resta');
|
|
4053
|
+
assert.include(reads(), 'listDenies');
|
|
4054
|
+
assert.notInclude(reads(), 'readSnapshot');
|
|
4055
|
+
touched.length = 0;
|
|
4056
|
+
assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
|
|
4057
|
+
assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
|
|
4058
|
+
assert.include(reads(), 'listDenies');
|
|
4059
|
+
assert.notInclude(reads(), 'readSnapshot');
|
|
4060
|
+
if (!snapshotDelegation)
|
|
4061
|
+
return;
|
|
4062
|
+
assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
|
|
4063
|
+
const admin = subject();
|
|
4064
|
+
await driver.grant(admin, 'org-admin', orgA);
|
|
4065
|
+
await driver.deny(admin, 'docs:read', orgA);
|
|
4066
|
+
touched.length = 0;
|
|
4067
|
+
await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
|
|
4068
|
+
status: 422,
|
|
4069
|
+
code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
|
|
4070
|
+
});
|
|
4071
|
+
assert.include(reads(), 'listDenies');
|
|
4072
|
+
assert.notInclude(reads(), 'readSnapshot');
|
|
4073
|
+
});
|
|
4074
|
+
},
|
|
4075
|
+
});
|
|
3818
4076
|
// ── 3b-2e · E2 · `roleInheritanceNative` ─────────────────────────────
|
|
3819
4077
|
caseFor('roleInheritanceNative', {
|
|
3820
4078
|
whenFalse: () => {
|
|
@@ -47,9 +47,19 @@ export declare const RELATIONS_CONTRACT_DENY_TYPES: {
|
|
|
47
47
|
readonly note: undefined;
|
|
48
48
|
};
|
|
49
49
|
export declare function contractDenyRelationsConfig(): RelationsConfig;
|
|
50
|
+
/**
|
|
51
|
+
* **Clave D15 OMITIDA ⇒ la declaración del driver** (gemelo de `isOmittedOptionalCapability` del runner de roles; ensayo
|
|
52
|
+
* rc.1 de COGNITIV, día 1). Una clave opcional que el harness no declara se juzga con la declaración NORMALIZADA del
|
|
53
|
+
* driver del caso (`normalizeRelationsCapabilities`); si el harness la declara, manda el harness.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isOmittedOptionalRelationsCapability(capabilities: RelationsDriverCapabilities, capability: keyof RelationsDriverCapabilities): boolean;
|
|
50
56
|
export interface RelationsDriverContractHarness {
|
|
51
57
|
name: string;
|
|
52
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Lo que este driver DECLARA. Cada valor se juzga con su cara. Una clave OPCIONAL (`?:`, regla D15: `relationDenies`,
|
|
60
|
+
* `atomicRenewal`, `experimental`) que el harness omite se juzga con la declaración normalizada del DRIVER
|
|
61
|
+
* (`isOmittedOptionalRelationsCapability`); declararla fija la cara y el juez exige que el driver diga lo mismo.
|
|
62
|
+
*/
|
|
53
63
|
capabilities: RelationsDriverCapabilities;
|
|
54
64
|
/** Construye el driver sobre la config del contrato (fresco por caso). */
|
|
55
65
|
makeDriver(config: RelationsConfig): RelationsDriver | Promise<RelationsDriver>;
|
|
@@ -28,7 +28,7 @@ import { assertCheckManyObjects, distinctObjects, expandByPosition, RELATIONS_CH
|
|
|
28
28
|
import { assertEnumeratePage, assertNoListPage } from '../relations/page.js';
|
|
29
29
|
import { systemClock } from '../clock.js';
|
|
30
30
|
import { resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
31
|
-
import { isRelUserset } from '../types.js';
|
|
31
|
+
import { isRelUserset, normalizeRelationsCapabilities } from '../types.js';
|
|
32
32
|
/* ── El vocabulario del contrato ─────────────────────────────────────────── */
|
|
33
33
|
/** Los holder types del contrato de relaciones. */
|
|
34
34
|
export const RELATIONS_HOLDER_TYPES = ['user', 'admin', 'integration'];
|
|
@@ -101,6 +101,21 @@ export function contractDenyRelationsConfig() {
|
|
|
101
101
|
database: { membersOf: true },
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
|
+
const OPTIONAL_RELATIONS_CAPABILITIES = Object.freeze({
|
|
105
|
+
relationDenies: true,
|
|
106
|
+
atomicRenewal: true,
|
|
107
|
+
experimental: true,
|
|
108
|
+
});
|
|
109
|
+
/**
|
|
110
|
+
* **Clave D15 OMITIDA ⇒ la declaración del driver** (gemelo de `isOmittedOptionalCapability` del runner de roles; ensayo
|
|
111
|
+
* rc.1 de COGNITIV, día 1). Una clave opcional que el harness no declara se juzga con la declaración NORMALIZADA del
|
|
112
|
+
* driver del caso (`normalizeRelationsCapabilities`); si el harness la declara, manda el harness.
|
|
113
|
+
*/
|
|
114
|
+
export function isOmittedOptionalRelationsCapability(capabilities, capability) {
|
|
115
|
+
return Object.prototype.hasOwnProperty.call(OPTIONAL_RELATIONS_CAPABILITIES, capability) && capabilities[capability] === undefined;
|
|
116
|
+
}
|
|
117
|
+
/** Presupuesto de un caso interno sin `timeout` propio cuando una cara se juzga dentro de UN caso (clave D15 omitida). */
|
|
118
|
+
const OMITTED_CASE_TIMEOUT_MS = 30_000;
|
|
104
119
|
/** El default de `RelationsDriverContractHarness.transactions`: Lucid + `authz_relations`. */
|
|
105
120
|
export function lucidRelationsContractTransactions() {
|
|
106
121
|
return {
|
|
@@ -668,22 +683,94 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
668
683
|
base,
|
|
669
684
|
};
|
|
670
685
|
}
|
|
686
|
+
/** El estado fresco de cada caso (el `setup` del grupo; `caseFromDriver` lo repite entre sus casos internos). */
|
|
687
|
+
async function prepareCase() {
|
|
688
|
+
// alpha.3: el manager de cada caso captura `onRelationWrite` — el par
|
|
689
|
+
// `transactionalWrites` juzga también el EVENTO (la otra cara observable
|
|
690
|
+
// de la capacidad) y R-07/R-08 juzgan el evento de la purga.
|
|
691
|
+
state.events = [];
|
|
692
|
+
const f = await freshManager({ onRelationWrite: (e) => void state.events.push(e) });
|
|
693
|
+
state.manager = f.manager;
|
|
694
|
+
state.base = f.base;
|
|
695
|
+
state.writes = f.writes;
|
|
696
|
+
state.calls = f.calls;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Lo que se JUZGA de una capacidad dentro de un caso: lo que declara el harness, o —si omite una clave opcional (D15)—
|
|
700
|
+
* la declaración normalizada del driver del caso. Lo leen los casos que dependen de otra capacidad (p. ej. los de
|
|
701
|
+
* `atomicRenewal` siembran el deny si `relationDenies`): con la clave omitida no pueden leer `undefined` como `false`.
|
|
702
|
+
*/
|
|
703
|
+
function judged(capability) {
|
|
704
|
+
if (isOmittedOptionalRelationsCapability(harness.capabilities, capability)) {
|
|
705
|
+
return normalizeRelationsCapabilities(state.base?.capabilities)[capability] === true;
|
|
706
|
+
}
|
|
707
|
+
return harness.capabilities[capability] === true;
|
|
708
|
+
}
|
|
671
709
|
api.group(`relations contract [${harness.name}]`, (group) => {
|
|
672
|
-
group.each.setup(
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
// de la capacidad) y R-07/R-08 juzgan el evento de la purga.
|
|
676
|
-
state.events = [];
|
|
677
|
-
const f = await freshManager({ onRelationWrite: (e) => void state.events.push(e) });
|
|
678
|
-
state.manager = f.manager;
|
|
679
|
-
state.base = f.base;
|
|
680
|
-
state.writes = f.writes;
|
|
681
|
-
state.calls = f.calls;
|
|
682
|
-
});
|
|
710
|
+
group.each.setup(prepareCase);
|
|
711
|
+
/** Mientras no es `null`, `test` guarda el caso aquí en vez de registrarlo (ver `caseFromDriver`). */
|
|
712
|
+
let capturing = null;
|
|
683
713
|
const test = (title, fn) => {
|
|
714
|
+
if (capturing) {
|
|
715
|
+
const entry = { title, fn };
|
|
716
|
+
capturing.push(entry);
|
|
717
|
+
return {
|
|
718
|
+
timeout(ms) {
|
|
719
|
+
entry.timeout = ms;
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
684
723
|
registered++;
|
|
685
724
|
return api.test(title, fn);
|
|
686
725
|
};
|
|
726
|
+
function captureFace(side) {
|
|
727
|
+
const previous = capturing;
|
|
728
|
+
const into = [];
|
|
729
|
+
capturing = into;
|
|
730
|
+
try {
|
|
731
|
+
side();
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
capturing = previous;
|
|
735
|
+
}
|
|
736
|
+
return into;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* **La cara de una clave D15 que el harness OMITE la elige el DRIVER** (gemelo de `caseFromDriver` del runner de
|
|
740
|
+
* roles). Las dos caras se capturan sin registrarse y se registra UN caso que, con el driver del caso, lee su
|
|
741
|
+
* declaración normalizada y corre en serie todos los casos de esa cara, cada uno sobre un estado fresco
|
|
742
|
+
* (`prepareCase`). Nada se relaja: si el driver declara `true` se juzga la cara `true` entera.
|
|
743
|
+
*/
|
|
744
|
+
function caseFromDriver(capability, pair) {
|
|
745
|
+
const faces = { true: captureFace(pair.whenTrue), false: captureFace(pair.whenFalse) };
|
|
746
|
+
const budget = (cases) => cases.reduce((ms, c) => ms + (c.timeout ?? OMITTED_CASE_TIMEOUT_MS), 0);
|
|
747
|
+
const title = `${capability}:(omitida en el harness ⇒ la declaración del driver) · se juzga la cara que el driver declara ` +
|
|
748
|
+
`(true: ${faces.true.length} caso(s) · false: ${faces.false.length}); un harness que la declare explícita manda (D15)`;
|
|
749
|
+
test(title, async ({ assert }) => {
|
|
750
|
+
const declared = normalizeRelationsCapabilities(state.base?.capabilities)[capability] === true;
|
|
751
|
+
const cases = declared ? faces.true : faces.false;
|
|
752
|
+
if (!cases.length) {
|
|
753
|
+
throw new Error(`[contrato ${harness.name}] el harness omite '${capability}' y el driver declara '${capability}: ${declared}', pero ` +
|
|
754
|
+
`esa cara no registró ningún caso: una cara vacía es un skip disfrazado.`);
|
|
755
|
+
}
|
|
756
|
+
for (const [index, entry] of cases.entries()) {
|
|
757
|
+
if (index > 0)
|
|
758
|
+
await prepareCase();
|
|
759
|
+
try {
|
|
760
|
+
await entry.fn({ assert });
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
const prefix = `[${capability}: ${declared} — declaración del driver; el harness la omite] ${entry.title}\n`;
|
|
764
|
+
if (error && typeof error === 'object' && !Object.isFrozen(error)) {
|
|
765
|
+
error.message = prefix + String(error.message ?? '');
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
throw new Error(prefix + String(error?.message ?? error), { cause: error });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
})?.timeout(Math.max(budget(faces.true), budget(faces.false)));
|
|
772
|
+
covered.add(capability);
|
|
773
|
+
}
|
|
687
774
|
function caseFor(capability, pair) {
|
|
688
775
|
// MÁS ESTRICTO que el guard de roles: las DOS caras tienen que existir
|
|
689
776
|
// en el código del runner, o es un skip disfrazado (exigencia del tester).
|
|
@@ -691,6 +778,8 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
691
778
|
throw new Error(`[contrato ${harness.name}] la capacidad '${capability}' no declara sus dos caras ` +
|
|
692
779
|
`(whenTrue Y whenFalse): una cara ausente es un skip disfrazado.`);
|
|
693
780
|
}
|
|
781
|
+
if (isOmittedOptionalRelationsCapability(harness.capabilities, capability))
|
|
782
|
+
return caseFromDriver(capability, pair);
|
|
694
783
|
const before = registered;
|
|
695
784
|
(harness.capabilities[capability] ? pair.whenTrue : pair.whenFalse)();
|
|
696
785
|
if (registered > before)
|
|
@@ -860,7 +949,7 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
860
949
|
assert.notProperty(members, 'cursor', 'membersOf sin page no devuelve cursor');
|
|
861
950
|
assert.deepEqual(ids(members.subjects), expected, `membersOf devuelve los ${N}`);
|
|
862
951
|
}
|
|
863
|
-
if (
|
|
952
|
+
if (judged('relationDenies')) {
|
|
864
953
|
const { manager } = await denyManager();
|
|
865
954
|
const denied = { type: 'document', id: docId() };
|
|
866
955
|
for (const u of users)
|
|
@@ -1984,20 +2073,20 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
1984
2073
|
}
|
|
1985
2074
|
caseFor('atomicRenewal', {
|
|
1986
2075
|
whenTrue: () => test('atomicRenewal:true · renovar (otra caducidad) una membresía que TRANSPORTA un deny —directa y ANIDADA— entra en UNA escritura: la caducidad cambia y el deny sigue negando', async ({ assert }) => {
|
|
1987
|
-
const { manager, base } =
|
|
1988
|
-
const { u, p, x, g, h, k } = await seedRenewal(manager,
|
|
2076
|
+
const { manager, base } = judged('relationDenies') ? await denyManager() : { manager: state.manager, base: state.base };
|
|
2077
|
+
const { u, p, x, g, h, k } = await seedRenewal(manager, judged('relationDenies'));
|
|
1989
2078
|
for (const group of [g, h, k]) {
|
|
1990
2079
|
await manager.relate(u, 'member', group, p, { expiresAt: null });
|
|
1991
2080
|
const expiry = await membershipExpiry(base, u, group, p);
|
|
1992
2081
|
if (expiry !== undefined)
|
|
1993
2082
|
assert.isNull(expiry, `la membresía de ${group.id} quedó SIN caducidad`);
|
|
1994
2083
|
}
|
|
1995
|
-
const carried =
|
|
2084
|
+
const carried = judged('relationDenies') && harness.capabilities.usersetSubjects === true;
|
|
1996
2085
|
assert.equal(await manager.check(u, 'viewer', x, p), !carried, 'el deny transportado sigue negando tras renovar');
|
|
1997
2086
|
}),
|
|
1998
2087
|
whenFalse: () => test('atomicRenewal:false · renovar una membresía que TRANSPORTA un deny vivo —directa o por un grupo que la contiene— ⇒ 409 E_AUTHZ_RENEWAL_UNSAFE con la receta y CERO escrituras (la caducidad no cambia, el deny sigue negando); una membresía sin deny se renueva', async ({ assert }) => {
|
|
1999
|
-
const denies =
|
|
2000
|
-
const { manager, base } =
|
|
2088
|
+
const denies = judged('relationDenies') && harness.capabilities.usersetSubjects === true;
|
|
2089
|
+
const { manager, base } = judged('relationDenies') ? await denyManager() : { manager: state.manager, base: state.base };
|
|
2001
2090
|
const { u, p, x, g, h, k, hour } = await seedRenewal(manager, denies);
|
|
2002
2091
|
if (denies) {
|
|
2003
2092
|
for (const group of [g, h]) {
|