@teamblind/axeda-testing 0.0.0-stage → 0.1.0-alpha.0

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.
@@ -0,0 +1,426 @@
1
+ import { GLOBAL_SCOPE, isAxedaError, isTransactional, parsePermissionKey, } from '@teamblind/axeda-core';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { defineTenantContract } from './tenant-contract.js';
4
+ /**
5
+ * Usuarios que la suite usa. Las persistencias respaldadas por una base con claves foráneas
6
+ * (Better Auth) deben tenerlos creados antes de empezar; la de memoria los ignora.
7
+ */
8
+ export const CONTRACT_USERS = ['u1', 'u2'];
9
+ const perms = (...keys) => keys.map(parsePermissionKey);
10
+ export const roleInput = (overrides = {}) => ({
11
+ key: 'cashier',
12
+ name: 'Cashier',
13
+ permissions: perms('invoice:create', 'invoice:read'),
14
+ ...overrides,
15
+ });
16
+ const expectCode = async (promise, code) => {
17
+ const error = await promise.then(() => null, (caught) => caught);
18
+ expect(isAxedaError(error, code), `expected ${code}, got ${String(error)}`).toBe(true);
19
+ };
20
+ /**
21
+ * Suite de contrato de los puertos de persistencia de Axeda. Toda implementación (memoria,
22
+ * Better Auth sobre SQLite o PostgreSQL, …) debe pasarla entera.
23
+ */
24
+ export function runPersistenceContract(name, create) {
25
+ describe(`persistence contract: ${name}`, () => {
26
+ let db;
27
+ beforeEach(async () => {
28
+ db = await create(CONTRACT_USERS);
29
+ });
30
+ defineTenantContract(() => db);
31
+ describe('roles', () => {
32
+ it('creates a role with defaults and reads it back by id and by key', async () => {
33
+ const role = await db.roles.create(roleInput());
34
+ expect(role).toMatchObject({
35
+ key: 'cashier',
36
+ status: 'active',
37
+ requireMfa: false,
38
+ managed: false,
39
+ version: 1,
40
+ description: null,
41
+ });
42
+ expect(await db.roles.get(role.id)).toEqual(role);
43
+ expect(await db.roles.getByKey('cashier')).toEqual(role);
44
+ });
45
+ it('returns null for unknown roles', async () => {
46
+ expect(await db.roles.get('nope')).toBeNull();
47
+ expect(await db.roles.getByKey('nope')).toBeNull();
48
+ });
49
+ it('rejects a duplicate key', async () => {
50
+ await db.roles.create(roleInput());
51
+ await expectCode(db.roles.create(roleInput()), 'AXEDA_ROLE_KEY_TAKEN');
52
+ });
53
+ it('lets exactly one of two concurrent creations of the same key win', async () => {
54
+ const results = await Promise.allSettled([
55
+ db.roles.create(roleInput({ key: 'race' })),
56
+ db.roles.create(roleInput({ key: 'race' })),
57
+ ]);
58
+ expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
59
+ const rejected = results.find((result) => result.status === 'rejected');
60
+ expect(isAxedaError(rejected?.status === 'rejected' ? rejected.reason : null, 'AXEDA_ROLE_KEY_TAKEN')).toBe(true);
61
+ expect((await db.roles.list({ limit: 10 })).items).toHaveLength(1);
62
+ });
63
+ it('getMany returns only the roles that exist', async () => {
64
+ const a = await db.roles.create(roleInput({ key: 'a' }));
65
+ const b = await db.roles.create(roleInput({ key: 'b' }));
66
+ const found = await db.roles.getMany([a.id, 'missing', b.id]);
67
+ expect(found.map((role) => role.key).sort()).toEqual(['a', 'b']);
68
+ expect(await db.roles.getMany([])).toEqual([]);
69
+ });
70
+ it('updates with the expected version and bumps it', async () => {
71
+ const role = await db.roles.create(roleInput());
72
+ const updated = await db.roles.update(role.id, 1, {
73
+ name: 'Senior cashier',
74
+ permissions: perms('invoice:*'),
75
+ requireMfa: true,
76
+ status: 'inactive',
77
+ });
78
+ expect(updated).toMatchObject({
79
+ name: 'Senior cashier',
80
+ requireMfa: true,
81
+ status: 'inactive',
82
+ version: 2,
83
+ });
84
+ expect(updated.permissions).toEqual(perms('invoice:*'));
85
+ expect(updated.key).toBe('cashier');
86
+ });
87
+ it('keeps unspecified fields and can clear the description', async () => {
88
+ const role = await db.roles.create(roleInput({ description: 'front desk' }));
89
+ const kept = await db.roles.update(role.id, 1, { name: 'Renamed' });
90
+ expect(kept.description).toBe('front desk');
91
+ const cleared = await db.roles.update(role.id, 2, { description: null });
92
+ expect(cleared.description).toBeNull();
93
+ });
94
+ it('rejects a stale version (optimistic concurrency)', async () => {
95
+ const role = await db.roles.create(roleInput());
96
+ await db.roles.update(role.id, 1, { name: 'first writer' });
97
+ await expectCode(db.roles.update(role.id, 1, { name: 'second writer' }), 'AXEDA_VERSION_CONFLICT');
98
+ expect((await db.roles.get(role.id))?.name).toBe('first writer');
99
+ });
100
+ it('rejects updating a role that does not exist', async () => {
101
+ await expectCode(db.roles.update('nope', 1, { name: 'x' }), 'AXEDA_ROLE_NOT_FOUND');
102
+ });
103
+ it('lists roles in key order with cursor pagination', async () => {
104
+ for (const key of ['d', 'a', 'c', 'b', 'e'])
105
+ await db.roles.create(roleInput({ key }));
106
+ const first = await db.roles.list({ limit: 2 });
107
+ expect(first.items.map((role) => role.key)).toEqual(['a', 'b']);
108
+ expect(first.nextCursor).not.toBeNull();
109
+ const second = await db.roles.list({ limit: 2, cursor: first.nextCursor ?? '' });
110
+ expect(second.items.map((role) => role.key)).toEqual(['c', 'd']);
111
+ const third = await db.roles.list({ limit: 2, cursor: second.nextCursor ?? '' });
112
+ expect(third.items.map((role) => role.key)).toEqual(['e']);
113
+ expect(third.nextCursor).toBeNull();
114
+ });
115
+ it('does not return a cursor when everything fits in one page', async () => {
116
+ await db.roles.create(roleInput());
117
+ const page = await db.roles.list({ limit: 10 });
118
+ expect(page.items).toHaveLength(1);
119
+ expect(page.nextCursor).toBeNull();
120
+ });
121
+ it('deletes a role together with its assignments', async () => {
122
+ const role = await db.roles.create(roleInput());
123
+ await db.assignments.create({ userId: 'u1', roleId: role.id, scope: GLOBAL_SCOPE });
124
+ expect(await db.roles.delete(role.id)).toBe(true);
125
+ expect(await db.roles.get(role.id)).toBeNull();
126
+ expect(await db.assignments.listForUser('u1')).toEqual([]);
127
+ expect(await db.roles.delete(role.id)).toBe(false);
128
+ });
129
+ });
130
+ describe('assignments', () => {
131
+ it('creates and lists assignments with their scope and expiry', async () => {
132
+ const role = await db.roles.create(roleInput());
133
+ const expiresAt = new Date('2030-01-01T00:00:00.000Z');
134
+ const created = await db.assignments.create({
135
+ userId: 'u1',
136
+ roleId: role.id,
137
+ scope: { type: 'company', id: 'c1' },
138
+ grantedBy: 'admin',
139
+ expiresAt,
140
+ });
141
+ expect(created).toMatchObject({
142
+ userId: 'u1',
143
+ roleId: role.id,
144
+ scope: { type: 'company', id: 'c1' },
145
+ grantedBy: 'admin',
146
+ });
147
+ expect(created.expiresAt?.getTime()).toBe(expiresAt.getTime());
148
+ expect(await db.assignments.listForUser('u1')).toHaveLength(1);
149
+ expect(await db.assignments.countForUser('u1')).toBe(1);
150
+ });
151
+ it('defaults grantedBy and expiresAt to null', async () => {
152
+ const role = await db.roles.create(roleInput());
153
+ const created = await db.assignments.create({
154
+ userId: 'u1',
155
+ roleId: role.id,
156
+ scope: GLOBAL_SCOPE,
157
+ });
158
+ expect(created.grantedBy).toBeNull();
159
+ expect(created.expiresAt).toBeNull();
160
+ });
161
+ it('rejects the same role in the same scope twice, but allows it in another scope', async () => {
162
+ const role = await db.roles.create(roleInput());
163
+ await db.assignments.create({
164
+ userId: 'u1',
165
+ roleId: role.id,
166
+ scope: { type: 'company', id: 'c1' },
167
+ });
168
+ await expectCode(db.assignments.create({
169
+ userId: 'u1',
170
+ roleId: role.id,
171
+ scope: { type: 'company', id: 'c1' },
172
+ }), 'AXEDA_ASSIGNMENT_EXISTS');
173
+ await db.assignments.create({
174
+ userId: 'u1',
175
+ roleId: role.id,
176
+ scope: { type: 'company', id: 'c2' },
177
+ });
178
+ await db.assignments.create({ userId: 'u1', roleId: role.id, scope: GLOBAL_SCOPE });
179
+ expect(await db.assignments.countForUser('u1')).toBe(3);
180
+ });
181
+ it('lets exactly one of two concurrent identical assignments win', async () => {
182
+ const role = await db.roles.create(roleInput());
183
+ const results = await Promise.allSettled([
184
+ db.assignments.create({ userId: 'u1', roleId: role.id, scope: GLOBAL_SCOPE }),
185
+ db.assignments.create({ userId: 'u1', roleId: role.id, scope: GLOBAL_SCOPE }),
186
+ ]);
187
+ expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
188
+ const rejected = results.find((result) => result.status === 'rejected');
189
+ expect(isAxedaError(rejected?.status === 'rejected' ? rejected.reason : null, 'AXEDA_ASSIGNMENT_EXISTS')).toBe(true);
190
+ expect(await db.assignments.countForUser('u1')).toBe(1);
191
+ });
192
+ it('rejects an assignment to a role that does not exist', async () => {
193
+ await expectCode(db.assignments.create({ userId: 'u1', roleId: 'missing', scope: GLOBAL_SCOPE }), 'AXEDA_ROLE_NOT_FOUND');
194
+ });
195
+ it('keeps users apart', async () => {
196
+ const role = await db.roles.create(roleInput());
197
+ await db.assignments.create({ userId: 'u1', roleId: role.id, scope: GLOBAL_SCOPE });
198
+ await db.assignments.create({ userId: 'u2', roleId: role.id, scope: GLOBAL_SCOPE });
199
+ expect(await db.assignments.countForUser('u1')).toBe(1);
200
+ expect(await db.assignments.countForUser('nobody')).toBe(0);
201
+ expect(await db.assignments.listForUser('nobody')).toEqual([]);
202
+ });
203
+ it('revokes one assignment', async () => {
204
+ const role = await db.roles.create(roleInput());
205
+ const created = await db.assignments.create({
206
+ userId: 'u1',
207
+ roleId: role.id,
208
+ scope: GLOBAL_SCOPE,
209
+ });
210
+ expect(await db.assignments.revoke(created.id)).toBe(true);
211
+ expect(await db.assignments.revoke(created.id)).toBe(false);
212
+ expect(await db.assignments.countForUser('u1')).toBe(0);
213
+ });
214
+ it('deletes every assignment of a user and reports how many', async () => {
215
+ const role = await db.roles.create(roleInput());
216
+ await db.assignments.create({ userId: 'u1', roleId: role.id, scope: GLOBAL_SCOPE });
217
+ await db.assignments.create({
218
+ userId: 'u1',
219
+ roleId: role.id,
220
+ scope: { type: 'company', id: 'c1' },
221
+ });
222
+ await db.assignments.create({ userId: 'u2', roleId: role.id, scope: GLOBAL_SCOPE });
223
+ expect(await db.assignments.deleteForUser('u1')).toBe(2);
224
+ expect(await db.assignments.countForUser('u1')).toBe(0);
225
+ expect(await db.assignments.countForUser('u2')).toBe(1);
226
+ expect(await db.assignments.deleteForUser('u1')).toBe(0);
227
+ });
228
+ });
229
+ describe('assignment lookups', () => {
230
+ it('gets an assignment by id, or null', async () => {
231
+ const role = await db.roles.create(roleInput());
232
+ const created = await db.assignments.create({
233
+ userId: 'u1',
234
+ roleId: role.id,
235
+ scope: { type: 'business', id: 'b1' },
236
+ grantedBy: 'admin',
237
+ });
238
+ expect(await db.assignments.get(created.id)).toEqual(created);
239
+ expect(await db.assignments.get('missing')).toBeNull();
240
+ });
241
+ it('lists only the assignments of exactly one scope, with cursor pagination', async () => {
242
+ const role = await db.roles.create(roleInput());
243
+ const inA = { type: 'company', id: 'A' };
244
+ await db.assignments.create({ userId: 'u1', roleId: role.id, scope: inA });
245
+ await db.assignments.create({ userId: 'u2', roleId: role.id, scope: inA });
246
+ await db.assignments.create({ userId: 'u1', roleId: role.id, scope: GLOBAL_SCOPE });
247
+ await db.assignments.create({
248
+ userId: 'u1',
249
+ roleId: role.id,
250
+ scope: { type: 'company', id: 'B' },
251
+ });
252
+ await db.assignments.create({
253
+ userId: 'u1',
254
+ roleId: role.id,
255
+ scope: { type: 'business', id: 'A' },
256
+ });
257
+ const first = await db.assignments.listForScope(inA, { limit: 1 });
258
+ expect(first.items).toHaveLength(1);
259
+ expect(first.nextCursor).not.toBeNull();
260
+ const second = await db.assignments.listForScope(inA, {
261
+ limit: 1,
262
+ cursor: first.nextCursor ?? '',
263
+ });
264
+ expect(second.items).toHaveLength(1);
265
+ expect(second.nextCursor).toBeNull();
266
+ const users = [...first.items, ...second.items].map((item) => item.userId).sort();
267
+ expect(users).toEqual(['u1', 'u2']);
268
+ const global = await db.assignments.listForScope(GLOBAL_SCOPE, { limit: 10 });
269
+ expect(global.items).toHaveLength(1);
270
+ expect((await db.assignments.listForScope({ type: 'company', id: 'none' }, { limit: 10 })).items).toEqual([]);
271
+ });
272
+ });
273
+ describe('audit', () => {
274
+ const event = (overrides = {}) => ({
275
+ actorId: 'admin',
276
+ action: 'assignment.create',
277
+ targetType: 'assignment',
278
+ targetId: 'x',
279
+ scope: null,
280
+ outcome: 'success',
281
+ ip: null,
282
+ userAgent: null,
283
+ metadata: {},
284
+ ...overrides,
285
+ });
286
+ it('appends an event and returns it with an id and a timestamp', async () => {
287
+ const stored = await db.audit.append(event({
288
+ scope: { type: 'company', id: 'A' },
289
+ ip: '203.0.113.7',
290
+ userAgent: 'curl/8',
291
+ metadata: { roleKey: 'cashier', nested: { ok: true }, list: [1, 'two'] },
292
+ }));
293
+ expect(stored.id).toEqual(expect.any(String));
294
+ expect(stored.at).toBeInstanceOf(Date);
295
+ expect(stored).toMatchObject({
296
+ actorId: 'admin',
297
+ action: 'assignment.create',
298
+ scope: { type: 'company', id: 'A' },
299
+ ip: '203.0.113.7',
300
+ userAgent: 'curl/8',
301
+ metadata: { roleKey: 'cashier', nested: { ok: true }, list: [1, 'two'] },
302
+ });
303
+ expect((await db.audit.list({}, { limit: 10 })).items).toEqual([stored]);
304
+ });
305
+ it('keeps system events (no actor) and a global scope', async () => {
306
+ const stored = await db.audit.append(event({
307
+ actorId: null,
308
+ action: 'bootstrap',
309
+ targetType: 'system',
310
+ targetId: null,
311
+ scope: GLOBAL_SCOPE,
312
+ }));
313
+ const [read] = (await db.audit.list({}, { limit: 10 })).items;
314
+ expect(read).toEqual(stored);
315
+ expect(read?.actorId).toBeNull();
316
+ expect(read?.scope).toEqual(GLOBAL_SCOPE);
317
+ });
318
+ it('lists the newest first and paginates without gaps or repeats', async () => {
319
+ const ids = [];
320
+ for (let index = 0; index < 5; index += 1) {
321
+ ids.push((await db.audit.append(event({ targetId: String(index) }))).id);
322
+ }
323
+ const seen = [];
324
+ let cursor;
325
+ for (let guard = 0; guard < 10; guard += 1) {
326
+ const page = await db.audit.list({}, { limit: 2, ...(cursor ? { cursor } : {}) });
327
+ seen.push(...page.items.map((item) => item.id));
328
+ if (page.nextCursor === null)
329
+ break;
330
+ cursor = page.nextCursor;
331
+ }
332
+ expect(seen).toEqual([...ids].reverse());
333
+ });
334
+ it('filters by actor, action, target and scope', async () => {
335
+ await db.audit.append(event({ actorId: 'a1', action: 'role.create', targetType: 'role' }));
336
+ await db.audit.append(event({ actorId: 'a2', scope: { type: 'company', id: 'A' } }));
337
+ await db.audit.append(event({ actorId: 'a2', scope: { type: 'company', id: 'B' } }));
338
+ await db.audit.append(event({ actorId: 'a2', targetId: 'needle' }));
339
+ const count = async (filter) => (await db.audit.list(filter, { limit: 50 })).items.length;
340
+ expect(await count({ actorId: 'a2' })).toBe(3);
341
+ expect(await count({ action: 'role.create' })).toBe(1);
342
+ expect(await count({ targetType: 'role' })).toBe(1);
343
+ expect(await count({ targetId: 'needle' })).toBe(1);
344
+ expect(await count({ scope: { type: 'company', id: 'A' } })).toBe(1);
345
+ expect(await count({ actorId: 'a2', scope: { type: 'company', id: 'B' } })).toBe(1);
346
+ expect(await count({ actorId: 'nobody' })).toBe(0);
347
+ expect(await count({})).toBe(4);
348
+ });
349
+ it('is rolled back with the transaction that wrote it', async () => {
350
+ await expect(db.transaction(async (tx) => {
351
+ await tx.audit.append(event());
352
+ throw new Error('boom');
353
+ })).rejects.toThrow('boom');
354
+ expect((await db.audit.list({}, { limit: 10 })).items).toEqual([]);
355
+ await db.transaction((tx) => tx.audit.append(event()));
356
+ expect((await db.audit.list({}, { limit: 10 })).items).toHaveLength(1);
357
+ });
358
+ });
359
+ describe('authorization versions', () => {
360
+ it('starts at zero and increments', async () => {
361
+ expect(await db.authz.getPolicyVersion()).toBe(0);
362
+ expect(await db.authz.getUserVersion('u1')).toBe(0);
363
+ expect(await db.authz.bumpPolicyVersion()).toBe(1);
364
+ expect(await db.authz.bumpUserVersion('u1')).toBe(1);
365
+ expect(await db.authz.bumpUserVersion('u1')).toBe(2);
366
+ expect(await db.authz.getUserVersion('u1')).toBe(2);
367
+ expect(await db.authz.getUserVersion('u2')).toBe(0);
368
+ });
369
+ it('never loses a concurrent increment (50 of 50)', async () => {
370
+ await Promise.all(Array.from({ length: 50 }, () => db.authz.bumpUserVersion('u1')));
371
+ await Promise.all(Array.from({ length: 50 }, () => db.authz.bumpPolicyVersion()));
372
+ expect(await db.authz.getUserVersion('u1')).toBe(50);
373
+ expect(await db.authz.getPolicyVersion()).toBe(50);
374
+ });
375
+ });
376
+ describe('transactions', () => {
377
+ it('commits every write and returns the callback value', async () => {
378
+ const result = await db.transaction(async (tx) => {
379
+ const role = await tx.roles.create(roleInput());
380
+ await tx.assignments.create({ userId: 'u1', roleId: role.id, scope: GLOBAL_SCOPE });
381
+ await tx.authz.bumpUserVersion('u1');
382
+ return role.key;
383
+ });
384
+ expect(result).toBe('cashier');
385
+ expect(await db.assignments.countForUser('u1')).toBe(1);
386
+ expect(await db.authz.getUserVersion('u1')).toBe(1);
387
+ });
388
+ it('rolls back every write when the callback throws, and rethrows the same error', async () => {
389
+ const role = await db.roles.create(roleInput({ key: 'kept' }));
390
+ const failure = new Error('boom');
391
+ await expect(db.transaction(async (tx) => {
392
+ await tx.roles.create(roleInput({ key: 'discarded' }));
393
+ await tx.assignments.create({ userId: 'u1', roleId: role.id, scope: GLOBAL_SCOPE });
394
+ await tx.authz.bumpUserVersion('u1');
395
+ await tx.authz.bumpPolicyVersion();
396
+ throw failure;
397
+ })).rejects.toBe(failure);
398
+ expect(await db.roles.getByKey('discarded')).toBeNull();
399
+ expect(await db.roles.getByKey('kept')).not.toBeNull();
400
+ expect(await db.assignments.countForUser('u1')).toBe(0);
401
+ expect(await db.authz.getUserVersion('u1')).toBe(0);
402
+ expect(await db.authz.getPolicyVersion()).toBe(0);
403
+ });
404
+ it('rolls back when a store error aborts the transaction midway', async () => {
405
+ await db.roles.create(roleInput({ key: 'taken' }));
406
+ await expectCode(db.transaction(async (tx) => {
407
+ await tx.authz.bumpPolicyVersion();
408
+ await tx.roles.create(roleInput({ key: 'taken' }));
409
+ }), 'AXEDA_ROLE_KEY_TAKEN');
410
+ expect(await db.authz.getPolicyVersion()).toBe(0);
411
+ });
412
+ it('keeps working after a rolled-back transaction', async () => {
413
+ await expect(db.transaction(async () => {
414
+ throw new Error('first');
415
+ })).rejects.toThrow('first');
416
+ await db.roles.create(roleInput());
417
+ expect(await db.roles.getByKey('cashier')).not.toBeNull();
418
+ });
419
+ it('passes the startup self-test that Axeda runs before serving requests', async () => {
420
+ expect(await isTransactional(db)).toBe(true);
421
+ expect((await db.roles.list({ limit: 100 })).items).toEqual([]);
422
+ expect(await db.authz.getPolicyVersion()).toBe(0);
423
+ });
424
+ });
425
+ });
426
+ }
@@ -0,0 +1,3 @@
1
+ import { type AxedaPersistence } from '@teamblind/axeda-core';
2
+ /** Pruebas de contrato de compañías, negocios e invitaciones (parte de `runPersistenceContract`). */
3
+ export declare function defineTenantContract(database: () => AxedaPersistence): void;