@proteinjs/user-server 1.11.1 → 1.13.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 +32 -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/authorization/UserActivityStamp.d.ts +32 -0
- package/dist/src/authorization/UserActivityStamp.d.ts.map +1 -0
- package/dist/src/authorization/UserActivityStamp.js +126 -0
- package/dist/src/authorization/UserActivityStamp.js.map +1 -0
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +9 -0
- package/dist/src/authorization/userCache.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/dist/test/UserActivityStamp.integration.test.d.ts +2 -0
- package/dist/test/UserActivityStamp.integration.test.d.ts.map +1 -0
- package/dist/test/UserActivityStamp.integration.test.js +255 -0
- package/dist/test/UserActivityStamp.integration.test.js.map +1 -0
- package/generated/index.ts +3 -1
- package/package.json +4 -3
- package/src/authorization/UserActivityStamp.ts +66 -0
- package/src/authorization/userCache.ts +9 -0
- 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
- package/test/UserActivityStamp.integration.test.ts +122 -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
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
+
import { tables, type User, type UserActivity } from '@proteinjs/user';
|
|
4
|
+
import { userCache } from '../src/authorization/userCache';
|
|
5
|
+
import { UserActivityStamp } from '../src/authorization/UserActivityStamp';
|
|
6
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
7
|
+
|
|
8
|
+
const testEnv = new UserServerTestEnvironment();
|
|
9
|
+
|
|
10
|
+
type StampInternals = {
|
|
11
|
+
constructor: { lastStampMs: Map<string, number>; STAMP_INTERVAL_MS: number };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const stampInternals = (stamp: UserActivityStamp) => (stamp as unknown as StampInternals).constructor;
|
|
15
|
+
|
|
16
|
+
const activityRows = async (scope: string): Promise<UserActivity[]> =>
|
|
17
|
+
await getDbAsSystem().query(tables.UserActivity, { scope });
|
|
18
|
+
|
|
19
|
+
/** The stamp write is fire-and-forget off userCache — poll the OUTCOME (row present) briefly. */
|
|
20
|
+
const waitForActivityRow = async (scope: string): Promise<UserActivity> => {
|
|
21
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
22
|
+
const rows = await activityRows(scope);
|
|
23
|
+
if (rows.length > 0) {
|
|
24
|
+
return rows[0];
|
|
25
|
+
}
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`No user_activity row appeared for scope ${scope}`);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* LAST ACTIVITY = HUMAN PRESENCE (UserActivityTable's contract): the stamp fires from
|
|
33
|
+
* userCache.create — the once-per-interactive-request session-cache build — and from nowhere
|
|
34
|
+
* else. These tests pin the seam's outcomes: a human's request lands the presence row; machine
|
|
35
|
+
* accounts (real sessions, e.g. the error bridge's polling login) never do; repeated stamps
|
|
36
|
+
* keep ONE row per user; the throttle holds writes to the interval.
|
|
37
|
+
*/
|
|
38
|
+
describe('UserActivityStamp — interactive presence', () => {
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
await testEnv.beforeAll();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await testEnv.afterAll();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
// Each test controls its own throttle history.
|
|
49
|
+
stampInternals(new UserActivityStamp()).lastStampMs.clear();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('stamps presence when a signed-in human makes an interactive request (through userCache.create)', async () => {
|
|
53
|
+
const user = await testEnv.createUser({ name: 'Present Human', email: 'present-human@test.local' });
|
|
54
|
+
|
|
55
|
+
const resolved = await userCache.create('interactive-session', user.email);
|
|
56
|
+
expect(resolved.id).toBe(user.id);
|
|
57
|
+
|
|
58
|
+
const row = await waitForActivityRow(user.id);
|
|
59
|
+
expect(moment(row.lastActiveAt).isAfter(moment().subtract(1, 'minute'))).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keeps ONE row per user and advances it on later stamps (scope-unique invariant)', async () => {
|
|
63
|
+
const user = await testEnv.createUser({ name: 'Returning Human', email: 'returning-human@test.local' });
|
|
64
|
+
const stamp = new UserActivityStamp();
|
|
65
|
+
|
|
66
|
+
await stamp.recordInteractiveRequest(user);
|
|
67
|
+
const first = await waitForActivityRow(user.id);
|
|
68
|
+
|
|
69
|
+
// Clear the throttle so the second request stamps immediately.
|
|
70
|
+
stampInternals(stamp).lastStampMs.clear();
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
72
|
+
await stamp.recordInteractiveRequest(user);
|
|
73
|
+
|
|
74
|
+
const rows = await activityRows(user.id);
|
|
75
|
+
expect(rows).toHaveLength(1);
|
|
76
|
+
expect(moment(rows[0].lastActiveAt).valueOf()).toBeGreaterThanOrEqual(moment(first.lastActiveAt).valueOf());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('never stamps a machine account, even though its requests ride a real session', async () => {
|
|
80
|
+
const machine = await getDbAsSystem().insert(tables.User, {
|
|
81
|
+
name: 'Ops machine',
|
|
82
|
+
email: 'stamp-machine@test.local',
|
|
83
|
+
password: 'test',
|
|
84
|
+
emailVerified: true,
|
|
85
|
+
roles: [],
|
|
86
|
+
isLoadedFromSource: true,
|
|
87
|
+
} as unknown as User);
|
|
88
|
+
|
|
89
|
+
// Await the stamp DIRECTLY (deterministic absence — no fire-and-forget race), then confirm
|
|
90
|
+
// the transport seam agrees by resolving the machine session through userCache too.
|
|
91
|
+
await new UserActivityStamp().recordInteractiveRequest(machine);
|
|
92
|
+
const resolved = await userCache.create('machine-session', machine.email);
|
|
93
|
+
expect(resolved.id).toBe(machine.id);
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
95
|
+
|
|
96
|
+
expect(await activityRows(machine.id)).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('throttles: a second request inside the interval writes nothing', async () => {
|
|
100
|
+
const user = await testEnv.createUser({ name: 'Rapid Human', email: 'rapid-human@test.local' });
|
|
101
|
+
const stamp = new UserActivityStamp();
|
|
102
|
+
|
|
103
|
+
await stamp.recordInteractiveRequest(user);
|
|
104
|
+
const first = await waitForActivityRow(user.id);
|
|
105
|
+
|
|
106
|
+
// Throttle history now holds this user; a second stamp inside the interval must not write.
|
|
107
|
+
await stamp.recordInteractiveRequest(user);
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
109
|
+
|
|
110
|
+
const rows = await activityRows(user.id);
|
|
111
|
+
expect(rows).toHaveLength(1);
|
|
112
|
+
expect(moment(rows[0].lastActiveAt).valueOf()).toBe(moment(first.lastActiveAt).valueOf());
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('a session for a missing account still resolves guest and stamps nothing (no throw)', async () => {
|
|
116
|
+
const before = (await getDbAsSystem().query(tables.UserActivity, {})).length;
|
|
117
|
+
const resolved = await userCache.create('stale-session', 'no-such-account@test.local');
|
|
118
|
+
expect(resolved.id).toBe('guest');
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
120
|
+
expect((await getDbAsSystem().query(tables.UserActivity, {})).length).toBe(before);
|
|
121
|
+
});
|
|
122
|
+
});
|