@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.0-alpha.10
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 +496 -29
- package/build/commands/authz_catalog_diff.d.ts.map +1 -1
- package/build/commands/authz_catalog_diff.js +0 -1
- package/build/commands/authz_catalog_diff.js.map +1 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -1
- package/build/commands/authz_catalog_prune_orphans.js +0 -1
- package/build/commands/authz_catalog_prune_orphans.js.map +1 -1
- package/build/commands/authz_freeze.d.ts.map +1 -1
- package/build/commands/authz_freeze.js +1 -2
- package/build/commands/authz_freeze.js.map +1 -1
- package/build/commands/authz_reconcile.d.ts +15 -6
- package/build/commands/authz_reconcile.d.ts.map +1 -1
- package/build/commands/authz_reconcile.js +143 -20
- package/build/commands/authz_reconcile.js.map +1 -1
- package/build/commands/authz_relations_reconcile.d.ts +72 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +234 -21
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/commands/authz_scopes_relay.d.ts +50 -0
- package/build/commands/authz_scopes_relay.d.ts.map +1 -1
- package/build/commands/authz_scopes_relay.js +213 -10
- package/build/commands/authz_scopes_relay.js.map +1 -1
- package/build/commands/authz_scopes_requeue.d.ts +80 -0
- package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
- package/build/commands/authz_scopes_requeue.js +230 -0
- package/build/commands/authz_scopes_requeue.js.map +1 -0
- 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/commands/main.d.ts +16 -13
- package/build/commands/main.d.ts.map +1 -1
- package/build/commands/main.js +59 -11
- package/build/commands/main.js.map +1 -1
- package/build/commands/openfga_provision.d.ts +49 -3
- package/build/commands/openfga_provision.d.ts.map +1 -1
- package/build/commands/openfga_provision.js +72 -7
- package/build/commands/openfga_provision.js.map +1 -1
- package/build/index.d.ts +15 -5
- package/build/index.d.ts.map +1 -1
- package/build/index.js +16 -3
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +7 -2
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +20 -3
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +106 -1
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js +11 -0
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts +53 -14
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +104 -32
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +74 -9
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +391 -93
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +71 -10
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +195 -16
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +15 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -1
- package/build/src/drivers/openfga_facts.js +25 -2
- package/build/src/drivers/openfga_facts.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +72 -6
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +308 -50
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +175 -4
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +219 -3
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +100 -20
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +159 -25
- package/build/src/freeze.js.map +1 -1
- package/build/src/manager.d.ts +239 -7
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +1192 -148
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +2 -2
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +2 -2
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +2 -2
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +2 -2
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +2 -2
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.js +11 -3
- package/build/src/relation_partition_trigger.js.map +1 -1
- package/build/src/relations/define_relations_config.d.ts +51 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +68 -1
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +106 -6
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +368 -30
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +81 -6
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +233 -77
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/scope_outbox.d.ts +54 -0
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +274 -33
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/backend_guard.d.ts +28 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +52 -0
- 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/shared/transaction_guard.d.ts +53 -0
- package/build/src/shared/transaction_guard.d.ts.map +1 -0
- package/build/src/shared/transaction_guard.js +74 -0
- package/build/src/shared/transaction_guard.js.map +1 -0
- package/build/src/sql_descendants.d.ts +8 -0
- package/build/src/sql_descendants.d.ts.map +1 -1
- package/build/src/sql_descendants.js.map +1 -1
- package/build/src/testing/contract.d.ts +55 -4
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +244 -9
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +6 -4
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +3 -2
- 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 +23 -1
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +603 -19
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
- package/build/src/testing/relations_reconcile_contract.js +49 -1
- package/build/src/testing/relations_reconcile_contract.js.map +1 -1
- package/build/src/testing/scope_outbox_contract.d.ts +98 -0
- package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
- package/build/src/testing/scope_outbox_contract.js +677 -0
- package/build/src/testing/scope_outbox_contract.js.map +1 -0
- package/build/src/traits/has_uuid.d.ts +2 -2
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +708 -38
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +55 -0
- package/build/src/types.js.map +1 -1
- package/build/stubs/config/authorization.stub +45 -1
- package/build/stubs/migration.stub +20 -10
- package/package.json +1 -1
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { v7 as uuidv7 } from 'uuid';
|
|
21
21
|
import { test as japaTest } from '@japa/runner';
|
|
22
|
+
import db from '@adonisjs/lucid/services/db';
|
|
23
|
+
import { scopeKey } from '../identity.js';
|
|
22
24
|
import { RelationsManager } from '../relations/manager.js';
|
|
23
25
|
import { defineRelationsConfig } from '../relations/define_relations_config.js';
|
|
26
|
+
import { systemClock } from '../clock.js';
|
|
27
|
+
import { resolveGrantExpiry } from '../expiry.js';
|
|
24
28
|
import { isRelUserset } from '../types.js';
|
|
25
29
|
/* ── El vocabulario del contrato ─────────────────────────────────────────── */
|
|
26
30
|
/** Los holder types del contrato de relaciones. */
|
|
@@ -45,6 +49,19 @@ export function contractRelationsConfig() {
|
|
|
45
49
|
database: { membersOf: true },
|
|
46
50
|
});
|
|
47
51
|
}
|
|
52
|
+
/** El default de `RelationsDriverContractHarness.transactions`: Lucid + `authz_relations`. */
|
|
53
|
+
export function lucidRelationsContractTransactions() {
|
|
54
|
+
return {
|
|
55
|
+
async begin() {
|
|
56
|
+
const trx = await db.transaction();
|
|
57
|
+
return { client: trx, commit: () => trx.commit(), rollback: () => trx.rollback() };
|
|
58
|
+
},
|
|
59
|
+
async census(partition) {
|
|
60
|
+
const rows = await db.from('authz_relations').where('partition_key', scopeKey(partition)).count('* as n');
|
|
61
|
+
return Number(rows[0]?.n ?? 0);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
48
65
|
function scopeKeyOf(scope) {
|
|
49
66
|
return scope.type === 'app' ? 'app' : `${scope.type}|${scope.uuid}`;
|
|
50
67
|
}
|
|
@@ -59,8 +76,19 @@ function subjectKeyOf(subject) {
|
|
|
59
76
|
* que prueba el CONTRATO —no el MODELO fusionado (eso es el `:8101` en 4-4)—.
|
|
60
77
|
*/
|
|
61
78
|
export function makeRelationsDriver(options) {
|
|
79
|
+
return buildRelationsDouble(options, [], systemClock);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* El doble, sobre un conjunto de tuplas COMPARTIDO y un reloj: `withClock`
|
|
83
|
+
* (solo con `injectableClock: true`) devuelve otra vista sobre las MISMAS
|
|
84
|
+
* tuplas con otro `now`, como los drivers reales (R-15).
|
|
85
|
+
*/
|
|
86
|
+
function buildRelationsDouble(options, tuples, now) {
|
|
62
87
|
const { config, capabilities, limits } = options;
|
|
63
|
-
|
|
88
|
+
/** Caducidad ESTRICTA (R-15): la que vence AHORA ya no cuenta. */
|
|
89
|
+
function active(t) {
|
|
90
|
+
return t.expiresAt === null || t.expiresAt.getTime() > now().getTime();
|
|
91
|
+
}
|
|
64
92
|
/**
|
|
65
93
|
* `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
|
|
66
94
|
* satisface R. `viewer includes editor` ⇒ un `editor` es también `viewer`,
|
|
@@ -84,11 +112,13 @@ export function makeRelationsDriver(options) {
|
|
|
84
112
|
}
|
|
85
113
|
return out;
|
|
86
114
|
}
|
|
115
|
+
/** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
|
|
87
116
|
function directSubjects(partition, objectType, objectId, relation) {
|
|
88
117
|
return tuples.filter((t) => t.partition === partition &&
|
|
89
118
|
t.objectType === objectType &&
|
|
90
119
|
t.objectId === objectId &&
|
|
91
|
-
t.relation === relation
|
|
120
|
+
t.relation === relation &&
|
|
121
|
+
active(t));
|
|
92
122
|
}
|
|
93
123
|
function resolves(subject, relation, object, partition, seen = new Set()) {
|
|
94
124
|
const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
|
|
@@ -142,24 +172,30 @@ export function makeRelationsDriver(options) {
|
|
|
142
172
|
}
|
|
143
173
|
const driver = {
|
|
144
174
|
capabilities,
|
|
145
|
-
async relate(subject, relation, object, partition) {
|
|
175
|
+
async relate(subject, relation, object, partition, options) {
|
|
146
176
|
const partitionKey = scopeKeyOf(partition);
|
|
147
177
|
const subjectKey = subjectKeyOf(subject);
|
|
148
|
-
const
|
|
178
|
+
const existing = tuples.find((t) => t.partition === partitionKey &&
|
|
149
179
|
t.objectType === object.type &&
|
|
150
180
|
t.objectId === object.id &&
|
|
151
181
|
t.relation === relation &&
|
|
152
182
|
t.subjectKey === subjectKey);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
subject,
|
|
160
|
-
subjectKey,
|
|
161
|
-
});
|
|
183
|
+
// R-15, los tres estados (invariante 10): omitido preserva la vigente
|
|
184
|
+
// (una caducada revive sin caducidad), null la quita, Date la fija.
|
|
185
|
+
const expiresAt = resolveGrantExpiry(existing?.expiresAt ?? null, options?.expiresAt, now());
|
|
186
|
+
if (existing) {
|
|
187
|
+
existing.expiresAt = expiresAt;
|
|
188
|
+
return;
|
|
162
189
|
}
|
|
190
|
+
tuples.push({
|
|
191
|
+
partition: partitionKey,
|
|
192
|
+
objectType: object.type,
|
|
193
|
+
objectId: object.id,
|
|
194
|
+
relation,
|
|
195
|
+
subject,
|
|
196
|
+
subjectKey,
|
|
197
|
+
expiresAt,
|
|
198
|
+
});
|
|
163
199
|
},
|
|
164
200
|
async unrelate(subject, relation, object, partition) {
|
|
165
201
|
const partitionKey = scopeKeyOf(partition);
|
|
@@ -182,7 +218,7 @@ export function makeRelationsDriver(options) {
|
|
|
182
218
|
const partitionKey = scopeKeyOf(partition);
|
|
183
219
|
const ids = new Set();
|
|
184
220
|
for (const t of tuples) {
|
|
185
|
-
if (t.partition === partitionKey && t.objectType === objectType)
|
|
221
|
+
if (t.partition === partitionKey && t.objectType === objectType && active(t))
|
|
186
222
|
ids.add(t.objectId);
|
|
187
223
|
}
|
|
188
224
|
const matches = [];
|
|
@@ -238,6 +274,8 @@ export function makeRelationsDriver(options) {
|
|
|
238
274
|
if (capabilities.enumerateRelations) {
|
|
239
275
|
driver.enumerateRelations = async (partition) => {
|
|
240
276
|
const partitionKey = scopeKeyOf(partition);
|
|
277
|
+
// SIN filtrar la caducada (R-15): llega con su `expiresAt` para que el
|
|
278
|
+
// destino de reconcile la cuente en `skipped`, no para que desaparezca.
|
|
241
279
|
const out = tuples
|
|
242
280
|
.filter((t) => t.partition === partitionKey)
|
|
243
281
|
.map((t) => ({
|
|
@@ -245,12 +283,30 @@ export function makeRelationsDriver(options) {
|
|
|
245
283
|
relation: t.relation,
|
|
246
284
|
object: { type: t.objectType, id: t.objectId },
|
|
247
285
|
partition,
|
|
286
|
+
expiresAt: t.expiresAt,
|
|
248
287
|
}));
|
|
249
288
|
return { tuples: out };
|
|
250
289
|
};
|
|
251
290
|
}
|
|
291
|
+
if (capabilities.injectableClock) {
|
|
292
|
+
// La vista con otro reloj comparte las tuplas (como `Object.create(this)`
|
|
293
|
+
// en los drivers reales). Solo existe si se declara: la cara `false` del
|
|
294
|
+
// juez exige que un driver sin la capacidad NO traiga `withClock`.
|
|
295
|
+
driver.withClock = (next) => buildRelationsDouble(options, tuples, next);
|
|
296
|
+
}
|
|
252
297
|
return driver;
|
|
253
298
|
}
|
|
299
|
+
/** El reloj fijo del juez (el mismo patrón que `fixedClock` del runner de roles). */
|
|
300
|
+
function fixedClock(start) {
|
|
301
|
+
let current = new Date(start.getTime());
|
|
302
|
+
return {
|
|
303
|
+
now: () => new Date(current.getTime()),
|
|
304
|
+
set: (at) => {
|
|
305
|
+
current = new Date(at.getTime());
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
254
310
|
/* ── El runner ───────────────────────────────────────────────────────────── */
|
|
255
311
|
function holder() {
|
|
256
312
|
return { type: 'user', uuid: uuidv7() };
|
|
@@ -271,11 +327,22 @@ function partition() {
|
|
|
271
327
|
*/
|
|
272
328
|
function spy(driver) {
|
|
273
329
|
let writes = 0;
|
|
330
|
+
let calls = 0;
|
|
274
331
|
const wrapped = new Proxy(driver, {
|
|
275
332
|
get(target, prop, receiver) {
|
|
276
|
-
|
|
333
|
+
// L-2: las CUATRO escrituras (también `purge*`, que reciben `{ transaction }`).
|
|
334
|
+
if (prop === 'relate' || prop === 'unrelate' || prop === 'purgeObject' || prop === 'purgeSubject') {
|
|
277
335
|
return async (...args) => {
|
|
278
336
|
writes++;
|
|
337
|
+
calls++;
|
|
338
|
+
return target[prop](...args);
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
// alpha.3 (cierre) · las TRES lecturas también cuentan: F-05 tiene que
|
|
342
|
+
// cortarlas ANTES del driver («cero llamadas» = ni una lectura).
|
|
343
|
+
if (prop === 'check' || prop === 'listObjects' || prop === 'listSubjects') {
|
|
344
|
+
return async (...args) => {
|
|
345
|
+
calls++;
|
|
279
346
|
return target[prop](...args);
|
|
280
347
|
};
|
|
281
348
|
}
|
|
@@ -283,7 +350,7 @@ function spy(driver) {
|
|
|
283
350
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
284
351
|
},
|
|
285
352
|
});
|
|
286
|
-
return { driver: wrapped, writes: () => writes };
|
|
353
|
+
return { driver: wrapped, writes: () => writes, calls: () => calls };
|
|
287
354
|
}
|
|
288
355
|
export function runRelationsDriverContract(harness) {
|
|
289
356
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
@@ -316,18 +383,30 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
316
383
|
async function freshManager(options) {
|
|
317
384
|
const base = await harness.makeDriver(contractRelationsConfig());
|
|
318
385
|
const s = spy(base);
|
|
319
|
-
|
|
386
|
+
// L-2: el manager nombra al driver en las dos puertas de `{ transaction }`
|
|
387
|
+
// como lo haría el provider (la clave en `relations.drivers`): aquí, el harness.
|
|
388
|
+
return {
|
|
389
|
+
manager: new RelationsManager(s.driver, contractRelationsConfig(), { driverName: harness.name, ...options }),
|
|
390
|
+
writes: s.writes,
|
|
391
|
+
calls: s.calls,
|
|
392
|
+
base,
|
|
393
|
+
};
|
|
320
394
|
}
|
|
321
395
|
api.group(`relations contract [${harness.name}]`, (group) => {
|
|
322
396
|
group.each.setup(async () => {
|
|
323
|
-
|
|
397
|
+
// alpha.3: el manager de cada caso captura `onRelationWrite` — el par
|
|
398
|
+
// `transactionalWrites` juzga también el EVENTO (la otra cara observable
|
|
399
|
+
// de la capacidad) y R-07/R-08 juzgan el evento de la purga.
|
|
400
|
+
state.events = [];
|
|
401
|
+
const f = await freshManager({ onRelationWrite: (e) => void state.events.push(e) });
|
|
324
402
|
state.manager = f.manager;
|
|
325
403
|
state.base = f.base;
|
|
326
404
|
state.writes = f.writes;
|
|
405
|
+
state.calls = f.calls;
|
|
327
406
|
});
|
|
328
407
|
const test = (title, fn) => {
|
|
329
408
|
registered++;
|
|
330
|
-
api.test(title, fn);
|
|
409
|
+
return api.test(title, fn);
|
|
331
410
|
};
|
|
332
411
|
function caseFor(capability, pair) {
|
|
333
412
|
// MÁS ESTRICTO que el guard de roles: las DOS caras tienen que existir
|
|
@@ -472,6 +551,80 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
472
551
|
assert.equal(caught.code, 'E_AUTHZ_RELATION_UNKNOWN');
|
|
473
552
|
assert.equal(state.writes(), 0);
|
|
474
553
|
});
|
|
554
|
+
/**
|
|
555
|
+
* **F-05 en las PURGAS y las LECTURAS** (2.4.0-alpha.3, cierre del 🔴 1 /
|
|
556
|
+
* 🟠 2 del auditor). Hasta el cierre F-05 solo cubría `relate`/`unrelate`,
|
|
557
|
+
* y en el store COMPARTIDO de `openfga` `purgeObject({ type:
|
|
558
|
+
* 'role_binding', id: R }, S)` borraba el binding REAL de un rol
|
|
559
|
+
* (`authorize` de `true` a `false`, evento de auditoría limpio) y
|
|
560
|
+
* `listSubjects('assignee', role_binding)` enumeraba sus asignados sin
|
|
561
|
+
* catálogo ni `within`. Un driver de terceros que no lo corte por el
|
|
562
|
+
* manager NO pasa: cero llamadas al driver (ni escrituras ni lecturas),
|
|
563
|
+
* cero eventos. Una purga o un `listObjects` validan el TIPO; `check`/
|
|
564
|
+
* `listSubjects` el par; `purgeSubject` el userset del sujeto.
|
|
565
|
+
*/
|
|
566
|
+
test('F-05 · purgeObject/purgeSubject(userset)/check/listObjects/listSubjects/membersOf contra un tipo NO declarado (role_binding) ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN con CERO llamadas al driver y CERO eventos; check/listObjects/listSubjects/membersOf con una relación no declarada, y relate/unrelate/check/listSubjects/listObjects/membersOf SIN relación (undefined) ⇒ 422 E_AUTHZ_RELATION_UNKNOWN', async ({ assert, }) => {
|
|
567
|
+
const u = holder();
|
|
568
|
+
const roleUuid = uuidv7();
|
|
569
|
+
const S = partition();
|
|
570
|
+
const binding = { type: 'role_binding', id: roleUuid };
|
|
571
|
+
const attempts = [
|
|
572
|
+
['purgeObject', () => state.manager.purgeObject(binding, S)],
|
|
573
|
+
[
|
|
574
|
+
'purgeSubject(userset)',
|
|
575
|
+
() => state.manager.purgeSubject({ object: binding, relation: 'role' }, S),
|
|
576
|
+
],
|
|
577
|
+
['check', () => state.manager.check(u, 'assignee', binding, S)],
|
|
578
|
+
['listObjects', () => state.manager.listObjects(u, 'assignee', 'role_binding', S)],
|
|
579
|
+
['listSubjects', () => state.manager.listSubjects('assignee', binding, S)],
|
|
580
|
+
// Cierre-2 · 🟡 3: `membersOf` es la octava operación (F-05 va ANTES de la capacidad).
|
|
581
|
+
['membersOf', () => state.manager.membersOf(binding, 'assignee', S)],
|
|
582
|
+
];
|
|
583
|
+
for (const [label, run] of attempts) {
|
|
584
|
+
const caught = await run().then(() => null, (e) => e);
|
|
585
|
+
assert.isNotNull(caught, `ROJO (${label}): entró contra un tipo NO declarado (role_binding)`);
|
|
586
|
+
assert.equal(caught.status, 422, `${label}: ${caught.message}`);
|
|
587
|
+
assert.equal(caught.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN', label);
|
|
588
|
+
}
|
|
589
|
+
// Una relación no declarada del tipo declarado, en las lecturas que la nombran.
|
|
590
|
+
const doc = { type: 'document', id: docId() };
|
|
591
|
+
for (const [label, run] of [
|
|
592
|
+
['check', () => state.manager.check(u, 'assignee', doc, S)],
|
|
593
|
+
['listObjects', () => state.manager.listObjects(u, 'assignee', 'document', S)],
|
|
594
|
+
['listSubjects', () => state.manager.listSubjects('assignee', doc, S)],
|
|
595
|
+
['membersOf', () => state.manager.membersOf(doc, 'assignee', S)],
|
|
596
|
+
]) {
|
|
597
|
+
const caught = await run().then(() => null, (e) => e);
|
|
598
|
+
assert.equal(caught?.status, 422, `${label}: ${caught?.message}`);
|
|
599
|
+
assert.equal(caught?.code, 'E_AUTHZ_RELATION_UNKNOWN', label);
|
|
600
|
+
}
|
|
601
|
+
// Cierre-2 · 🟠 1: SIN relación (`request.input('relation')` ausente) es
|
|
602
|
+
// 422, no una tupla escrita, no un 503 del backend y no la unión de
|
|
603
|
+
// `listSubjects`. Un driver de terceros lo hereda del manager.
|
|
604
|
+
for (const [label, run] of [
|
|
605
|
+
['relate', () => state.manager.relate(u, undefined, doc, S)],
|
|
606
|
+
['unrelate', () => state.manager.unrelate(u, undefined, doc, S)],
|
|
607
|
+
['check', () => state.manager.check(u, undefined, doc, S)],
|
|
608
|
+
['listSubjects', () => state.manager.listSubjects(undefined, doc, S)],
|
|
609
|
+
['listObjects', () => state.manager.listObjects(u, undefined, 'document', S)],
|
|
610
|
+
['membersOf', () => state.manager.membersOf(doc, undefined, S)],
|
|
611
|
+
]) {
|
|
612
|
+
const caught = await run().then(() => null, (e) => e);
|
|
613
|
+
assert.isNotNull(caught, `ROJO (${label}): ENTRÓ sin relación`);
|
|
614
|
+
assert.equal(caught.status, 422, `${label}: ${caught.message}`);
|
|
615
|
+
assert.equal(caught.code, 'E_AUTHZ_RELATION_UNKNOWN', label);
|
|
616
|
+
}
|
|
617
|
+
assert.equal(state.calls(), 0, 'CERO llamadas al driver: ni una escritura ni una lectura');
|
|
618
|
+
assert.lengthOf(state.events, 0, 'CERO eventos: no hubo purga que auditar');
|
|
619
|
+
// CONTROL: lo declarado sigue entrando por los mismos cinco caminos.
|
|
620
|
+
await state.manager.relate(u, 'viewer', doc, S);
|
|
621
|
+
assert.isTrue(await state.manager.check(u, 'viewer', doc, S));
|
|
622
|
+
assert.lengthOf((await state.manager.listObjects(u, 'viewer', 'document', S)).objects, 1);
|
|
623
|
+
assert.lengthOf((await state.manager.listSubjects('viewer', doc, S)).subjects, 1);
|
|
624
|
+
await state.manager.purgeObject(doc, S);
|
|
625
|
+
await state.manager.purgeSubject({ object: { type: 'group', id: docId() }, relation: 'member' }, S);
|
|
626
|
+
assert.isFalse(await state.manager.check(u, 'viewer', doc, S));
|
|
627
|
+
});
|
|
475
628
|
/* ── R-13: assertWrite PURO + actor en onRelationWrite ── */
|
|
476
629
|
test('R-13 · assertWrite rechaza ⇒ nada toca el driver; actor viaja en onRelationWrite', async ({ assert, }) => {
|
|
477
630
|
const events = [];
|
|
@@ -503,6 +656,102 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
503
656
|
assert.deepEqual(events[0].actor, actor);
|
|
504
657
|
assert.equal(events[0].operation, 'relate');
|
|
505
658
|
});
|
|
659
|
+
/* ── R-17 · las purgas notifican (2.4.0-alpha.3 · E7) ── */
|
|
660
|
+
test('R-17 · purgeObject/purgeSubject notifican UN evento por llamada con su forma (sin relation; purgeObject sin subject, purgeSubject sin object) y el actor, también cuando no borran nada; requireActor las alcanza (422 sin actor, cero llamadas, cero eventos)', async ({ assert, }) => {
|
|
661
|
+
const u = holder();
|
|
662
|
+
const v = holder();
|
|
663
|
+
const doc = { type: 'document', id: docId() };
|
|
664
|
+
const doc2 = { type: 'document', id: docId() };
|
|
665
|
+
const p = partition();
|
|
666
|
+
const actor = holder();
|
|
667
|
+
await state.manager.relate(u, 'viewer', doc, p);
|
|
668
|
+
await state.manager.relate(v, 'editor', doc, p);
|
|
669
|
+
await state.manager.relate(u, 'owner', doc2, p);
|
|
670
|
+
const before = state.events.length;
|
|
671
|
+
// purgeObject: dos tuplas borradas, UN evento, con la forma decidida.
|
|
672
|
+
await state.manager.purgeObject(doc, p, { actor });
|
|
673
|
+
assert.lengthOf(state.events, before + 1, 'ROJO: la purga no notifica exactamente UN evento por llamada');
|
|
674
|
+
const purgedObject = state.events[before];
|
|
675
|
+
assert.equal(purgedObject.operation, 'purgeObject');
|
|
676
|
+
assert.deepEqual(purgedObject.object, doc);
|
|
677
|
+
assert.deepEqual(purgedObject.partition, p);
|
|
678
|
+
assert.deepEqual(purgedObject.actor, actor);
|
|
679
|
+
assert.notProperty(purgedObject, 'subject');
|
|
680
|
+
assert.notProperty(purgedObject, 'relation');
|
|
681
|
+
assert.isFalse(await state.manager.check(u, 'viewer', doc, p));
|
|
682
|
+
// purgeSubject: UN evento, sin object.
|
|
683
|
+
await state.manager.purgeSubject(u, p, { actor });
|
|
684
|
+
assert.lengthOf(state.events, before + 2);
|
|
685
|
+
const purgedSubject = state.events[before + 1];
|
|
686
|
+
assert.equal(purgedSubject.operation, 'purgeSubject');
|
|
687
|
+
assert.deepEqual(purgedSubject.subject, u);
|
|
688
|
+
assert.deepEqual(purgedSubject.partition, p);
|
|
689
|
+
assert.deepEqual(purgedSubject.actor, actor);
|
|
690
|
+
assert.notProperty(purgedSubject, 'object');
|
|
691
|
+
assert.notProperty(purgedSubject, 'relation');
|
|
692
|
+
assert.isFalse(await state.manager.check(u, 'owner', doc2, p));
|
|
693
|
+
// Una purga que no borra nada notifica igual (sin actor, el evento no lo inventa).
|
|
694
|
+
await state.manager.purgeObject({ type: 'document', id: docId() }, p);
|
|
695
|
+
assert.lengthOf(state.events, before + 3);
|
|
696
|
+
assert.equal(state.events[before + 2].operation, 'purgeObject');
|
|
697
|
+
assert.notProperty(state.events[before + 2], 'actor');
|
|
698
|
+
// requireActor alcanza a las purgas (D-3): 422 ANTES del driver.
|
|
699
|
+
const strictEvents = [];
|
|
700
|
+
const strict = await freshManager({ requireActor: true, onRelationWrite: (e) => void strictEvents.push(e) });
|
|
701
|
+
for (const [operation, run] of [
|
|
702
|
+
['purgeObject', () => strict.manager.purgeObject(doc, p)],
|
|
703
|
+
['purgeSubject', () => strict.manager.purgeSubject(u, p)],
|
|
704
|
+
]) {
|
|
705
|
+
let caught;
|
|
706
|
+
try {
|
|
707
|
+
await run();
|
|
708
|
+
assert.fail(`${operation}: requireActor: true tenía que exigir actor a la purga`);
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
caught = error;
|
|
712
|
+
}
|
|
713
|
+
assert.equal(caught.status, 422, `${operation}: ${caught.message}`);
|
|
714
|
+
assert.equal(caught.code, 'E_AUTHZ_ACTOR_REQUIRED', operation);
|
|
715
|
+
}
|
|
716
|
+
assert.equal(strict.writes(), 0, 'cero llamadas al driver');
|
|
717
|
+
assert.lengthOf(strictEvents, 0);
|
|
718
|
+
await strict.manager.purgeObject(doc, p, { actor });
|
|
719
|
+
assert.equal(strict.writes(), 1);
|
|
720
|
+
assert.lengthOf(strictEvents, 1);
|
|
721
|
+
assert.deepEqual(strictEvents[0].actor, actor);
|
|
722
|
+
});
|
|
723
|
+
/* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
|
|
724
|
+
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
|
+
const u = holder();
|
|
726
|
+
const doc = { type: 'document', id: docId() };
|
|
727
|
+
const p = partition();
|
|
728
|
+
// Caducada al escribirse: no concede y no se enumera (caducidad
|
|
729
|
+
// estricta; sin reloj inyectado, un instante PASADO ya lo observa).
|
|
730
|
+
await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
|
|
731
|
+
assert.isFalse(await state.manager.check(u, 'viewer', doc, p), 'caducada ⇒ check false');
|
|
732
|
+
const objects = await state.manager.listObjects(u, 'viewer', 'document', p);
|
|
733
|
+
assert.isFalse(objects.objects.some((o) => o.id === doc.id), 'caducada ⇒ listObjects no la lista');
|
|
734
|
+
const subjects = await state.manager.listSubjects('viewer', doc, p);
|
|
735
|
+
assert.isFalse(subjects.subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid), 'caducada ⇒ listSubjects no la lista');
|
|
736
|
+
// Con caducidad futura concede (y `Date` sobre la caducada la FIJA: tercer estado).
|
|
737
|
+
await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() + 3_600_000) });
|
|
738
|
+
assert.isTrue(await state.manager.check(u, 'viewer', doc, p), 'futura ⇒ concede');
|
|
739
|
+
// Un `expiresAt` que no es Date válida/null/omitido es 422 ANTES del driver.
|
|
740
|
+
const writes = state.writes();
|
|
741
|
+
for (const bad of ['mañana', 12345, new Date('no-es-fecha')]) {
|
|
742
|
+
let caught;
|
|
743
|
+
try {
|
|
744
|
+
await state.manager.relate(u, 'viewer', doc, p, { expiresAt: bad });
|
|
745
|
+
assert.fail(`debería haber lanzado con ${String(bad)}`);
|
|
746
|
+
}
|
|
747
|
+
catch (error) {
|
|
748
|
+
caught = error;
|
|
749
|
+
}
|
|
750
|
+
assert.equal(caught.status, 422);
|
|
751
|
+
assert.equal(caught.code, 'E_AUTHZ_INVALID_IDENTITY');
|
|
752
|
+
}
|
|
753
|
+
assert.equal(state.writes(), writes, 'el driver no se tocó con un expiresAt inválido');
|
|
754
|
+
});
|
|
506
755
|
/* ── Capacidades, cada una con sus DOS caras ── */
|
|
507
756
|
caseFor('singleCheckRelations', {
|
|
508
757
|
// whenTrue: la derivación (includes + userset anidado) resuelve — la
|
|
@@ -641,6 +890,341 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
641
890
|
assert.isUndefined(page.truncated);
|
|
642
891
|
}),
|
|
643
892
|
});
|
|
893
|
+
/* ── R-15 · el par `injectableClock` (paridad con roles, 2.5 · J1) ── */
|
|
894
|
+
caseFor('injectableClock', {
|
|
895
|
+
whenTrue: () => test('injectableClock:true · expiresAt en tres estados con el reloj inyectado, y la caducidad EXACTA (T−1 ms concede, T no, T+1 no) en check/listObjects/listSubjects y a través de un userset; renovar y revivir sin dormir', async ({ assert, }) => {
|
|
896
|
+
assert.typeOf(state.base.withClock, 'function', 'injectableClock: true exige withClock en el puerto');
|
|
897
|
+
// `T` lleva milisegundos a propósito (2.5 · J3): un motor que trunque
|
|
898
|
+
// a segundos falla aquí (DATETIME(3) en MySQL).
|
|
899
|
+
const T = new Date('2030-06-15T12:34:56.789Z');
|
|
900
|
+
const clock = fixedClock(new Date(T.getTime() - 60_000));
|
|
901
|
+
const clocked = new RelationsManager(state.base.withClock(clock.now), contractRelationsConfig());
|
|
902
|
+
const u = holder();
|
|
903
|
+
const doc = { type: 'document', id: docId() };
|
|
904
|
+
const viaGroup = { type: 'document', id: docId() };
|
|
905
|
+
const g = { type: 'group', id: docId() };
|
|
906
|
+
const p = partition();
|
|
907
|
+
await clocked.relate(u, 'viewer', doc, p, { expiresAt: T });
|
|
908
|
+
// La MEMBRESÍA caduca: g#member es editor de `viaGroup` sin plazo, pero
|
|
909
|
+
// u es member de g solo hasta T ⇒ en T deja de ser viewer de viaGroup.
|
|
910
|
+
await clocked.relate(u, 'member', g, p, { expiresAt: T });
|
|
911
|
+
await clocked.relate({ object: g, relation: 'member' }, 'editor', viaGroup, p);
|
|
912
|
+
const observe = async () => ({
|
|
913
|
+
check: await clocked.check(u, 'viewer', doc, p),
|
|
914
|
+
listObjects: (await clocked.listObjects(u, 'viewer', 'document', p)).objects.some((o) => o.id === doc.id),
|
|
915
|
+
listSubjects: (await clocked.listSubjects('viewer', doc, p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
|
|
916
|
+
viaGroup: await clocked.check(u, 'viewer', viaGroup, p),
|
|
917
|
+
...(harness.capabilities.membersOfNative
|
|
918
|
+
? {
|
|
919
|
+
membersOf: (await clocked.membersOf(g, 'member', p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
|
|
920
|
+
}
|
|
921
|
+
: {}),
|
|
922
|
+
});
|
|
923
|
+
const alive = {
|
|
924
|
+
check: true,
|
|
925
|
+
listObjects: true,
|
|
926
|
+
listSubjects: true,
|
|
927
|
+
viaGroup: true,
|
|
928
|
+
...(harness.capabilities.membersOfNative ? { membersOf: true } : {}),
|
|
929
|
+
};
|
|
930
|
+
const gone = {
|
|
931
|
+
check: false,
|
|
932
|
+
listObjects: false,
|
|
933
|
+
listSubjects: false,
|
|
934
|
+
viaGroup: false,
|
|
935
|
+
...(harness.capabilities.membersOfNative ? { membersOf: false } : {}),
|
|
936
|
+
};
|
|
937
|
+
clock.set(new Date(T.getTime() - 1));
|
|
938
|
+
assert.deepEqual(await observe(), alive, 'T−1 ms');
|
|
939
|
+
clock.set(T);
|
|
940
|
+
assert.deepEqual(await observe(), gone, 'T: la que vence ahora ya no cuenta (estricta)');
|
|
941
|
+
clock.set(new Date(T.getTime() + 1));
|
|
942
|
+
assert.deepEqual(await observe(), gone, 'T+1 ms');
|
|
943
|
+
// Renovación: `Date` posterior vuelve a conceder hasta ESE instante.
|
|
944
|
+
const T2 = new Date(T.getTime() + 1_000);
|
|
945
|
+
await clocked.relate(u, 'viewer', doc, p, { expiresAt: T2 });
|
|
946
|
+
await clocked.relate(u, 'member', g, p, { expiresAt: T2 });
|
|
947
|
+
assert.deepEqual(await observe(), alive, 'renovada en T+1 ms');
|
|
948
|
+
clock.set(new Date(T2.getTime() - 1));
|
|
949
|
+
assert.deepEqual(await observe(), alive, 'T2−1 ms');
|
|
950
|
+
clock.set(T2);
|
|
951
|
+
assert.deepEqual(await observe(), gone, 'T2');
|
|
952
|
+
// Los tres estados (invariante 10), con el reloj en T2 (u ya caducado):
|
|
953
|
+
// omitido sobre una CADUCADA revive sin caducidad;
|
|
954
|
+
await clocked.relate(u, 'viewer', doc, p);
|
|
955
|
+
await clocked.relate(u, 'member', g, p);
|
|
956
|
+
clock.set(new Date('2099-12-31T23:59:59.999Z'));
|
|
957
|
+
assert.deepEqual(await observe(), alive, 'revivida sin caducidad: vigente en 2099');
|
|
958
|
+
// omitido sobre una VIGENTE la preserva (no la convierte en permanente);
|
|
959
|
+
const keep = holder();
|
|
960
|
+
const soon = new Date(clock.now().getTime() + 1_500);
|
|
961
|
+
await clocked.relate(keep, 'viewer', doc, p, { expiresAt: soon });
|
|
962
|
+
await clocked.relate(keep, 'viewer', doc, p);
|
|
963
|
+
// null la quita.
|
|
964
|
+
const lift = holder();
|
|
965
|
+
await clocked.relate(lift, 'viewer', doc, p, { expiresAt: soon });
|
|
966
|
+
await clocked.relate(lift, 'viewer', doc, p, { expiresAt: null });
|
|
967
|
+
clock.set(new Date(soon.getTime() - 1));
|
|
968
|
+
assert.isTrue(await clocked.check(keep, 'viewer', doc, p), 'preservada: vigente en soon−1');
|
|
969
|
+
clock.set(soon);
|
|
970
|
+
assert.isFalse(await clocked.check(keep, 'viewer', doc, p), 'preservada: vence en soon (omitido NO la hizo permanente)');
|
|
971
|
+
assert.isTrue(await clocked.check(lift, 'viewer', doc, p), 'null la quitó: sigue vigente');
|
|
972
|
+
// El driver del harness (sin reloj inyectado) no se ha tocado: para él
|
|
973
|
+
// (hoy) T2 —2030— es futuro y la relación renovada concede.
|
|
974
|
+
assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
|
|
975
|
+
}),
|
|
976
|
+
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
|
+
assert.notTypeOf(state.base.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');
|
|
978
|
+
const doc = { type: 'document', id: docId() };
|
|
979
|
+
const p = partition();
|
|
980
|
+
const keep = holder();
|
|
981
|
+
const lift = holder();
|
|
982
|
+
const revive = holder();
|
|
983
|
+
const soon = new Date(Date.now() + 1_500);
|
|
984
|
+
await state.manager.relate(keep, 'viewer', doc, p, { expiresAt: soon });
|
|
985
|
+
await state.manager.relate(keep, 'viewer', doc, p); // omitido: preserva `soon`
|
|
986
|
+
await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: soon });
|
|
987
|
+
await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: null }); // la quita
|
|
988
|
+
await state.manager.relate(revive, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
|
|
989
|
+
assert.isFalse(await state.manager.check(revive, 'viewer', doc, p), 'caducada no concede');
|
|
990
|
+
await state.manager.relate(revive, 'viewer', doc, p); // omitido sobre caducada: revive sin caducidad
|
|
991
|
+
assert.isTrue(await state.manager.check(keep, 'viewer', doc, p));
|
|
992
|
+
assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
|
|
993
|
+
assert.isTrue(await state.manager.check(revive, 'viewer', doc, p));
|
|
994
|
+
await sleep(1_700);
|
|
995
|
+
assert.isFalse(await state.manager.check(keep, 'viewer', doc, p), 'preservada: venció');
|
|
996
|
+
assert.isTrue(await state.manager.check(lift, 'viewer', doc, p), 'sin caducidad: sigue');
|
|
997
|
+
assert.isTrue(await state.manager.check(revive, 'viewer', doc, p), 'revivida sin caducidad: sigue');
|
|
998
|
+
}),
|
|
999
|
+
});
|
|
1000
|
+
/* ── L-2 · el par `transactionalWrites` (panel `{trx}`, (C); mismo nombre que en roles) ── */
|
|
1001
|
+
caseFor('transactionalWrites', {
|
|
1002
|
+
// L-4: la cara `true`. Lo que se juzga es «los dos o ninguno con TU
|
|
1003
|
+
// transacción» POR CENSO (paridad con la cara `true` de roles, L-3): el
|
|
1004
|
+
// rollback deja CERO tuplas nuevas en la partición (no solo `check`
|
|
1005
|
+
// false — una respuesta puede salir bien con la fila viva), el commit
|
|
1006
|
+
// las deja escritas, mientras la transacción está abierta la autoridad
|
|
1007
|
+
// (que lee por la conexión del motor) no ve nada, y `purgeObject`/
|
|
1008
|
+
// `purgeSubject` borran y REVIERTEN juntos (tras el rollback todo lo
|
|
1009
|
+
// purgado está de vuelta). Y la otra mitad: una transacción que NO es
|
|
1010
|
+
// la del driver no recibe ni una sentencia. Necesita un motor con
|
|
1011
|
+
// transacciones reales y pool ≥ 2: el harness `database` lo declara
|
|
1012
|
+
// `true` en `sqlite-file`/PG/MySQL y `false` en `:memory:`.
|
|
1013
|
+
whenTrue: () => test('transactionalWrites:true · relate/unrelate/purgeObject/purgeSubject con { transaction } + rollback ⇒ check sin cambio Y CERO tuplas nuevas (censo); + commit ⇒ aplicado; purge* borran y revierten JUNTOS; el EVENTO de las cuatro lleva transactional: true (y sin { transaction }, no); una transacción ajena, un cliente sin transacción o el `db` entero ⇒ 500 E_AUTHZ_CONFIG sin UNA sentencia por ella', async ({ assert, }) => {
|
|
1014
|
+
assert.equal(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: true y el driver no: declara lo observable');
|
|
1015
|
+
const tx = harness.transactions ?? lucidRelationsContractTransactions();
|
|
1016
|
+
const u = holder();
|
|
1017
|
+
const v = holder();
|
|
1018
|
+
const doc = { type: 'document', id: docId() };
|
|
1019
|
+
const other = { type: 'document', id: docId() };
|
|
1020
|
+
const p = partition();
|
|
1021
|
+
const can = () => state.manager.check(u, 'viewer', doc, p);
|
|
1022
|
+
const census = () => tx.census(p);
|
|
1023
|
+
// Una transacción que se queda abierta tras una aserción fallida
|
|
1024
|
+
// retiene una conexión del pool y arrastra a los casos siguientes:
|
|
1025
|
+
// el rollback va en `finally`, y el commit solo si nada falló.
|
|
1026
|
+
const rolledBack = async (fn) => {
|
|
1027
|
+
const t = await tx.begin();
|
|
1028
|
+
try {
|
|
1029
|
+
await fn(t.client);
|
|
1030
|
+
}
|
|
1031
|
+
finally {
|
|
1032
|
+
await t.rollback();
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
const committed = async (fn) => {
|
|
1036
|
+
const t = await tx.begin();
|
|
1037
|
+
try {
|
|
1038
|
+
await fn(t.client);
|
|
1039
|
+
}
|
|
1040
|
+
catch (error) {
|
|
1041
|
+
await t.rollback();
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
1044
|
+
await t.commit();
|
|
1045
|
+
};
|
|
1046
|
+
// relate: dentro no se ve desde fuera; rollback ⇒ nada; commit ⇒ concede.
|
|
1047
|
+
let seenInside;
|
|
1048
|
+
await rolledBack(async (transaction) => {
|
|
1049
|
+
await state.manager.relate(u, 'viewer', doc, p, { transaction });
|
|
1050
|
+
seenInside = await can();
|
|
1051
|
+
});
|
|
1052
|
+
assert.equal(await census(), 0, 'ROJO: la relación sobrevivió al rollback (la escritura NO fue por la transacción del llamante)');
|
|
1053
|
+
assert.isFalse(await can(), 'relate + rollback ⇒ no concede');
|
|
1054
|
+
assert.isFalse(seenInside, 'la autoridad lee por la conexión del motor: el relate sin confirmar no concede');
|
|
1055
|
+
await committed(async (transaction) => {
|
|
1056
|
+
await state.manager.relate(u, 'viewer', doc, p, { transaction });
|
|
1057
|
+
});
|
|
1058
|
+
assert.isTrue(await can(), 'relate + commit ⇒ concede');
|
|
1059
|
+
assert.equal(await census(), 1);
|
|
1060
|
+
// unrelate: rollback ⇒ la tupla sigue; commit ⇒ se va.
|
|
1061
|
+
await rolledBack(async (transaction) => {
|
|
1062
|
+
await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
|
|
1063
|
+
seenInside = await can();
|
|
1064
|
+
});
|
|
1065
|
+
assert.equal(await census(), 1, 'ROJO: el unrelate sobrevivió al rollback');
|
|
1066
|
+
assert.isTrue(await can(), 'unrelate + rollback ⇒ sigue concediendo');
|
|
1067
|
+
assert.isTrue(seenInside, 'el unrelate sin confirmar no se ve desde la conexión del motor');
|
|
1068
|
+
await committed(async (transaction) => {
|
|
1069
|
+
await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
|
|
1070
|
+
});
|
|
1071
|
+
assert.isFalse(await can(), 'unrelate + commit ⇒ no concede');
|
|
1072
|
+
assert.equal(await census(), 0);
|
|
1073
|
+
// purgeObject: dos sujetos sobre `doc` + uno sobre `other`; rollback ⇒
|
|
1074
|
+
// los tres siguen (borran y revierten JUNTOS); commit ⇒ quedan solo
|
|
1075
|
+
// los de `other`.
|
|
1076
|
+
await state.manager.relate(u, 'viewer', doc, p);
|
|
1077
|
+
await state.manager.relate(v, 'editor', doc, p);
|
|
1078
|
+
await state.manager.relate(u, 'owner', other, p);
|
|
1079
|
+
assert.equal(await census(), 3);
|
|
1080
|
+
await rolledBack(async (transaction) => {
|
|
1081
|
+
await state.manager.purgeObject(doc, p, { transaction });
|
|
1082
|
+
seenInside = await can();
|
|
1083
|
+
});
|
|
1084
|
+
assert.equal(await census(), 3, 'ROJO: purgeObject sobrevivió al rollback (algo de lo purgado no volvió)');
|
|
1085
|
+
assert.isTrue(await can(), 'purgeObject + rollback ⇒ sigue concediendo');
|
|
1086
|
+
assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
|
|
1087
|
+
assert.isTrue(seenInside, 'el purgeObject sin confirmar no se ve desde la conexión del motor');
|
|
1088
|
+
await committed(async (transaction) => {
|
|
1089
|
+
await state.manager.purgeObject(doc, p, { transaction });
|
|
1090
|
+
});
|
|
1091
|
+
assert.equal(await census(), 1, 'purgeObject + commit ⇒ solo queda la tupla de `other`');
|
|
1092
|
+
assert.isFalse(await can());
|
|
1093
|
+
assert.isFalse(await state.manager.check(v, 'viewer', doc, p));
|
|
1094
|
+
assert.isTrue(await state.manager.check(u, 'owner', other, p));
|
|
1095
|
+
// purgeSubject: `u` es owner de `other` y viewer de `doc` de nuevo;
|
|
1096
|
+
// rollback ⇒ las dos siguen; commit ⇒ las dos se van y `v` no se toca.
|
|
1097
|
+
await state.manager.relate(u, 'viewer', doc, p);
|
|
1098
|
+
await state.manager.relate(v, 'viewer', doc, p);
|
|
1099
|
+
assert.equal(await census(), 3);
|
|
1100
|
+
await rolledBack(async (transaction) => {
|
|
1101
|
+
await state.manager.purgeSubject(u, p, { transaction });
|
|
1102
|
+
seenInside = await can();
|
|
1103
|
+
});
|
|
1104
|
+
assert.equal(await census(), 3, 'ROJO: purgeSubject sobrevivió al rollback');
|
|
1105
|
+
assert.isTrue(await can(), 'purgeSubject + rollback ⇒ sigue concediendo');
|
|
1106
|
+
assert.isTrue(await state.manager.check(u, 'owner', other, p));
|
|
1107
|
+
assert.isTrue(seenInside, 'el purgeSubject sin confirmar no se ve desde la conexión del motor');
|
|
1108
|
+
await committed(async (transaction) => {
|
|
1109
|
+
await state.manager.purgeSubject(u, p, { transaction });
|
|
1110
|
+
});
|
|
1111
|
+
assert.equal(await census(), 1, 'purgeSubject + commit ⇒ solo queda la tupla de `v`');
|
|
1112
|
+
assert.isFalse(await can());
|
|
1113
|
+
assert.isFalse(await state.manager.check(u, 'owner', other, p));
|
|
1114
|
+
assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
|
|
1115
|
+
// alpha.3 · B3: la OTRA cara observable de la capacidad — el evento.
|
|
1116
|
+
// Cada escritura inscrita en `{ transaction }` (rollback o commit,
|
|
1117
|
+
// da igual: el paquete no ve el desenlace) publica `transactional:
|
|
1118
|
+
// true`; las que fueron SIN transacción, no (ausente, no `false`).
|
|
1119
|
+
const inTrx = state.events.filter((e) => e.transactional === true).map((e) => e.operation);
|
|
1120
|
+
assert.deepEqual(inTrx, ['relate', 'relate', 'unrelate', 'unrelate', 'purgeObject', 'purgeObject', 'purgeSubject', 'purgeSubject'], 'ROJO: el evento de una escritura inscrita en tu transacción no lleva transactional: true (las ocho: 2 por operación, rollback + commit)');
|
|
1121
|
+
const outside = state.events.filter((e) => e.transactional !== true);
|
|
1122
|
+
assert.isAbove(outside.length, 0, 'las escrituras sin transacción también notifican');
|
|
1123
|
+
for (const e of outside)
|
|
1124
|
+
assert.notProperty(e, 'transactional', `${e.operation} sin { transaction }: la marca está AUSENTE`);
|
|
1125
|
+
// Una transacción que NO es la del driver (`assertCallerTransaction`):
|
|
1126
|
+
// 500 `E_AUTHZ_CONFIG` y ni una sentencia por ella (espía).
|
|
1127
|
+
const statements = [];
|
|
1128
|
+
const spyTrx = (shape) => new Proxy(shape, {
|
|
1129
|
+
get(target, prop) {
|
|
1130
|
+
if (prop === 'from' || prop === 'table' || prop === 'raw' || prop === 'query' || prop === 'knexQuery' || prop === 'rawQuery' || prop === 'transaction') {
|
|
1131
|
+
return (...args) => {
|
|
1132
|
+
statements.push(`${String(prop)}(${String(args[0] ?? '')})`);
|
|
1133
|
+
return { where: () => ({}), select: async () => [], insert: async () => [], delete: async () => 0 };
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
return Reflect.get(target, prop);
|
|
1137
|
+
},
|
|
1138
|
+
});
|
|
1139
|
+
const foreign = spyTrx({ isTransaction: true, connectionName: `not-${harness.name}-${uuidv7()}` });
|
|
1140
|
+
const notATransaction = spyTrx({ isTransaction: false, connectionName: 'primary' });
|
|
1141
|
+
const wholeDb = spyTrx({ connection() { }, primaryConnectionName: 'primary' });
|
|
1142
|
+
const w = holder();
|
|
1143
|
+
const writesBefore = state.writes();
|
|
1144
|
+
for (const [label, transaction] of [
|
|
1145
|
+
['otra conexión', foreign],
|
|
1146
|
+
['un cliente sin transacción', notATransaction],
|
|
1147
|
+
['el db entero', wholeDb],
|
|
1148
|
+
]) {
|
|
1149
|
+
for (const [operation, run] of [
|
|
1150
|
+
['relate', () => state.manager.relate(w, 'viewer', doc, p, { transaction })],
|
|
1151
|
+
['unrelate', () => state.manager.unrelate(w, 'viewer', doc, p, { transaction })],
|
|
1152
|
+
['purgeObject', () => state.manager.purgeObject(doc, p, { transaction })],
|
|
1153
|
+
['purgeSubject', () => state.manager.purgeSubject(w, p, { transaction })],
|
|
1154
|
+
]) {
|
|
1155
|
+
let caught;
|
|
1156
|
+
try {
|
|
1157
|
+
await run();
|
|
1158
|
+
assert.fail(`${label}/${operation}: debería haber lanzado`);
|
|
1159
|
+
}
|
|
1160
|
+
catch (error) {
|
|
1161
|
+
caught = error;
|
|
1162
|
+
}
|
|
1163
|
+
assert.equal(caught.status, 500, `${label}/${operation}: ${caught.message}`);
|
|
1164
|
+
assert.equal(caught.code, 'E_AUTHZ_CONFIG', `${label}/${operation}`);
|
|
1165
|
+
assert.include(caught.message, operation, `${label}/${operation}: nombra la operación`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
assert.deepEqual(statements, [], 'ni una sentencia por una transacción que no es la del driver');
|
|
1169
|
+
assert.isAbove(state.writes(), writesBefore, 'la trx ajena la juzga el DRIVER (assertCallerTransaction), no la puerta 1');
|
|
1170
|
+
assert.equal(await census(), 1, 'la trx ajena no escribió nada');
|
|
1171
|
+
assert.isFalse(await state.manager.check(w, 'viewer', doc, p));
|
|
1172
|
+
// Y la trx ajena (500 del driver) tampoco notifica: no hubo escritura.
|
|
1173
|
+
assert.equal(state.events.length, inTrx.length + outside.length, 'una escritura rechazada por assertCallerTransaction no notifica');
|
|
1174
|
+
})?.timeout(30_000),
|
|
1175
|
+
whenFalse: () => test('transactionalWrites:false · { transaction } en relate/unrelate/purgeObject/purgeSubject ⇒ 500 E_AUTHZ_UNSUPPORTED nombrando driver y operación, con CERO llamadas al driver (espía) y CERO eventos; y requireTransactionalWrites: true ⇒ 500 E_AUTHZ_CONFIG al construir el manager (= al resolver el driver)', async ({ assert, }) => {
|
|
1176
|
+
assert.notEqual(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: false y el driver declara true: declara lo observable');
|
|
1177
|
+
const trx = { from() { }, table() { }, isTransaction: true, connectionName: 'primary' };
|
|
1178
|
+
const u = holder();
|
|
1179
|
+
const doc = { type: 'document', id: docId() };
|
|
1180
|
+
const p = partition();
|
|
1181
|
+
const writes = [
|
|
1182
|
+
['relate', () => state.manager.relate(u, 'viewer', doc, p, { transaction: trx })],
|
|
1183
|
+
['unrelate', () => state.manager.unrelate(u, 'viewer', doc, p, { transaction: trx })],
|
|
1184
|
+
['purgeObject', () => state.manager.purgeObject(doc, p, { transaction: trx })],
|
|
1185
|
+
['purgeSubject', () => state.manager.purgeSubject(u, p, { transaction: trx })],
|
|
1186
|
+
];
|
|
1187
|
+
for (const [operation, run] of writes) {
|
|
1188
|
+
let caught;
|
|
1189
|
+
try {
|
|
1190
|
+
await run();
|
|
1191
|
+
assert.fail(`${operation}: debería haber lanzado`);
|
|
1192
|
+
}
|
|
1193
|
+
catch (error) {
|
|
1194
|
+
caught = error;
|
|
1195
|
+
}
|
|
1196
|
+
assert.equal(caught.status, 500, `${operation}: ${caught.message}`);
|
|
1197
|
+
assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED', operation);
|
|
1198
|
+
assert.include(caught.message, `'${harness.name}'`, `${operation}: nombra el driver`);
|
|
1199
|
+
assert.include(caught.message, operation, `${operation}: nombra la operación`);
|
|
1200
|
+
assert.include(caught.message, 'requireTransactionalWrites', `${operation}: la letra lleva la salida`);
|
|
1201
|
+
}
|
|
1202
|
+
assert.equal(state.writes(), 0, 'cero llamadas al driver: el rechazo va ANTES del backend');
|
|
1203
|
+
assert.deepEqual(state.events, [], 'alpha.3 · B3: una escritura que no ocurre no notifica (cero eventos)');
|
|
1204
|
+
// Sin `{ transaction }` la MISMA escritura entra, y notifica SIN la marca.
|
|
1205
|
+
await state.manager.relate(u, 'viewer', doc, p);
|
|
1206
|
+
assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
|
|
1207
|
+
assert.equal(state.writes(), 1);
|
|
1208
|
+
assert.lengthOf(state.events, 1);
|
|
1209
|
+
assert.notProperty(state.events[0], 'transactional', 'con transactionalWrites: false la marca NUNCA aparece');
|
|
1210
|
+
// Puerta 2, opt-in: al construir el manager (= resolver el driver).
|
|
1211
|
+
let atResolve;
|
|
1212
|
+
try {
|
|
1213
|
+
new RelationsManager(state.base, contractRelationsConfig(), {
|
|
1214
|
+
requireTransactionalWrites: true,
|
|
1215
|
+
driverName: harness.name,
|
|
1216
|
+
});
|
|
1217
|
+
assert.fail('requireTransactionalWrites: true sobre un driver que declara false tenía que lanzar al construir');
|
|
1218
|
+
}
|
|
1219
|
+
catch (error) {
|
|
1220
|
+
atResolve = error;
|
|
1221
|
+
}
|
|
1222
|
+
assert.equal(atResolve.status, 500, String(atResolve?.message));
|
|
1223
|
+
assert.equal(atResolve.code, 'E_AUTHZ_CONFIG');
|
|
1224
|
+
assert.include(atResolve.message, `'${harness.name}'`);
|
|
1225
|
+
assert.include(atResolve.message, 'transactionalWrites');
|
|
1226
|
+
}),
|
|
1227
|
+
});
|
|
644
1228
|
// Al cerrar: toda capacidad declarada tiene que haber registrado su cara.
|
|
645
1229
|
group.teardown(async () => {
|
|
646
1230
|
const uncovered = Object.keys(harness.capabilities).filter((c) => !covered.has(c));
|