@proteinjs/user-server 1.11.1 → 1.12.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.
- package/CHANGELOG.md +21 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/migrations/SweepMalformedAccessGrants.d.ts +76 -0
- package/dist/src/migrations/SweepMalformedAccessGrants.d.ts.map +1 -0
- package/dist/src/migrations/SweepMalformedAccessGrants.js +354 -0
- package/dist/src/migrations/SweepMalformedAccessGrants.js.map +1 -0
- package/dist/src/routes/devLogin.d.ts +5 -0
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +13 -0
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/services/Roles.d.ts +2 -0
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +22 -2
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/test/DevLogin.test.js +41 -0
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/MachineAccounts.integration.test.js +91 -7
- package/dist/test/MachineAccounts.integration.test.js.map +1 -1
- package/dist/test/Roles.integration.test.js +141 -2
- package/dist/test/Roles.integration.test.js.map +1 -1
- package/dist/test/SweepMalformedAccessGrants.integration.test.d.ts +2 -0
- package/dist/test/SweepMalformedAccessGrants.integration.test.d.ts.map +1 -0
- package/dist/test/SweepMalformedAccessGrants.integration.test.js +424 -0
- package/dist/test/SweepMalformedAccessGrants.integration.test.js.map +1 -0
- package/generated/index.ts +3 -1
- package/package.json +4 -3
- package/src/migrations/SweepMalformedAccessGrants.ts +229 -0
- package/src/routes/devLogin.ts +15 -0
- package/src/services/Roles.ts +28 -2
- package/test/DevLogin.test.ts +26 -0
- package/test/MachineAccounts.integration.test.ts +76 -33
- package/test/Roles.integration.test.ts +67 -2
- package/test/SweepMalformedAccessGrants.integration.test.ts +255 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { getDbAsSystem, QueryBuilder, Reference, Table, TableWatcher } from '@proteinjs/db';
|
|
2
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
3
|
+
import { AccessGrant, tables, User } from '@proteinjs/user';
|
|
4
|
+
import { SweepMalformedAccessGrants } from '../src/migrations/SweepMalformedAccessGrants';
|
|
5
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
6
|
+
|
|
7
|
+
const testEnv = new UserServerTestEnvironment();
|
|
8
|
+
|
|
9
|
+
type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
|
|
10
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
11
|
+
|
|
12
|
+
/** Same harness cast as MachineAccounts.integration.test.ts: recompute the static watcher map on the next Db. */
|
|
13
|
+
const resetTableWatcherMap = () => {
|
|
14
|
+
const runner = (getDbAsSystem() as unknown as { tableWatcherRunner: object }).tableWatcherRunner;
|
|
15
|
+
(runner.constructor as { tableWatcherMap?: unknown }).tableWatcherMap = undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Observes access_grant deletes the way the app's watchers do (ContentReferenceAccessGrantTableWatcher
|
|
20
|
+
* lives in space-common, above this package): a delete that rides the Db layer lands here with the
|
|
21
|
+
* deleted rows; a raw-SQL delete never would.
|
|
22
|
+
*/
|
|
23
|
+
class RecordingAccessGrantWatcher implements TableWatcher<AccessGrant> {
|
|
24
|
+
static deletedIds: string[] = [];
|
|
25
|
+
|
|
26
|
+
name(): string {
|
|
27
|
+
return 'RecordingAccessGrantWatcher';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
table(): Table<AccessGrant> {
|
|
31
|
+
return tables.AccessGrant;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async afterDelete<T extends AccessGrant>(_count: number, deletedRecords: T[]): Promise<void> {
|
|
35
|
+
for (const record of deletedRecords) {
|
|
36
|
+
RecordingAccessGrantWatcher.deletedIds.push(record.id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* SweepMalformedAccessGrants (one-time migration): the AccessGrant well-formedness invariant now
|
|
43
|
+
* refuses a grant with no principal or resource id, but the rows its two pre-fix producers left
|
|
44
|
+
* behind (the test environment's NULL-principal `thought` grant that crashed Deploy to Test
|
|
45
|
+
* 32614670162) are still there. The sweep deletes EXACTLY the invariant's complement
|
|
46
|
+
* (`principal IS NULL OR resource IS NULL`) by id through the Db layer — watchers fire — and
|
|
47
|
+
* nothing else: well-formed grants survive, and DANGLING grants (non-null ids pointing at purged
|
|
48
|
+
* rows) are reported, never deleted. Idempotent.
|
|
49
|
+
*
|
|
50
|
+
* Red-first: written against the contract; a no-op sweep fails the delete legs, and a sweep on
|
|
51
|
+
* the wrong predicate fails the well-formed-survives legs.
|
|
52
|
+
*/
|
|
53
|
+
describe('SweepMalformedAccessGrants — deletes exactly the malformed rows through the Db layer; reports dangling; idempotent', () => {
|
|
54
|
+
let owner: User;
|
|
55
|
+
let grantee: User;
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
await testEnv.beforeAll();
|
|
59
|
+
// The dangling report resolves resource tables by name off the source graph (none is loaded
|
|
60
|
+
// in tests) — register exactly this package's tables, as the app's composition would.
|
|
61
|
+
objectCache()['@proteinjs/db/Table'] = Object.values(tables);
|
|
62
|
+
objectCache()['@proteinjs/db/TableWatcher'] = [new RecordingAccessGrantWatcher()];
|
|
63
|
+
resetTableWatcherMap();
|
|
64
|
+
}, 120_000);
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
delete objectCache()['@proteinjs/db/Table'];
|
|
68
|
+
delete objectCache()['@proteinjs/db/TableWatcher'];
|
|
69
|
+
resetTableWatcherMap();
|
|
70
|
+
await testEnv.afterAll();
|
|
71
|
+
}, 60_000);
|
|
72
|
+
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
const db = getDbAsSystem();
|
|
75
|
+
await db.delete(tables.AccessGrant, {});
|
|
76
|
+
await db.delete(tables.User, {});
|
|
77
|
+
RecordingAccessGrantWatcher.deletedIds = [];
|
|
78
|
+
owner = await testEnv.createUser({ name: 'Owner', email: 'owner@test.local' });
|
|
79
|
+
grantee = await testEnv.createUser({ name: 'Grantee', email: 'grantee@test.local' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** A well-formed grant, inserted as system (the invariant admits it). Resource rides the user table here. */
|
|
83
|
+
async function insertGrant(args: {
|
|
84
|
+
principalId: string;
|
|
85
|
+
resourceId: string;
|
|
86
|
+
resourceTable?: string;
|
|
87
|
+
accessLevel: AccessGrant['accessLevel'];
|
|
88
|
+
}): Promise<AccessGrant> {
|
|
89
|
+
const resourceTable = args.resourceTable ?? tables.User.name;
|
|
90
|
+
return await getDbAsSystem().insert(tables.AccessGrant, {
|
|
91
|
+
principal: new Reference(tables.User.name, args.principalId),
|
|
92
|
+
resource: new Reference(resourceTable, args.resourceId),
|
|
93
|
+
resourceTable,
|
|
94
|
+
accessLevel: args.accessLevel,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Seed the malformed shape BENEATH the insert invariant (which rightly refuses it now): insert
|
|
100
|
+
* well-formed, then null the reference in place — the row the environment actually holds.
|
|
101
|
+
*/
|
|
102
|
+
async function nullOut(grantId: string, field: 'principal' | 'resource'): Promise<void> {
|
|
103
|
+
await getDbAsSystem().update(tables.AccessGrant, { [field]: null } as Partial<AccessGrant>, { id: grantId });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function grantById(id: string): Promise<AccessGrant | undefined> {
|
|
107
|
+
return await getDbAsSystem().get(tables.AccessGrant, { id });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function allGrantIds(): Promise<string[]> {
|
|
111
|
+
const grants = await getDbAsSystem().query(
|
|
112
|
+
tables.AccessGrant,
|
|
113
|
+
new QueryBuilder<AccessGrant>(tables.AccessGrant.name)
|
|
114
|
+
);
|
|
115
|
+
return grants.map((grant) => grant.id).sort();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it('dry run reports without deleting; real run deletes exactly the malformed rows (watchers fire); well-formed and dangling survive; re-run is a no-op', async () => {
|
|
119
|
+
// Well-formed — must survive untouched.
|
|
120
|
+
const ownerGrant = await insertGrant({ principalId: owner.id, resourceId: owner.id, accessLevel: 'owner' });
|
|
121
|
+
const readGrant = await insertGrant({ principalId: grantee.id, resourceId: owner.id, accessLevel: 'read' });
|
|
122
|
+
// Malformed — the invariant's complement, one of each shape.
|
|
123
|
+
const nullPrincipal = await insertGrant({ principalId: grantee.id, resourceId: owner.id, accessLevel: 'write' });
|
|
124
|
+
await nullOut(nullPrincipal.id, 'principal');
|
|
125
|
+
const nullResource = await insertGrant({ principalId: grantee.id, resourceId: owner.id, accessLevel: 'admin' });
|
|
126
|
+
await nullOut(nullResource.id, 'resource');
|
|
127
|
+
// Dangling — well-formed ids that resolve to nothing. A separate class: reported, never deleted.
|
|
128
|
+
const purgedPrincipal = await insertGrant({
|
|
129
|
+
principalId: 'purged-user',
|
|
130
|
+
resourceId: owner.id,
|
|
131
|
+
accessLevel: 'read',
|
|
132
|
+
});
|
|
133
|
+
const purgedResource = await insertGrant({
|
|
134
|
+
principalId: grantee.id,
|
|
135
|
+
resourceId: 'purged-row',
|
|
136
|
+
accessLevel: 'read',
|
|
137
|
+
});
|
|
138
|
+
const unregisteredTable = await insertGrant({
|
|
139
|
+
principalId: grantee.id,
|
|
140
|
+
resourceId: 'some-thought',
|
|
141
|
+
resourceTable: 'thought',
|
|
142
|
+
accessLevel: 'write',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect((await grantById(nullPrincipal.id))?.principal ?? null).toBeNull();
|
|
146
|
+
expect((await grantById(nullResource.id))?.resource ?? null).toBeNull();
|
|
147
|
+
const seededIds = [
|
|
148
|
+
ownerGrant.id,
|
|
149
|
+
readGrant.id,
|
|
150
|
+
nullPrincipal.id,
|
|
151
|
+
nullResource.id,
|
|
152
|
+
purgedPrincipal.id,
|
|
153
|
+
purgedResource.id,
|
|
154
|
+
unregisteredTable.id,
|
|
155
|
+
].sort();
|
|
156
|
+
expect(await allGrantIds()).toEqual(seededIds);
|
|
157
|
+
|
|
158
|
+
const sweep = new SweepMalformedAccessGrants();
|
|
159
|
+
|
|
160
|
+
// ---------- DRY RUN: the two malformed rows are listed; nothing is deleted ----------
|
|
161
|
+
const dry = await sweep.sweep({ dryRun: true });
|
|
162
|
+
expect(dry.dryRun).toBe(true);
|
|
163
|
+
expect(dry.malformed).toBe(2);
|
|
164
|
+
expect(dry.rows.map((row) => row.id).sort()).toEqual([nullPrincipal.id, nullResource.id].sort());
|
|
165
|
+
expect(dry.rows.find((row) => row.id === nullPrincipal.id)).toMatchObject({
|
|
166
|
+
resourceTable: tables.User.name,
|
|
167
|
+
accessLevel: 'write',
|
|
168
|
+
});
|
|
169
|
+
expect(dry.rows.every((row) => typeof row.created === 'string' && row.created.length > 0)).toBe(true);
|
|
170
|
+
expect(await allGrantIds()).toEqual(seededIds);
|
|
171
|
+
expect(RecordingAccessGrantWatcher.deletedIds).toEqual([]);
|
|
172
|
+
|
|
173
|
+
// ---------- REAL RUN: exactly the malformed rows go, through the Db layer ----------
|
|
174
|
+
const real = await sweep.sweep({ dryRun: false });
|
|
175
|
+
expect(real.dryRun).toBe(false);
|
|
176
|
+
expect(real.malformed).toBe(2);
|
|
177
|
+
expect(await grantById(nullPrincipal.id)).toBeUndefined();
|
|
178
|
+
expect(await grantById(nullResource.id)).toBeUndefined();
|
|
179
|
+
// The after-delete watchers saw precisely the deleted rows — the delete rode the Db layer.
|
|
180
|
+
expect(RecordingAccessGrantWatcher.deletedIds.sort()).toEqual([nullPrincipal.id, nullResource.id].sort());
|
|
181
|
+
// Well-formed rows intact, levels unchanged.
|
|
182
|
+
expect((await grantById(ownerGrant.id))?.accessLevel).toBe('owner');
|
|
183
|
+
expect((await grantById(readGrant.id))?.accessLevel).toBe('read');
|
|
184
|
+
// Dangling rows intact — reported below, not deleted.
|
|
185
|
+
expect(await grantById(purgedPrincipal.id)).toBeDefined();
|
|
186
|
+
expect(await grantById(purgedResource.id)).toBeDefined();
|
|
187
|
+
expect(await grantById(unregisteredTable.id)).toBeDefined();
|
|
188
|
+
expect(await allGrantIds()).toEqual(
|
|
189
|
+
[ownerGrant.id, readGrant.id, purgedPrincipal.id, purgedResource.id, unregisteredTable.id].sort()
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// ---------- DANGLING REPORT: each purged reference named with its reason; still nothing deleted ----------
|
|
193
|
+
const dangling = await sweep.reportDangling();
|
|
194
|
+
expect(dangling.danglingPrincipal).toBe(1);
|
|
195
|
+
expect(dangling.danglingResource).toBe(2);
|
|
196
|
+
expect(dangling.rows.find((row) => row.id === purgedPrincipal.id)).toMatchObject({
|
|
197
|
+
principal: 'purged-user',
|
|
198
|
+
reason: 'principal missing',
|
|
199
|
+
});
|
|
200
|
+
expect(dangling.rows.find((row) => row.id === purgedResource.id)).toMatchObject({
|
|
201
|
+
resource: 'purged-row',
|
|
202
|
+
reason: 'resource missing',
|
|
203
|
+
});
|
|
204
|
+
expect(dangling.rows.find((row) => row.id === unregisteredTable.id)).toMatchObject({
|
|
205
|
+
resourceTable: 'thought',
|
|
206
|
+
reason: 'resource table not registered',
|
|
207
|
+
});
|
|
208
|
+
expect(dangling.rows.map((row) => row.id)).not.toContain(ownerGrant.id);
|
|
209
|
+
expect(dangling.rows.map((row) => row.id)).not.toContain(readGrant.id);
|
|
210
|
+
expect(await allGrantIds()).toEqual(
|
|
211
|
+
[ownerGrant.id, readGrant.id, purgedPrincipal.id, purgedResource.id, unregisteredTable.id].sort()
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// ---------- IDEMPOTENCY: a second real run finds nothing, deletes nothing ----------
|
|
215
|
+
RecordingAccessGrantWatcher.deletedIds = [];
|
|
216
|
+
const again = await sweep.sweep({ dryRun: false });
|
|
217
|
+
expect(again.malformed).toBe(0);
|
|
218
|
+
expect(again.rows).toEqual([]);
|
|
219
|
+
expect(RecordingAccessGrantWatcher.deletedIds).toEqual([]);
|
|
220
|
+
expect(await allGrantIds()).toEqual(
|
|
221
|
+
[ownerGrant.id, readGrant.id, purgedPrincipal.id, purgedResource.id, unregisteredTable.id].sort()
|
|
222
|
+
);
|
|
223
|
+
}, 120_000);
|
|
224
|
+
|
|
225
|
+
it('the migration record runs dry-run pass, delete pass, and dangling report; its output carries the rows; a second run is zero', async () => {
|
|
226
|
+
const ownerGrant = await insertGrant({ principalId: owner.id, resourceId: owner.id, accessLevel: 'owner' });
|
|
227
|
+
const nullPrincipal = await insertGrant({ principalId: grantee.id, resourceId: owner.id, accessLevel: 'read' });
|
|
228
|
+
await nullOut(nullPrincipal.id, 'principal');
|
|
229
|
+
const purgedPrincipal = await insertGrant({
|
|
230
|
+
principalId: 'purged-user',
|
|
231
|
+
resourceId: owner.id,
|
|
232
|
+
accessLevel: 'read',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const migration = new SweepMalformedAccessGrants();
|
|
236
|
+
expect(migration.table.name).toBe('migration');
|
|
237
|
+
|
|
238
|
+
const output = await migration.record.run();
|
|
239
|
+
expect(output).toMatchObject({
|
|
240
|
+
malformedDryRun: 1,
|
|
241
|
+
malformed: 1,
|
|
242
|
+
danglingPrincipal: 1,
|
|
243
|
+
danglingResource: 0,
|
|
244
|
+
});
|
|
245
|
+
expect(output.malformedRows.map((row) => row.id)).toEqual([nullPrincipal.id]);
|
|
246
|
+
expect(output.danglingRows.map((row) => row.id)).toEqual([purgedPrincipal.id]);
|
|
247
|
+
expect(await allGrantIds()).toEqual([ownerGrant.id, purgedPrincipal.id].sort());
|
|
248
|
+
expect(RecordingAccessGrantWatcher.deletedIds).toEqual([nullPrincipal.id]);
|
|
249
|
+
|
|
250
|
+
const second = await migration.record.run();
|
|
251
|
+
expect(second).toMatchObject({ malformedDryRun: 0, malformed: 0, danglingPrincipal: 1, danglingResource: 0 });
|
|
252
|
+
expect(second.malformedRows).toEqual([]);
|
|
253
|
+
expect(await allGrantIds()).toEqual([ownerGrant.id, purgedPrincipal.id].sort());
|
|
254
|
+
}, 120_000);
|
|
255
|
+
});
|