Skip to content

Commit fb799a2

Browse files
authored
feat: apply system limit (#7925)
* feat: apply system limit * refactor: type system * refactor: address review comments
1 parent 900201a commit fb799a2

File tree

50 files changed

+630
-280
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+630
-280
lines changed

packages/connectors/connector-logto-email/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"access": "public"
5353
},
5454
"devDependencies": {
55-
"@logto/cloud": "0.2.5-af943a1",
55+
"@logto/cloud": "0.2.5-fd82773",
5656
"@silverhand/eslint-config": "6.0.1",
5757
"@silverhand/ts-config": "6.0.0",
5858
"@types/node": "^22.14.0",

packages/console/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"@fontsource/roboto-mono": "^5.0.0",
2929
"@inkeep/cxkit-react": "^0.5.66",
3030
"@jest/types": "^29.5.0",
31-
"@logto/cloud": "0.2.5-af943a1",
31+
"@logto/cloud": "0.2.5-fd82773",
3232
"@logto/connector-kit": "workspace:^",
3333
"@logto/core-kit": "workspace:^",
3434
"@logto/language-kit": "workspace:^",

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
"zod": "3.24.3"
102102
},
103103
"devDependencies": {
104-
"@logto/cloud": "0.2.5-af943a1",
104+
"@logto/cloud": "0.2.5-fd82773",
105105
"@silverhand/eslint-config": "6.0.1",
106106
"@silverhand/ts-config": "6.0.0",
107107
"@types/adm-zip": "^0.5.5",

packages/core/src/__mocks__/cloud-connection.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ const defaultSystemLimit: Subscription['systemLimit'] = {
1717
organizationsLimit: 100_000,
1818
hooksLimit: 10,
1919
tenantMembersLimit: 100,
20+
usersPerOrganizationLimit: 1000,
21+
organizationUserRolesLimit: 1000,
22+
organizationMachineToMachineRolesLimit: 500,
23+
organizationScopesLimit: 1000,
2024
};
2125

2226
export const mockGetCloudConnectionData: CloudConnectionLibrary['getCloudConnectionData'] =

packages/core/src/libraries/jwt-customizer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ export class JwtCustomizerLibrary {
171171
return;
172172
}
173173

174+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
175+
// @ts-ignore TS2589: caused by router type growth from @logto/cloud
174176
const [client, jwtCustomizers] = await Promise.all([
175177
this.cloudConnection.getClient(),
176178
this.logtoConfigs.getJwtCustomizers(consoleLog),

packages/core/src/libraries/quota.test.ts

Lines changed: 113 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,10 @@ const { jest } = import.meta;
77
const { mockEsmWithActual } = createMockUtils(jest);
88

99
const mockGetTenantSubscription = jest.fn();
10-
const mockGetTenantUsageData = jest.fn();
1110
const mockReportSubscriptionUpdates = jest.fn();
1211

1312
await mockEsmWithActual('#src/utils/subscription/index.js', () => ({
1413
getTenantSubscription: mockGetTenantSubscription,
15-
getTenantUsageData: mockGetTenantUsageData,
1614
reportSubscriptionUpdates: mockReportSubscriptionUpdates,
1715
}));
1816

@@ -22,6 +20,7 @@ const { QuotaLibrary } = await import('./quota.js');
2220

2321
const originalIsCloud = EnvSet.values.isCloud;
2422
const originalIsIntegrationTest = EnvSet.values.isIntegrationTest;
23+
const originalIsDevFeaturesEnabled = EnvSet.values.isDevFeaturesEnabled;
2524

2625
/**
2726
* These tests spin up a full MockTenant, which instantiates the real EnvSet and runs its load sequence.
@@ -30,7 +29,10 @@ const originalIsIntegrationTest = EnvSet.values.isIntegrationTest;
3029
* reference to the original EnvSet singleton. Mutating the live flags keeps both MockTenant and
3130
* QuotaLibrary aligned with the test scenarios without breaking their dependency chain.
3231
*/
33-
const setEnvFlag = (key: 'isCloud' | 'isIntegrationTest', value: boolean) => {
32+
const setEnvFlag = (
33+
key: 'isCloud' | 'isIntegrationTest' | 'isDevFeaturesEnabled',
34+
value: boolean
35+
) => {
3436
Reflect.set(EnvSet.values, key, value);
3537
};
3638

@@ -57,22 +59,15 @@ beforeEach(() => {
5759
jest.clearAllMocks();
5860
setEnvFlag('isCloud', true);
5961
setEnvFlag('isIntegrationTest', false);
62+
setEnvFlag('isDevFeaturesEnabled', originalIsDevFeaturesEnabled);
6063

6164
mockGetTenantSubscription.mockResolvedValue(mockSubscriptionData);
62-
mockGetTenantUsageData.mockResolvedValue({
63-
quota: mockSubscriptionData.quota,
64-
usage: {
65-
tenantMembersLimit: 0,
66-
socialConnectorsLimit: 0,
67-
},
68-
resources: {},
69-
roles: {},
70-
});
7165
});
7266

7367
afterEach(() => {
7468
setEnvFlag('isCloud', originalIsCloud);
7569
setEnvFlag('isIntegrationTest', originalIsIntegrationTest);
70+
setEnvFlag('isDevFeaturesEnabled', originalIsDevFeaturesEnabled);
7671
});
7772

7873
describe('guardTenantUsageByKey', () => {
@@ -92,13 +87,18 @@ describe('guardTenantUsageByKey', () => {
9287
expect(getSelfComputedUsageByKey).not.toHaveBeenCalled();
9388
});
9489

95-
it('skips guard when quota limit is null', async () => {
90+
it('skips guard when both quota and system limits are null/undefined', async () => {
91+
setEnvFlag('isDevFeaturesEnabled', true);
9692
mockGetTenantSubscription.mockResolvedValueOnce({
9793
...mockSubscriptionData,
9894
quota: {
9995
...mockSubscriptionData.quota,
10096
applicationsLimit: null,
10197
},
98+
systemLimit: {
99+
...mockSubscriptionData.systemLimit,
100+
applicationsLimit: undefined,
101+
},
102102
});
103103

104104
const getSelfComputedUsageByKey = jest.fn();
@@ -113,6 +113,62 @@ describe('guardTenantUsageByKey', () => {
113113
expect(getSelfComputedUsageByKey).not.toHaveBeenCalled();
114114
});
115115

116+
// Todo @xiaoyijun: remove once the dev feature guard is retired.
117+
it('skips system limit guard when dev features are disabled', async () => {
118+
setEnvFlag('isDevFeaturesEnabled', false);
119+
mockGetTenantSubscription.mockResolvedValueOnce({
120+
...mockSubscriptionData,
121+
quota: {
122+
...mockSubscriptionData.quota,
123+
applicationsLimit: null,
124+
},
125+
systemLimit: {
126+
...mockSubscriptionData.systemLimit,
127+
applicationsLimit: 2,
128+
},
129+
});
130+
131+
const getSelfComputedUsageByKey = jest.fn().mockResolvedValue(2);
132+
const { quotaLibrary } = createQuotaLibrary({
133+
queriesOverride: {
134+
tenantUsage: { getSelfComputedUsageByKey },
135+
},
136+
});
137+
138+
await expect(quotaLibrary.guardTenantUsageByKey('applicationsLimit')).resolves.not.toThrow();
139+
140+
expect(getSelfComputedUsageByKey).not.toHaveBeenCalled();
141+
});
142+
143+
it('throws when usage reaches system limit', async () => {
144+
setEnvFlag('isDevFeaturesEnabled', true);
145+
mockGetTenantSubscription.mockResolvedValueOnce({
146+
...mockSubscriptionData,
147+
quota: {
148+
...mockSubscriptionData.quota,
149+
applicationsLimit: null,
150+
},
151+
systemLimit: {
152+
...mockSubscriptionData.systemLimit,
153+
applicationsLimit: 2,
154+
},
155+
});
156+
157+
const getSelfComputedUsageByKey = jest.fn().mockResolvedValue(2);
158+
const { quotaLibrary } = createQuotaLibrary({
159+
queriesOverride: {
160+
tenantUsage: { getSelfComputedUsageByKey },
161+
},
162+
});
163+
164+
await expect(quotaLibrary.guardTenantUsageByKey('applicationsLimit')).rejects.toMatchObject({
165+
code: 'system_limit.limit_exceeded',
166+
status: 403,
167+
});
168+
169+
expect(getSelfComputedUsageByKey).toHaveBeenCalledTimes(1);
170+
});
171+
116172
it('throws when boolean quota limit is disabled', async () => {
117173
const { quotaLibrary } = createQuotaLibrary();
118174

@@ -171,12 +227,17 @@ describe('guardTenantUsageByKey', () => {
171227
});
172228

173229
it('throws when numeric quota limit is not number type', async () => {
230+
setEnvFlag('isDevFeaturesEnabled', true);
174231
mockGetTenantSubscription.mockResolvedValueOnce({
175232
...mockSubscriptionData,
176233
quota: {
177234
...mockSubscriptionData.quota,
178235
applicationsLimit: '3' as unknown as number,
179236
},
237+
systemLimit: {
238+
...mockSubscriptionData.systemLimit,
239+
applicationsLimit: undefined,
240+
},
180241
});
181242

182243
const getSelfComputedUsageByKey = jest.fn();
@@ -209,35 +270,6 @@ describe('guardTenantUsageByKey', () => {
209270
});
210271
});
211272

212-
it('uses cloud usage data for tenantMembersLimit', async () => {
213-
mockGetTenantSubscription.mockResolvedValueOnce({
214-
...mockSubscriptionData,
215-
quota: {
216-
...mockSubscriptionData.quota,
217-
tenantMembersLimit: 10,
218-
},
219-
});
220-
mockGetTenantUsageData.mockResolvedValueOnce({
221-
quota: mockSubscriptionData.quota,
222-
usage: { tenantMembersLimit: 5 },
223-
resources: {},
224-
roles: {},
225-
});
226-
227-
const getSelfComputedUsageByKey = jest.fn();
228-
229-
const { quotaLibrary, tenant } = createQuotaLibrary({
230-
queriesOverride: {
231-
tenantUsage: { getSelfComputedUsageByKey },
232-
},
233-
});
234-
235-
await quotaLibrary.guardTenantUsageByKey('tenantMembersLimit');
236-
237-
expect(mockGetTenantUsageData).toHaveBeenCalledWith(tenant.cloudConnection);
238-
expect(getSelfComputedUsageByKey).not.toHaveBeenCalled();
239-
});
240-
241273
it('uses connector library for socialConnectorsLimit', async () => {
242274
mockGetTenantSubscription.mockResolvedValueOnce({
243275
...mockSubscriptionData,
@@ -281,13 +313,13 @@ describe('guardTenantUsageByKey', () => {
281313
},
282314
});
283315

284-
await quotaLibrary.guardTenantUsageByKey('scopesPerResourceLimit', {
285-
entityId: 'resource_1',
286-
});
316+
await quotaLibrary.guardTenantUsageByKey('scopesPerResourceLimit', 'resource_1');
287317

288-
expect(getSelfComputedUsageByKey).toHaveBeenCalledWith(tenant.id, 'scopesPerResourceLimit', {
289-
entityId: 'resource_1',
290-
});
318+
expect(getSelfComputedUsageByKey).toHaveBeenCalledWith(
319+
tenant.id,
320+
'scopesPerResourceLimit',
321+
'resource_1'
322+
);
291323
});
292324

293325
it('throws when entity usage exceeds limit', async () => {
@@ -308,24 +340,25 @@ describe('guardTenantUsageByKey', () => {
308340
});
309341

310342
await expect(
311-
quotaLibrary.guardTenantUsageByKey('scopesPerRoleLimit', { entityId: 'role_1' })
343+
quotaLibrary.guardTenantUsageByKey('scopesPerRoleLimit', 'role_1')
312344
).rejects.toMatchObject({
313345
code: 'subscription.limit_exceeded',
314346
status: 403,
315347
});
316348
});
317349

318350
it('skips guard for add-on usage keys on paid plans', async () => {
351+
setEnvFlag('isDevFeaturesEnabled', false);
319352
mockGetTenantSubscription.mockResolvedValueOnce({
320353
...mockSubscriptionData,
321-
planId: ReservedPlanId.Pro,
354+
planId: ReservedPlanId.Pro202509,
322355
quota: {
323356
...mockSubscriptionData.quota,
324357
machineToMachineLimit: 1,
325358
},
326359
});
327360

328-
const getSelfComputedUsageByKey = jest.fn();
361+
const getSelfComputedUsageByKey = jest.fn().mockResolvedValue(0);
329362

330363
const { quotaLibrary } = createQuotaLibrary({
331364
queriesOverride: {
@@ -337,6 +370,35 @@ describe('guardTenantUsageByKey', () => {
337370

338371
expect(getSelfComputedUsageByKey).not.toHaveBeenCalled();
339372
});
373+
374+
it('calls getTenantUsageByKey only once when both system limit and quota limit checks are needed', async () => {
375+
setEnvFlag('isDevFeaturesEnabled', true);
376+
mockGetTenantSubscription.mockResolvedValueOnce({
377+
...mockSubscriptionData,
378+
quota: {
379+
...mockSubscriptionData.quota,
380+
applicationsLimit: 10, // Quota limit set
381+
},
382+
systemLimit: {
383+
...mockSubscriptionData.systemLimit,
384+
applicationsLimit: 5, // System limit set (lower than quota)
385+
},
386+
});
387+
388+
const getSelfComputedUsageByKey = jest.fn().mockResolvedValue(2);
389+
390+
const { quotaLibrary } = createQuotaLibrary({
391+
queriesOverride: {
392+
tenantUsage: { getSelfComputedUsageByKey },
393+
},
394+
});
395+
396+
// Should pass both checks: 2 < 5 (system limit) and 2 < 10 (quota limit)
397+
await expect(quotaLibrary.guardTenantUsageByKey('applicationsLimit')).resolves.not.toThrow();
398+
399+
// The key point: usage should be fetched only once, not twice
400+
expect(getSelfComputedUsageByKey).toHaveBeenCalledTimes(1);
401+
});
340402
});
341403

342404
describe('reportSubscriptionUpdatesUsage', () => {

0 commit comments

Comments
 (0)