plugin-ai-api 1.0.25 → 1.0.28
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/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
- package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
- package/dist/client/562.44b16aad4718b4c7.js +10 -0
- package/dist/client/685.ae483e17b6b49c98.js +10 -0
- package/dist/client/{757.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
- package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.3971233415999b2c.js +10 -0
- package/dist/client-v2/562.45d5c504433be38b.js +10 -0
- package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
- package/dist/client-v2/{757.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
- package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
- package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +2 -5
- package/dist/locale/en-US.json +26 -8
- package/dist/locale/vi-VN.json +26 -8
- package/dist/locale/zh-CN.json +26 -8
- package/dist/server/billing.js +25 -32
- package/dist/server/collections/ai-api-config.js +1 -7
- package/dist/server/collections/ai-api-group-members.js +62 -0
- package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
- package/dist/server/collections/ai-api-model-metadata.js +6 -0
- package/dist/server/collections/ai-api-usage-groups.js +74 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
- package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
- package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
- package/dist/server/plugin.js +90 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +0 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +32 -32
- package/dist/server/routes/completions.js +16 -19
- package/dist/server/routes/embeddings.js +2 -1
- package/dist/server/routes/models.js +2 -1
- package/dist/server/routes/router.js +3 -2
- package/dist/server/services/file-processor.js +186 -22
- package/dist/server/usage.js +5 -1
- package/dist/server/utils/direct-llm-context.js +13 -11
- package/dist/server/utils/rate-limiter.js +1 -1
- package/dist/server/utils/request-cache.js +61 -0
- package/dist/server/utils/resolve-service.js +2 -1
- package/dist/server/utils/user-permissions.js +25 -39
- package/dist/server/validation.js +7 -0
- package/dist/swagger.js +6 -7
- package/package.json +1 -1
- package/src/client/__tests__/settings-registration.test.tsx +6 -29
- package/src/client/plugin.tsx +5 -16
- package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
- package/src/client-v2/locale.ts +3 -1
- package/src/client-v2/pages/GeneralPage.tsx +0 -5
- package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
- package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +26 -8
- package/src/locale/vi-VN.json +26 -8
- package/src/locale/zh-CN.json +26 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +122 -4
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/usage-groups.test.ts +160 -0
- package/src/server/__tests__/usage-monitor.test.ts +2 -0
- package/src/server/__tests__/usage-route.test.ts +262 -2
- package/src/server/__tests__/usage.test.ts +38 -0
- package/src/server/__tests__/user-permissions.test.ts +214 -133
- package/src/server/__tests__/validation.test.ts +11 -0
- package/src/server/billing.ts +30 -38
- package/src/server/collections/ai-api-config.ts +1 -7
- package/src/server/collections/ai-api-group-members.ts +41 -0
- package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
- package/src/server/collections/ai-api-model-metadata.ts +7 -0
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/middleware/rate-limit.ts +10 -12
- package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
- package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
- package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
- package/src/server/plugin.ts +101 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +0 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +39 -36
- package/src/server/routes/completions.ts +18 -21
- package/src/server/routes/embeddings.ts +2 -1
- package/src/server/routes/models.ts +4 -3
- package/src/server/routes/router.ts +4 -3
- package/src/server/services/file-processor.ts +214 -24
- package/src/server/usage.ts +5 -1
- package/src/server/utils/direct-llm-context.ts +20 -11
- package/src/server/utils/rate-limiter.ts +1 -1
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +2 -1
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +7 -8
- package/dist/client/123.e6fe04c856ce6417.js +0 -10
- package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
- package/dist/client/562.17a0a299d2e5152c.js +0 -10
- package/dist/client/902.e74518750f1e4201.js +0 -10
- package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
- package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
- package/dist/client-v2/902.c7c00a565085438a.js +0 -10
- package/dist/server/resource/ai-api-user-permissions.js +0 -75
- package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
- package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
- package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
- package/src/server/resource/ai-api-user-permissions.ts +0 -76
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
11
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import type { AiApiUsageGroup } from '../quota-groups';
|
|
12
13
|
import {
|
|
13
14
|
buildAccessScope,
|
|
14
15
|
enforceModelAccess,
|
|
15
|
-
|
|
16
|
+
invalidateGroupAccessCache,
|
|
16
17
|
isModelAllowed,
|
|
17
18
|
isServiceAllowed,
|
|
18
19
|
resolveUserAccessScope,
|
|
@@ -21,48 +22,112 @@ import {
|
|
|
21
22
|
const OPENAI = { name: 'openai', title: 'OpenAI' };
|
|
22
23
|
const ANTHROPIC = { name: 'anthropic', title: 'Anthropic' };
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
function group(values: Partial<AiApiUsageGroup> = {}): AiApiUsageGroup {
|
|
26
|
+
return {
|
|
27
|
+
id: 1,
|
|
28
|
+
name: 'Test group',
|
|
29
|
+
isDefault: false,
|
|
30
|
+
quotaMode: 'per_user',
|
|
31
|
+
rateLimitPerMinute: 60,
|
|
32
|
+
enabled: false,
|
|
33
|
+
periodType: 'monthly',
|
|
34
|
+
timezone: 'UTC',
|
|
35
|
+
requestLimit: null,
|
|
36
|
+
totalTokenLimit: null,
|
|
37
|
+
costLimit: null,
|
|
38
|
+
currency: 'USD',
|
|
39
|
+
rejectUnpricedModel: true,
|
|
40
|
+
missingUsageBehavior: 'use_reserved',
|
|
41
|
+
contextOverflowBehavior: 'reject',
|
|
42
|
+
allowedLlmServices: [],
|
|
43
|
+
allowAllModels: true,
|
|
44
|
+
allowedModels: [],
|
|
45
|
+
...values,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Sequelize models expose columns via .get(); .get() with no argument returns the full record. */
|
|
25
50
|
function row(values: Record<string, unknown>) {
|
|
26
|
-
return { get: (key
|
|
51
|
+
return { get: (key?: string) => (key === undefined ? values : values[key]) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_GROUP_ROW = row({
|
|
55
|
+
id: 99,
|
|
56
|
+
name: 'Default',
|
|
57
|
+
isDefault: true,
|
|
58
|
+
allowedLlmServices: [],
|
|
59
|
+
allowAllModels: true,
|
|
60
|
+
allowedModels: [],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
interface MockOptions {
|
|
64
|
+
/** Default 1; null simulates an unauthenticated caller. */
|
|
65
|
+
userId?: number | null;
|
|
66
|
+
/** The group row attached to the caller's membership; omit = no membership row. */
|
|
67
|
+
memberGroup?: unknown;
|
|
68
|
+
/** Membership exists but points at a missing group. */
|
|
69
|
+
memberWithoutGroup?: boolean;
|
|
70
|
+
/** Default group row; omit = DEFAULT_GROUP_ROW, null = missing (lazy-create path). */
|
|
71
|
+
defaultGroup?: unknown;
|
|
72
|
+
throws?: boolean;
|
|
73
|
+
appName?: string;
|
|
27
74
|
}
|
|
28
75
|
|
|
29
|
-
function mockContext(options:
|
|
30
|
-
const
|
|
76
|
+
function mockContext(options: MockOptions = {}) {
|
|
77
|
+
const memberGroup = options.memberGroup ?? null;
|
|
78
|
+
const memberFindOne = vi.fn(async () => {
|
|
79
|
+
if (options.throws) throw new Error('collection unavailable');
|
|
80
|
+
if (options.memberWithoutGroup) return row({ group: null });
|
|
81
|
+
if (!memberGroup) return null;
|
|
82
|
+
return row({ group: memberGroup });
|
|
83
|
+
});
|
|
84
|
+
const hasDefault = 'defaultGroup' in options;
|
|
85
|
+
const groupFindOne = vi.fn(async () => {
|
|
31
86
|
if (options.throws) throw new Error('collection unavailable');
|
|
32
|
-
return options.
|
|
87
|
+
return hasDefault ? options.defaultGroup : DEFAULT_GROUP_ROW;
|
|
33
88
|
});
|
|
89
|
+
const groupCreate = vi.fn(async ({ values }: { values: Record<string, unknown> }) =>
|
|
90
|
+
row({ id: 99, isDefault: true, allowedLlmServices: [], allowAllModels: true, allowedModels: [], ...values }),
|
|
91
|
+
);
|
|
34
92
|
const ctx = {
|
|
35
93
|
state: { currentUser: options.userId === null ? undefined : { id: options.userId ?? 1 } },
|
|
36
|
-
db: {
|
|
94
|
+
db: {
|
|
95
|
+
getRepository: (name: string) =>
|
|
96
|
+
name === 'aiApiGroupMembers' ? { findOne: memberFindOne } : { findOne: groupFindOne, create: groupCreate },
|
|
97
|
+
},
|
|
37
98
|
app: { name: options.appName ?? 'main' },
|
|
38
99
|
log: { warn: vi.fn(), error: vi.fn() },
|
|
39
100
|
status: 200,
|
|
40
101
|
body: undefined,
|
|
41
102
|
} as unknown as Context;
|
|
42
|
-
return {
|
|
103
|
+
return {
|
|
104
|
+
ctx,
|
|
105
|
+
memberFindOne,
|
|
106
|
+
groupFindOne,
|
|
107
|
+
groupCreate,
|
|
108
|
+
};
|
|
43
109
|
}
|
|
44
110
|
|
|
45
111
|
beforeEach(() => {
|
|
46
|
-
|
|
112
|
+
invalidateGroupAccessCache();
|
|
47
113
|
});
|
|
48
114
|
|
|
49
115
|
describe('buildAccessScope', () => {
|
|
50
|
-
it('treats
|
|
51
|
-
const scope = buildAccessScope(
|
|
52
|
-
expect(scope.
|
|
53
|
-
expect(scope.
|
|
54
|
-
expect(scope.
|
|
116
|
+
it('treats empty group lists as no narrowing', () => {
|
|
117
|
+
const scope = buildAccessScope(group());
|
|
118
|
+
expect(scope.allowedServices).toEqual([]);
|
|
119
|
+
expect(scope.allowAllModels).toBe(true);
|
|
120
|
+
expect(scope.allowedModels.size).toBe(0);
|
|
121
|
+
expect(scope.lookupFailed).toBe(false);
|
|
55
122
|
});
|
|
56
123
|
|
|
57
|
-
it('
|
|
58
|
-
|
|
59
|
-
expect(scope.denyAll).toBe(true);
|
|
60
|
-
expect(scope.allowedServices).toEqual([]);
|
|
124
|
+
it('carries the group id for cache keying', () => {
|
|
125
|
+
expect(buildAccessScope(group({ id: 7 })).groupId).toBe(7);
|
|
61
126
|
});
|
|
62
127
|
|
|
63
|
-
it('
|
|
128
|
+
it('narrows services and models from the group settings', () => {
|
|
64
129
|
const scope = buildAccessScope(
|
|
65
|
-
|
|
130
|
+
group({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
|
|
66
131
|
);
|
|
67
132
|
expect(scope.allowedServices).toEqual(['openai']);
|
|
68
133
|
expect(scope.allowAllModels).toBe(false);
|
|
@@ -70,12 +135,15 @@ describe('buildAccessScope', () => {
|
|
|
70
135
|
});
|
|
71
136
|
|
|
72
137
|
it('defaults allowAllModels to true when the column is unset', () => {
|
|
73
|
-
expect(buildAccessScope(
|
|
138
|
+
expect(buildAccessScope(group({ allowAllModels: undefined })).allowAllModels).toBe(true);
|
|
74
139
|
});
|
|
75
140
|
|
|
76
141
|
it('discards non-string entries in the json arrays', () => {
|
|
77
142
|
const scope = buildAccessScope(
|
|
78
|
-
|
|
143
|
+
group({
|
|
144
|
+
allowedLlmServices: ['openai', null, 42, ''] as unknown as string[],
|
|
145
|
+
allowedModels: [{}, 'openai/gpt-4o'] as unknown as string[],
|
|
146
|
+
}),
|
|
79
147
|
);
|
|
80
148
|
expect(scope.allowedServices).toEqual(['openai']);
|
|
81
149
|
expect([...scope.allowedModels]).toEqual(['openai/gpt-4o']);
|
|
@@ -83,174 +151,185 @@ describe('buildAccessScope', () => {
|
|
|
83
151
|
});
|
|
84
152
|
|
|
85
153
|
describe('isServiceAllowed', () => {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
it('leaves behaviour unchanged for a user with no record', () => {
|
|
89
|
-
expect(isServiceAllowed(noRecord, ['openai'], OPENAI)).toBe(true);
|
|
90
|
-
expect(isServiceAllowed(noRecord, ['openai'], ANTHROPIC)).toBe(false);
|
|
91
|
-
expect(isServiceAllowed(noRecord, [], ANTHROPIC)).toBe(true);
|
|
92
|
-
});
|
|
154
|
+
const open = buildAccessScope(group());
|
|
93
155
|
|
|
94
|
-
it('
|
|
95
|
-
|
|
96
|
-
expect(isServiceAllowed(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
it('denies everything when the service list is empty', () => {
|
|
100
|
-
const scope = buildAccessScope(row({ allowedLlmServices: [] }));
|
|
101
|
-
expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(false);
|
|
102
|
-
expect(isServiceAllowed(scope, [], OPENAI)).toBe(false);
|
|
156
|
+
it('an open group inherits the global whitelist', () => {
|
|
157
|
+
expect(isServiceAllowed(open, ['openai'], OPENAI)).toBe(true);
|
|
158
|
+
expect(isServiceAllowed(open, ['openai'], ANTHROPIC)).toBe(false);
|
|
159
|
+
expect(isServiceAllowed(open, [], ANTHROPIC)).toBe(true);
|
|
103
160
|
});
|
|
104
161
|
|
|
105
162
|
it('never widens the global whitelist (strict subset)', () => {
|
|
106
|
-
const scope = buildAccessScope(
|
|
163
|
+
const scope = buildAccessScope(group({ allowedLlmServices: ['openai', 'anthropic'] }));
|
|
107
164
|
expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(true);
|
|
108
|
-
// Granted to the
|
|
165
|
+
// Granted to the group, but absent from the global whitelist → still denied.
|
|
109
166
|
expect(isServiceAllowed(scope, ['openai'], ANTHROPIC)).toBe(false);
|
|
110
167
|
});
|
|
111
168
|
|
|
112
|
-
it('
|
|
113
|
-
const
|
|
114
|
-
expect(isServiceAllowed(
|
|
115
|
-
expect(isServiceAllowed(
|
|
116
|
-
expect(isServiceAllowed(byTitle, [], ANTHROPIC)).toBe(false);
|
|
169
|
+
it('narrows within an empty global whitelist', () => {
|
|
170
|
+
const scope = buildAccessScope(group({ allowedLlmServices: ['openai'] }));
|
|
171
|
+
expect(isServiceAllowed(scope, [], OPENAI)).toBe(true);
|
|
172
|
+
expect(isServiceAllowed(scope, [], ANTHROPIC)).toBe(false);
|
|
117
173
|
});
|
|
118
174
|
|
|
119
|
-
it('
|
|
120
|
-
const scope = buildAccessScope(
|
|
175
|
+
it('matches a service by title as well as by name', () => {
|
|
176
|
+
const scope = buildAccessScope(group({ allowedLlmServices: ['OpenAI'] }));
|
|
121
177
|
expect(isServiceAllowed(scope, [], OPENAI)).toBe(true);
|
|
122
178
|
expect(isServiceAllowed(scope, [], ANTHROPIC)).toBe(false);
|
|
123
179
|
});
|
|
124
180
|
|
|
181
|
+
it('denies everything when the lookup failed', () => {
|
|
182
|
+
const failed = { ...open, lookupFailed: true };
|
|
183
|
+
expect(isServiceAllowed(failed, [], OPENAI)).toBe(false);
|
|
184
|
+
expect(isServiceAllowed(failed, ['openai'], OPENAI)).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
125
187
|
it('tolerates a null or malformed global whitelist', () => {
|
|
126
|
-
expect(isServiceAllowed(
|
|
127
|
-
expect(isServiceAllowed(
|
|
188
|
+
expect(isServiceAllowed(open, null, OPENAI)).toBe(true);
|
|
189
|
+
expect(isServiceAllowed(open, 'openai', OPENAI)).toBe(true);
|
|
128
190
|
});
|
|
129
191
|
});
|
|
130
192
|
|
|
131
193
|
describe('isModelAllowed', () => {
|
|
132
|
-
it('allows every model when the
|
|
133
|
-
expect(isModelAllowed(buildAccessScope(
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('allows every model of the granted services when allowAllModels is true', () => {
|
|
137
|
-
const scope = buildAccessScope(row({ allowedLlmServices: ['openai'], allowAllModels: true }));
|
|
138
|
-
expect(isModelAllowed(scope, 'openai/anything')).toBe(true);
|
|
194
|
+
it('allows every model when the group allows all', () => {
|
|
195
|
+
expect(isModelAllowed(buildAccessScope(group()), 'openai/gpt-4o')).toBe(true);
|
|
139
196
|
});
|
|
140
197
|
|
|
141
198
|
it('restricts to the listed models when allowAllModels is false', () => {
|
|
142
|
-
const scope = buildAccessScope(
|
|
143
|
-
row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
|
|
144
|
-
);
|
|
199
|
+
const scope = buildAccessScope(group({ allowAllModels: false, allowedModels: ['openai/gpt-4o'] }));
|
|
145
200
|
expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(true);
|
|
146
201
|
expect(isModelAllowed(scope, 'openai/gpt-4o-mini')).toBe(false);
|
|
147
202
|
});
|
|
148
203
|
|
|
149
|
-
it('denies every model when the
|
|
150
|
-
const
|
|
151
|
-
expect(isModelAllowed(
|
|
204
|
+
it('denies every model when the lookup failed', () => {
|
|
205
|
+
const failed = { ...buildAccessScope(group()), lookupFailed: true };
|
|
206
|
+
expect(isModelAllowed(failed, 'openai/gpt-4o')).toBe(false);
|
|
152
207
|
});
|
|
153
208
|
});
|
|
154
209
|
|
|
155
210
|
describe('resolveUserAccessScope', () => {
|
|
156
|
-
it('
|
|
157
|
-
const { ctx,
|
|
158
|
-
|
|
159
|
-
expect(
|
|
211
|
+
it('resolves the default group for an unauthenticated caller without a membership lookup', async () => {
|
|
212
|
+
const { ctx, memberFindOne, groupFindOne } = mockContext({ userId: null });
|
|
213
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
214
|
+
expect(scope.allowedServices).toEqual([]);
|
|
215
|
+
expect(scope.allowAllModels).toBe(true);
|
|
216
|
+
expect(memberFindOne).not.toHaveBeenCalled();
|
|
217
|
+
expect(groupFindOne).toHaveBeenCalledTimes(1);
|
|
160
218
|
});
|
|
161
219
|
|
|
162
|
-
it('
|
|
163
|
-
const { ctx,
|
|
164
|
-
await resolveUserAccessScope(ctx);
|
|
165
|
-
|
|
166
|
-
expect(
|
|
220
|
+
it('lazily creates the default group when it is missing', async () => {
|
|
221
|
+
const { ctx, groupCreate } = mockContext({ userId: null, defaultGroup: null });
|
|
222
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
223
|
+
expect(groupCreate).toHaveBeenCalledTimes(1);
|
|
224
|
+
expect(scope.allowAllModels).toBe(true);
|
|
167
225
|
});
|
|
168
226
|
|
|
169
|
-
it('
|
|
170
|
-
const { ctx
|
|
171
|
-
await resolveUserAccessScope(ctx);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
expect(findOne).toHaveBeenCalledTimes(2);
|
|
227
|
+
it('narrows the scope by the membership group', async () => {
|
|
228
|
+
const { ctx } = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
|
|
229
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
230
|
+
expect(scope.groupId).toBe(5);
|
|
231
|
+
expect(scope.allowedServices).toEqual(['openai']);
|
|
175
232
|
});
|
|
176
233
|
|
|
177
|
-
it('
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
invalidateUserPermissionCache(2);
|
|
183
|
-
await resolveUserAccessScope(first.ctx);
|
|
184
|
-
expect(first.findOne).toHaveBeenCalledTimes(1);
|
|
234
|
+
it('falls back to the default group when the membership points at a missing group', async () => {
|
|
235
|
+
const { ctx, groupFindOne } = mockContext({ memberWithoutGroup: true });
|
|
236
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
237
|
+
expect(scope.allowedServices).toEqual([]);
|
|
238
|
+
expect(groupFindOne).toHaveBeenCalledTimes(1);
|
|
185
239
|
});
|
|
186
240
|
|
|
187
|
-
it('
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
expect(
|
|
194
|
-
expect(
|
|
241
|
+
it('keeps serving the cached scope when the group row changes within the TTL', async () => {
|
|
242
|
+
const first = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
|
|
243
|
+
expect((await resolveUserAccessScope(first.ctx)).allowedServices).toEqual(['openai']);
|
|
244
|
+
|
|
245
|
+
// The next request resolves membership live, but the scope for group 5 is still cached.
|
|
246
|
+
const second = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['anthropic'] })) });
|
|
247
|
+
expect((await resolveUserAccessScope(second.ctx)).allowedServices).toEqual(['openai']);
|
|
248
|
+
expect(second.memberFindOne).toHaveBeenCalledTimes(1);
|
|
249
|
+
|
|
250
|
+
invalidateGroupAccessCache(5);
|
|
251
|
+
const third = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['anthropic'] })) });
|
|
252
|
+
expect((await resolveUserAccessScope(third.ctx)).allowedServices).toEqual(['anthropic']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('needs no invalidation when a user moves to another group', async () => {
|
|
256
|
+
const first = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
|
|
257
|
+
expect((await resolveUserAccessScope(first.ctx)).allowedServices).toEqual(['openai']);
|
|
258
|
+
|
|
259
|
+
// The next request resolves the new membership live, so no cache invalidation is needed.
|
|
260
|
+
const second = mockContext({ memberGroup: row(group({ id: 6, allowedLlmServices: ['anthropic'] })) });
|
|
261
|
+
expect((await resolveUserAccessScope(second.ctx)).allowedServices).toEqual(['anthropic']);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('shares one cache entry between members of the same group', async () => {
|
|
265
|
+
const first = mockContext({ userId: 1, memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
|
|
266
|
+
const second = mockContext({ userId: 2, memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
|
|
267
|
+
const a = await resolveUserAccessScope(first.ctx);
|
|
268
|
+
const b = await resolveUserAccessScope(second.ctx);
|
|
269
|
+
expect(b).toBe(a);
|
|
195
270
|
});
|
|
196
271
|
|
|
197
|
-
it('
|
|
272
|
+
it('fails closed when the membership lookup is unavailable', async () => {
|
|
198
273
|
const { ctx } = mockContext({ throws: true });
|
|
199
274
|
const scope = await resolveUserAccessScope(ctx);
|
|
275
|
+
// Treating a failed lookup as "open" would silently lift every user's restrictions
|
|
276
|
+
// during a rolling upgrade where the tables do not exist yet.
|
|
277
|
+
expect(scope.lookupFailed).toBe(true);
|
|
278
|
+
expect(ctx.log.error).toHaveBeenCalled();
|
|
200
279
|
expect(isServiceAllowed(scope, [], OPENAI)).toBe(false);
|
|
201
280
|
expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(false);
|
|
202
281
|
});
|
|
203
282
|
|
|
204
283
|
it('does not cache a failed lookup', async () => {
|
|
205
|
-
const { ctx,
|
|
284
|
+
const { ctx, memberFindOne } = mockContext({ throws: true });
|
|
206
285
|
await resolveUserAccessScope(ctx);
|
|
207
286
|
await resolveUserAccessScope(ctx);
|
|
208
|
-
expect(
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('does not share a cache entry between apps with the same
|
|
212
|
-
const main = mockContext({
|
|
213
|
-
const sub = mockContext({
|
|
214
|
-
await resolveUserAccessScope(main.ctx);
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
expect(sub.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
await resolveUserAccessScope(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
await resolveUserAccessScope(
|
|
230
|
-
expect(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
const
|
|
287
|
+
expect(memberFindOne).toHaveBeenCalledTimes(2);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('does not share a cache entry between apps with the same group id', async () => {
|
|
291
|
+
const main = mockContext({ appName: 'main', memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
|
|
292
|
+
const sub = mockContext({ appName: 'sub', memberGroup: row(group({ id: 5, allowedLlmServices: ['anthropic'] })) });
|
|
293
|
+
expect((await resolveUserAccessScope(main.ctx)).allowedServices).toEqual(['openai']);
|
|
294
|
+
// Sub-apps share this process but have separate databases, so group 5 in "sub" is a
|
|
295
|
+
// different group than group 5 in "main" and must not inherit its cached scope.
|
|
296
|
+
expect((await resolveUserAccessScope(sub.ctx)).allowedServices).toEqual(['anthropic']);
|
|
297
|
+
expect(sub.memberFindOne).toHaveBeenCalledTimes(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('invalidates a group across every app', async () => {
|
|
301
|
+
const main = mockContext({ appName: 'main', memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
|
|
302
|
+
const sub = mockContext({ appName: 'sub', memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
|
|
303
|
+
const mainBefore = await resolveUserAccessScope(main.ctx);
|
|
304
|
+
const subBefore = await resolveUserAccessScope(sub.ctx);
|
|
305
|
+
|
|
306
|
+
invalidateGroupAccessCache(5);
|
|
307
|
+
|
|
308
|
+
expect(await resolveUserAccessScope(main.ctx)).not.toBe(mainBefore);
|
|
309
|
+
expect(await resolveUserAccessScope(sub.ctx)).not.toBe(subBefore);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('does not invalidate a group whose id is a suffix of another', async () => {
|
|
313
|
+
const first = mockContext({ userId: 1, memberGroup: row(group({ id: 1, allowedLlmServices: ['openai'] })) });
|
|
314
|
+
const second = mockContext({ userId: 2, memberGroup: row(group({ id: 21, allowedLlmServices: ['openai'] })) });
|
|
315
|
+
const secondBefore = await resolveUserAccessScope(second.ctx);
|
|
237
316
|
await resolveUserAccessScope(first.ctx);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
expect(second.
|
|
317
|
+
|
|
318
|
+
invalidateGroupAccessCache(1);
|
|
319
|
+
|
|
320
|
+
expect(await resolveUserAccessScope(second.ctx)).toBe(secondBefore);
|
|
242
321
|
});
|
|
243
322
|
});
|
|
244
323
|
|
|
245
324
|
describe('enforceModelAccess', () => {
|
|
246
325
|
it('passes a permitted service and model through untouched', async () => {
|
|
247
|
-
const { ctx } = mockContext({
|
|
326
|
+
const { ctx } = mockContext({});
|
|
248
327
|
expect(await enforceModelAccess(ctx, ['openai'], OPENAI, 'gpt-4o')).toBe(true);
|
|
249
328
|
expect(ctx.status).toBe(200);
|
|
250
329
|
});
|
|
251
330
|
|
|
252
331
|
it('returns 403 model_not_available for a denied service', async () => {
|
|
253
|
-
const { ctx } = mockContext({
|
|
332
|
+
const { ctx } = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
|
|
254
333
|
expect(await enforceModelAccess(ctx, ['openai', 'anthropic'], ANTHROPIC, 'claude')).toBe(false);
|
|
255
334
|
expect(ctx.status).toBe(403);
|
|
256
335
|
// `permission_denied` is what every other 403 in this plugin reports; keeping the type
|
|
@@ -260,15 +339,17 @@ describe('enforceModelAccess', () => {
|
|
|
260
339
|
|
|
261
340
|
it('returns 403 model_not_available for a denied model of a granted service', async () => {
|
|
262
341
|
const { ctx } = mockContext({
|
|
263
|
-
|
|
342
|
+
memberGroup: row(
|
|
343
|
+
group({ id: 5, allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
|
|
344
|
+
),
|
|
264
345
|
});
|
|
265
346
|
expect(await enforceModelAccess(ctx, ['openai'], OPENAI, 'gpt-4o-mini')).toBe(false);
|
|
266
347
|
expect(ctx.status).toBe(403);
|
|
267
348
|
expect(ctx.body).toMatchObject({ error: { code: 'model_not_available', type: 'permission_denied' } });
|
|
268
349
|
});
|
|
269
350
|
|
|
270
|
-
it('denies a
|
|
271
|
-
const { ctx } = mockContext({
|
|
351
|
+
it('denies a group-granted service that the global whitelist excludes', async () => {
|
|
352
|
+
const { ctx } = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['anthropic'] })) });
|
|
272
353
|
expect(await enforceModelAccess(ctx, ['openai'], ANTHROPIC, 'claude')).toBe(false);
|
|
273
354
|
expect(ctx.status).toBe(403);
|
|
274
355
|
});
|
|
@@ -16,6 +16,7 @@ function quotaPolicy(values: Record<string, unknown>): Model {
|
|
|
16
16
|
const requiredPolicy = {
|
|
17
17
|
periodType: 'monthly',
|
|
18
18
|
missingUsageBehavior: 'use_reserved',
|
|
19
|
+
quotaMode: 'per_user',
|
|
19
20
|
timezone: 'UTC',
|
|
20
21
|
};
|
|
21
22
|
|
|
@@ -33,4 +34,14 @@ describe('AI API quota policy validation', () => {
|
|
|
33
34
|
'contextOverflowBehavior must be reject or truncate.',
|
|
34
35
|
);
|
|
35
36
|
});
|
|
37
|
+
|
|
38
|
+
it.each(['share', 'per_user'] as const)('accepts %s quota mode', (quotaMode) => {
|
|
39
|
+
expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, quotaMode }))).not.toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects an unsupported quota mode', () => {
|
|
43
|
+
expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, quotaMode: 'global' }))).toThrow(
|
|
44
|
+
'quotaMode must be share or per_user.',
|
|
45
|
+
);
|
|
46
|
+
});
|
|
36
47
|
});
|