@tumbaland/backend-core 1.24.0 → 1.26.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/dist/app/createBaseApp.d.ts +24 -0
- package/dist/app/createBaseApp.d.ts.map +1 -1
- package/dist/app/createBaseApp.js +20 -1
- package/dist/app/createBaseApp.js.map +1 -1
- package/dist/entitlements/client.d.ts.map +1 -1
- package/dist/entitlements/client.js +17 -1
- package/dist/entitlements/client.js.map +1 -1
- package/dist/entitlements/index.d.ts +3 -2
- package/dist/entitlements/index.d.ts.map +1 -1
- package/dist/entitlements/index.js +5 -4
- package/dist/entitlements/index.js.map +1 -1
- package/dist/entitlements/middleware.d.ts.map +1 -1
- package/dist/entitlements/middleware.js +8 -19
- package/dist/entitlements/middleware.js.map +1 -1
- package/dist/entitlements/reconcile.d.ts +74 -0
- package/dist/entitlements/reconcile.d.ts.map +1 -0
- package/dist/entitlements/reconcile.js +297 -0
- package/dist/entitlements/reconcile.js.map +1 -0
- package/dist/entitlements/types.d.ts +0 -8
- package/dist/entitlements/types.d.ts.map +1 -1
- package/dist/entitlements/usage.d.ts +1 -1
- package/dist/entitlements/usage.d.ts.map +1 -1
- package/dist/entitlements/usage.js +11 -20
- package/dist/entitlements/usage.js.map +1 -1
- package/dist/groups/client.d.ts +27 -0
- package/dist/groups/client.d.ts.map +1 -0
- package/dist/groups/client.js +153 -0
- package/dist/groups/client.js.map +1 -0
- package/dist/groups/index.d.ts +5 -0
- package/dist/groups/index.d.ts.map +1 -0
- package/dist/groups/index.js +10 -0
- package/dist/groups/index.js.map +1 -0
- package/dist/groups/subject.d.ts +30 -0
- package/dist/groups/subject.d.ts.map +1 -0
- package/dist/groups/subject.js +52 -0
- package/dist/groups/subject.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/createBaseApp.test.ts +31 -0
- package/src/app/createBaseApp.ts +28 -1
- package/src/entitlements/client.test.ts +30 -0
- package/src/entitlements/client.ts +18 -1
- package/src/entitlements/index.ts +4 -2
- package/src/entitlements/middleware.test.ts +9 -30
- package/src/entitlements/middleware.ts +8 -19
- package/src/entitlements/reconcile.test.ts +333 -0
- package/src/entitlements/reconcile.ts +384 -0
- package/src/entitlements/types.ts +0 -9
- package/src/entitlements/usage.test.ts +27 -31
- package/src/entitlements/usage.ts +12 -22
- package/src/groups/client.test.ts +215 -0
- package/src/groups/client.ts +182 -0
- package/src/groups/index.ts +5 -0
- package/src/groups/subject.test.ts +85 -0
- package/src/groups/subject.ts +50 -0
- package/src/index.ts +3 -0
- package/dist/entitlements/mode.d.ts +0 -10
- package/dist/entitlements/mode.d.ts.map +0 -1
- package/dist/entitlements/mode.js +0 -32
- package/dist/entitlements/mode.js.map +0 -1
- package/src/entitlements/mode.test.ts +0 -50
- package/src/entitlements/mode.ts +0 -28
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
jest.mock('../logging/logger', () => ({
|
|
2
|
+
__esModule: true,
|
|
3
|
+
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
4
|
+
}));
|
|
5
|
+
|
|
6
|
+
import mongoose from 'mongoose';
|
|
7
|
+
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
8
|
+
import logger from '../logging/logger';
|
|
9
|
+
import { UsageMeter } from './UsageMeter';
|
|
10
|
+
import { getUsage, setUsage } from './usage';
|
|
11
|
+
import { reconcileUsageMeters, runUsageReconciliation } from './reconcile';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Against a real Mongo, not mocks: the whole point of this module is the shape
|
|
15
|
+
* of the aggregations and the joins between four services' collections, and a
|
|
16
|
+
* mocked driver would assert nothing about either.
|
|
17
|
+
*/
|
|
18
|
+
let mongod: MongoMemoryServer;
|
|
19
|
+
|
|
20
|
+
const OWNER = 'owner@example.com';
|
|
21
|
+
const MEMBER = 'member@example.com';
|
|
22
|
+
const LONER = 'loner@example.com';
|
|
23
|
+
|
|
24
|
+
const OWNER_ID = '68987cf71078a6d4d52ba430';
|
|
25
|
+
const MEMBER_ID = '689880b91078a6d4d52ba43f';
|
|
26
|
+
const LONER_ID = '68b4e9fc05b3c0001a2286c3';
|
|
27
|
+
const GROUP_ID = '689a8219e15515e8f6539ad2';
|
|
28
|
+
|
|
29
|
+
const db = () => mongoose.connection.db!;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
mongod = await MongoMemoryServer.create();
|
|
33
|
+
await mongoose.connect(mongod.getUri());
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await mongoose.disconnect();
|
|
38
|
+
await mongod.stop();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** The shape the live database actually has: everything owned through one group. */
|
|
42
|
+
async function seed(
|
|
43
|
+
overrides: {
|
|
44
|
+
groups?: unknown[];
|
|
45
|
+
albums?: unknown[];
|
|
46
|
+
photos?: unknown[];
|
|
47
|
+
tickers?: unknown[];
|
|
48
|
+
} = {}
|
|
49
|
+
) {
|
|
50
|
+
await db()
|
|
51
|
+
.collection('users')
|
|
52
|
+
.insertMany([
|
|
53
|
+
{ _id: new mongoose.Types.ObjectId(OWNER_ID), email: 'Owner@Example.com' },
|
|
54
|
+
{ _id: new mongoose.Types.ObjectId(MEMBER_ID), email: MEMBER },
|
|
55
|
+
{ _id: new mongoose.Types.ObjectId(LONER_ID), email: LONER }
|
|
56
|
+
] as never);
|
|
57
|
+
|
|
58
|
+
const groups = overrides.groups ?? [
|
|
59
|
+
{
|
|
60
|
+
_id: new mongoose.Types.ObjectId(GROUP_ID),
|
|
61
|
+
name: 'Household',
|
|
62
|
+
createdBy: OWNER,
|
|
63
|
+
members: [
|
|
64
|
+
{ email: OWNER, role: 'owner' },
|
|
65
|
+
{ email: MEMBER, role: 'admin' }
|
|
66
|
+
],
|
|
67
|
+
deletedAt: null
|
|
68
|
+
}
|
|
69
|
+
];
|
|
70
|
+
if (groups.length) await db().collection('groups').insertMany(groups as never);
|
|
71
|
+
|
|
72
|
+
const albums = overrides.albums ?? [
|
|
73
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, groupId: GROUP_ID, totalSize: 300, name: 'Shared' },
|
|
74
|
+
{ _id: new mongoose.Types.ObjectId(), userId: MEMBER_ID, totalSize: 50, name: 'Personal' }
|
|
75
|
+
];
|
|
76
|
+
if (albums.length) await db().collection('albums').insertMany(albums as never);
|
|
77
|
+
|
|
78
|
+
const photos =
|
|
79
|
+
overrides.photos ??
|
|
80
|
+
(albums as { _id: mongoose.Types.ObjectId }[]).flatMap((album, i) => [
|
|
81
|
+
{ albumId: album._id, size: i === 0 ? 200 : 50 },
|
|
82
|
+
...(i === 0 ? [{ albumId: album._id, size: 100 }] : [])
|
|
83
|
+
]);
|
|
84
|
+
if ((photos as unknown[]).length) await db().collection('photos').insertMany(photos as never);
|
|
85
|
+
|
|
86
|
+
const tickers = overrides.tickers ?? [
|
|
87
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, groupId: GROUP_ID, ticker: 'VWCE.DEX', isActive: true },
|
|
88
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, groupId: GROUP_ID, ticker: 'VUSA.DEX', isActive: true }
|
|
89
|
+
];
|
|
90
|
+
if ((tickers as unknown[]).length) await db().collection('trackedtickers').insertMany(tickers as never);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
for (const name of ['users', 'groups', 'albums', 'photos', 'trackedtickers']) {
|
|
95
|
+
await db().collection(name).deleteMany({});
|
|
96
|
+
}
|
|
97
|
+
await UsageMeter.deleteMany({});
|
|
98
|
+
await UsageMeter.syncIndexes();
|
|
99
|
+
jest.clearAllMocks();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('reconcileUsageMeters', () => {
|
|
103
|
+
it('bills a group album to the owner and a personal album to its own user', async () => {
|
|
104
|
+
await seed();
|
|
105
|
+
|
|
106
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
107
|
+
|
|
108
|
+
// 200 + 100 in the shared album, all to the owner; the member keeps only
|
|
109
|
+
// their own 50, rather than the shared bytes landing on their free tier.
|
|
110
|
+
expect(report.expected[OWNER].storageBytes).toBe(300);
|
|
111
|
+
expect(report.expected[MEMBER].storageBytes).toBe(50);
|
|
112
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
113
|
+
expect(await getUsage(MEMBER, 'storageBytes')).toBe(50);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('joins userId to email through the users collection, matching on lowercase', async () => {
|
|
117
|
+
await seed();
|
|
118
|
+
|
|
119
|
+
await reconcileUsageMeters({ apply: true });
|
|
120
|
+
|
|
121
|
+
// The seeded user has a capitalised address; meters are keyed lowercase, so
|
|
122
|
+
// a missed normalization would open a second counter instead of filling one.
|
|
123
|
+
expect(await getUsage('owner@example.com', 'storageBytes')).toBe(300);
|
|
124
|
+
expect(await UsageMeter.countDocuments({ meter: 'storageBytes' })).toBe(2);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('counts distinct active symbols, not rows', async () => {
|
|
128
|
+
await seed({
|
|
129
|
+
tickers: [
|
|
130
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, groupId: GROUP_ID, ticker: 'VWCE.DEX', isActive: true },
|
|
131
|
+
// Same symbol held personally as well — one Alpha Vantage call covers both.
|
|
132
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, ticker: 'vwce.dex', isActive: true },
|
|
133
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, ticker: 'DEAD.DEX', isActive: false }
|
|
134
|
+
]
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await reconcileUsageMeters({ apply: true });
|
|
138
|
+
|
|
139
|
+
expect(await getUsage(OWNER, 'trackedTickers')).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('counts seats as distinct people in the groups a subject owns', async () => {
|
|
143
|
+
await seed();
|
|
144
|
+
|
|
145
|
+
await reconcileUsageMeters({ apply: true });
|
|
146
|
+
|
|
147
|
+
expect(await getUsage(OWNER, 'seats')).toBe(2);
|
|
148
|
+
// A member is not charged for a group somebody else owns.
|
|
149
|
+
expect(await getUsage(MEMBER, 'seats')).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('keeps billing a soft-deleted group storage and tickers, but not seats', async () => {
|
|
153
|
+
await seed({
|
|
154
|
+
groups: [
|
|
155
|
+
{
|
|
156
|
+
_id: new mongoose.Types.ObjectId(GROUP_ID),
|
|
157
|
+
name: 'Household',
|
|
158
|
+
createdBy: OWNER,
|
|
159
|
+
members: [
|
|
160
|
+
{ email: OWNER, role: 'owner' },
|
|
161
|
+
{ email: MEMBER, role: 'admin' }
|
|
162
|
+
],
|
|
163
|
+
deletedAt: new Date('2026-01-01')
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await reconcileUsageMeters({ apply: true });
|
|
169
|
+
|
|
170
|
+
// The photos are still in MinIO and the tickers are still fetched daily;
|
|
171
|
+
// the seats grant nobody access to anything.
|
|
172
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
173
|
+
expect(await getUsage(OWNER, 'trackedTickers')).toBe(2);
|
|
174
|
+
expect(await getUsage(OWNER, 'seats')).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('prefers the owner role over a stale createdBy', async () => {
|
|
178
|
+
await seed({
|
|
179
|
+
groups: [
|
|
180
|
+
{
|
|
181
|
+
_id: new mongoose.Types.ObjectId(GROUP_ID),
|
|
182
|
+
name: 'Household',
|
|
183
|
+
createdBy: LONER,
|
|
184
|
+
members: [
|
|
185
|
+
{ email: OWNER, role: 'owner' },
|
|
186
|
+
{ email: MEMBER, role: 'admin' }
|
|
187
|
+
],
|
|
188
|
+
deletedAt: null
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await reconcileUsageMeters({ apply: true });
|
|
194
|
+
|
|
195
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
196
|
+
expect(await getUsage(LONER, 'storageBytes')).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('writes nothing without apply', async () => {
|
|
200
|
+
await seed();
|
|
201
|
+
|
|
202
|
+
const report = await reconcileUsageMeters();
|
|
203
|
+
|
|
204
|
+
expect(report.changes.length).toBeGreaterThan(0);
|
|
205
|
+
expect(report.written).toBe(0);
|
|
206
|
+
expect(await UsageMeter.countDocuments({})).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('is idempotent — a second run in a row changes nothing', async () => {
|
|
210
|
+
await seed();
|
|
211
|
+
|
|
212
|
+
await reconcileUsageMeters({ apply: true });
|
|
213
|
+
const second = await reconcileUsageMeters({ apply: true });
|
|
214
|
+
|
|
215
|
+
expect(second.changes).toEqual([]);
|
|
216
|
+
expect(second.written).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('repairs a leaked increment, which is the whole reason it is scheduled', async () => {
|
|
220
|
+
await seed();
|
|
221
|
+
// A crash between writing the photo and updating the meter, or a release
|
|
222
|
+
// that never ran: the counter says more than the photos do.
|
|
223
|
+
await setUsage(OWNER, 'storageBytes', 999999);
|
|
224
|
+
|
|
225
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
226
|
+
|
|
227
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
228
|
+
expect(report.changes).toContainEqual({ subjectId: OWNER, meter: 'storageBytes', from: 999999, to: 300 });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('corrects an existing counter down to zero but never creates a zero row', async () => {
|
|
232
|
+
await seed({ albums: [], photos: [], tickers: [] });
|
|
233
|
+
await setUsage(OWNER, 'storageBytes', 500);
|
|
234
|
+
|
|
235
|
+
await reconcileUsageMeters({ apply: true });
|
|
236
|
+
|
|
237
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(0);
|
|
238
|
+
// The other users had no counter and no usage — nothing was invented.
|
|
239
|
+
expect(await UsageMeter.countDocuments({ subjectId: LONER })).toBe(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('reports meters for unknown subjects instead of deleting them', async () => {
|
|
243
|
+
await seed();
|
|
244
|
+
await setUsage('ghost@example.com', 'storageBytes', 42);
|
|
245
|
+
|
|
246
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
247
|
+
|
|
248
|
+
expect(report.orphanedMeters).toEqual([{ subjectId: 'ghost@example.com', meter: 'storageBytes', count: 42 }]);
|
|
249
|
+
expect(await getUsage('ghost@example.com', 'storageBytes')).toBe(42);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('scopes to one subject when asked, leaving everyone else untouched', async () => {
|
|
253
|
+
await seed();
|
|
254
|
+
await setUsage(MEMBER, 'storageBytes', 777);
|
|
255
|
+
|
|
256
|
+
await reconcileUsageMeters({ apply: true, subject: 'Owner@Example.com' });
|
|
257
|
+
|
|
258
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
259
|
+
expect(await getUsage(MEMBER, 'storageBytes')).toBe(777);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('never touches aiJobs, which has no source data to recompute from', async () => {
|
|
263
|
+
await seed();
|
|
264
|
+
await setUsage(OWNER, 'aiJobs', 17);
|
|
265
|
+
|
|
266
|
+
await reconcileUsageMeters({ apply: true });
|
|
267
|
+
|
|
268
|
+
expect(await getUsage(OWNER, 'aiJobs')).toBe(17);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('bills an album in a vanished group to its uploader rather than losing the bytes', async () => {
|
|
272
|
+
await seed({ groups: [] });
|
|
273
|
+
|
|
274
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
275
|
+
|
|
276
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
277
|
+
expect(report.warnings.some(w => w.includes('references missing group'))).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('warns when a denormalized album counter disagrees, without rewriting it', async () => {
|
|
281
|
+
const albumId = new mongoose.Types.ObjectId();
|
|
282
|
+
await seed({
|
|
283
|
+
albums: [{ _id: albumId, userId: MEMBER_ID, totalSize: 9999, name: 'Drifted' }],
|
|
284
|
+
photos: [{ albumId, size: 50 }],
|
|
285
|
+
tickers: []
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
289
|
+
|
|
290
|
+
expect(await getUsage(MEMBER, 'storageBytes')).toBe(50);
|
|
291
|
+
expect(report.warnings.some(w => w.includes('but photos sum to 50'))).toBe(true);
|
|
292
|
+
const album = await db().collection('albums').findOne({ _id: albumId });
|
|
293
|
+
expect(album?.totalSize).toBe(9999);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('runUsageReconciliation', () => {
|
|
298
|
+
it('applies, and logs a correction at warn so a persistent leak is visible', async () => {
|
|
299
|
+
await seed();
|
|
300
|
+
// Settle first, so the only change the scheduled run sees is the drift.
|
|
301
|
+
await reconcileUsageMeters({ apply: true });
|
|
302
|
+
await setUsage(OWNER, 'storageBytes', 999999);
|
|
303
|
+
jest.clearAllMocks();
|
|
304
|
+
|
|
305
|
+
const report = await runUsageReconciliation();
|
|
306
|
+
|
|
307
|
+
expect(report?.written).toBe(1);
|
|
308
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
309
|
+
expect(logger.warn).toHaveBeenCalledWith('entitlement.reconciled', expect.objectContaining({ corrected: 1 }));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('logs at info when nothing drifted', async () => {
|
|
313
|
+
await seed();
|
|
314
|
+
await reconcileUsageMeters({ apply: true });
|
|
315
|
+
jest.clearAllMocks();
|
|
316
|
+
|
|
317
|
+
await runUsageReconciliation();
|
|
318
|
+
|
|
319
|
+
expect(logger.info).toHaveBeenCalledWith('entitlement.reconciled', expect.objectContaining({ corrected: 0 }));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('swallows a failure rather than taking the process down from a cron callback', async () => {
|
|
323
|
+
const spy = jest.spyOn(UsageMeter, 'find').mockImplementationOnce(() => {
|
|
324
|
+
throw new Error('mongo is gone');
|
|
325
|
+
});
|
|
326
|
+
await seed();
|
|
327
|
+
|
|
328
|
+
await expect(runUsageReconciliation()).resolves.toBeNull();
|
|
329
|
+
expect(logger.error).toHaveBeenCalledWith('Usage meter reconciliation failed', expect.anything());
|
|
330
|
+
|
|
331
|
+
spy.mockRestore();
|
|
332
|
+
});
|
|
333
|
+
});
|