plugin-ai-api 1.0.20 → 1.0.23
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/123.e6fe04c856ce6417.js +10 -0
- package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
- package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
- package/dist/client/562.17a0a299d2e5152c.js +10 -0
- package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
- package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
- package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
- package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
- package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
- package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +39 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +39 -9
- package/dist/locale/vi-VN.json +31 -1
- package/dist/locale/zh-CN.json +31 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/collections/ai-api-user-permissions.js +67 -0
- package/dist/server/plugin.js +45 -1
- package/dist/server/resource/ai-api-config.js +17 -0
- package/dist/server/resource/ai-api-user-permissions.js +75 -0
- package/dist/server/routes/agent-completions.js +67 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +174 -20
- package/dist/server/routes/completions.js +41 -21
- package/dist/server/routes/embeddings.js +6 -14
- package/dist/server/routes/models.js +102 -20
- package/dist/server/routes/router.js +94 -22
- package/dist/server/usage.js +2 -0
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/openai-format.js +17 -3
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/utils/user-permissions.js +160 -0
- package/dist/server/validation.js +18 -0
- package/dist/swagger.js +36 -4
- package/package.json +2 -2
- package/src/client/__tests__/settings-registration.test.tsx +69 -0
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +28 -8
- package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
- package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
- package/src/client-v2/locale.ts +21 -1
- package/src/client-v2/pages/GeneralPage.tsx +13 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
- package/src/client-v2/plugin.tsx +50 -1
- package/src/constants.ts +28 -0
- package/src/locale/en-US.json +39 -9
- package/src/locale/vi-VN.json +31 -1
- package/src/locale/zh-CN.json +31 -1
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/models.test.ts +116 -0
- package/src/server/__tests__/openai-format.test.ts +52 -1
- package/src/server/__tests__/permission-sync.test.ts +109 -0
- package/src/server/__tests__/request-body.test.ts +310 -0
- package/src/server/__tests__/streaming-observability.test.ts +51 -0
- package/src/server/__tests__/usage-route.test.ts +213 -0
- package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
- package/src/server/__tests__/user-permissions.test.ts +284 -0
- package/src/server/collections/ai-api-config.ts +6 -0
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/collections/ai-api-user-permissions.ts +46 -0
- package/src/server/plugin.ts +65 -4
- package/src/server/resource/ai-api-config.ts +23 -0
- package/src/server/resource/ai-api-user-permissions.ts +76 -0
- package/src/server/routes/agent-completions.ts +84 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +294 -20
- package/src/server/routes/completions.ts +54 -20
- package/src/server/routes/embeddings.ts +10 -15
- package/src/server/routes/models.ts +318 -195
- package/src/server/routes/router.ts +136 -26
- package/src/server/usage.ts +2 -0
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/openai-format.ts +26 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/utils/user-permissions.ts +218 -0
- package/src/server/validation.ts +27 -0
- package/src/swagger.ts +47 -4
- package/dist/client/302.25edd5d75460acbf.js +0 -10
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
- package/src/client/AiApiConfigPage.tsx +0 -309
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Context } from '@nocobase/actions';
|
|
2
|
+
import {
|
|
3
|
+
addAiApiUsage,
|
|
4
|
+
finishAiApiObservation,
|
|
5
|
+
markAiApiFirstProviderOutput,
|
|
6
|
+
startAiApiObservation,
|
|
7
|
+
} from '../utils/app-observability';
|
|
8
|
+
|
|
9
|
+
const CONTRACT_SYMBOL = Symbol.for('nocobase.app-observability.contract');
|
|
10
|
+
|
|
11
|
+
function createContext(contract?: object): Context {
|
|
12
|
+
const app = {
|
|
13
|
+
logger: { warn: vi.fn() },
|
|
14
|
+
...(contract ? { [CONTRACT_SYMBOL]: contract } : {}),
|
|
15
|
+
};
|
|
16
|
+
return { app, state: {} } as unknown as Context;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('AI API app observability bridge', () => {
|
|
20
|
+
it('is a no-op when observability is absent', () => {
|
|
21
|
+
const ctx = createContext();
|
|
22
|
+
expect(() => {
|
|
23
|
+
startAiApiObservation(ctx, {
|
|
24
|
+
service: 'llm.chat',
|
|
25
|
+
operation: '/chat/completions',
|
|
26
|
+
streaming: true,
|
|
27
|
+
mode: 'llm',
|
|
28
|
+
});
|
|
29
|
+
markAiApiFirstProviderOutput(ctx);
|
|
30
|
+
addAiApiUsage(ctx, { prompt_tokens: 2, completion_tokens: 3 });
|
|
31
|
+
finishAiApiObservation(ctx, { status: 'succeeded' });
|
|
32
|
+
}).not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('records TTFT, normalized usage and finalizes exactly once', () => {
|
|
36
|
+
const handle = {
|
|
37
|
+
markFirstByte: vi.fn(),
|
|
38
|
+
addInputTokens: vi.fn(),
|
|
39
|
+
addOutputTokens: vi.fn(),
|
|
40
|
+
finish: vi.fn(),
|
|
41
|
+
};
|
|
42
|
+
const start = vi.fn(() => handle);
|
|
43
|
+
const ctx = createContext({ start });
|
|
44
|
+
|
|
45
|
+
startAiApiObservation(ctx, {
|
|
46
|
+
service: 'llm.agent',
|
|
47
|
+
operation: '/chat/completions',
|
|
48
|
+
streaming: true,
|
|
49
|
+
model: 'service/model',
|
|
50
|
+
mode: 'agent',
|
|
51
|
+
});
|
|
52
|
+
markAiApiFirstProviderOutput(ctx);
|
|
53
|
+
addAiApiUsage(ctx, { prompt_tokens: 11, completion_tokens: 7 });
|
|
54
|
+
finishAiApiObservation(ctx, { status: 'succeeded' });
|
|
55
|
+
finishAiApiObservation(ctx, { status: 'failed' });
|
|
56
|
+
|
|
57
|
+
expect(start).toHaveBeenCalledWith(
|
|
58
|
+
expect.objectContaining({
|
|
59
|
+
service: 'llm.agent',
|
|
60
|
+
streaming: true,
|
|
61
|
+
attributes: expect.objectContaining({ mode: 'agent', model: 'service/model' }),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
expect(handle.markFirstByte).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(handle.addInputTokens).toHaveBeenCalledWith(11);
|
|
66
|
+
expect(handle.addOutputTokens).toHaveBeenCalledWith(7);
|
|
67
|
+
expect(handle.finish).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(handle.finish).toHaveBeenCalledWith({ status: 'succeeded' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('fails open when contract callbacks throw', () => {
|
|
72
|
+
const ctx = createContext({
|
|
73
|
+
start: () => ({
|
|
74
|
+
markFirstByte: () => {
|
|
75
|
+
throw new Error('ttft failed');
|
|
76
|
+
},
|
|
77
|
+
addInputTokens: () => {
|
|
78
|
+
throw new Error('tokens failed');
|
|
79
|
+
},
|
|
80
|
+
addOutputTokens: () => {},
|
|
81
|
+
finish: () => {
|
|
82
|
+
throw new Error('finish failed');
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
expect(() => {
|
|
87
|
+
startAiApiObservation(ctx, {
|
|
88
|
+
service: 'llm.completion',
|
|
89
|
+
operation: '/completions',
|
|
90
|
+
streaming: true,
|
|
91
|
+
mode: 'llm',
|
|
92
|
+
});
|
|
93
|
+
markAiApiFirstProviderOutput(ctx);
|
|
94
|
+
addAiApiUsage(ctx, { prompt_tokens: 1, completion_tokens: null });
|
|
95
|
+
finishAiApiObservation(ctx, { status: 'failed', errorCode: 'stream_error' });
|
|
96
|
+
}).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Context } from '@nocobase/actions';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { buildModelObject, handleGetModel, handleListModels } from '../routes/models';
|
|
13
|
+
|
|
14
|
+
const CREATED = 1_700_000_000;
|
|
15
|
+
|
|
16
|
+
function permissionLookupFailureContext() {
|
|
17
|
+
const ctx = {
|
|
18
|
+
app: { name: 'main', pm: { get: () => ({}) } },
|
|
19
|
+
state: { currentUser: { id: 1 } },
|
|
20
|
+
db: {
|
|
21
|
+
getRepository: (name: string) => {
|
|
22
|
+
if (name === 'aiApiConfig') return { findOne: vi.fn(async () => null) };
|
|
23
|
+
if (name === 'llmServices') return { find: vi.fn(async () => []) };
|
|
24
|
+
if (name === 'aiApiUserPermissions') {
|
|
25
|
+
return { findOne: vi.fn(async () => Promise.reject(new Error('permission database unavailable'))) };
|
|
26
|
+
}
|
|
27
|
+
return { find: vi.fn(async () => []) };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
log: { error: vi.fn(), warn: vi.fn() },
|
|
31
|
+
status: 0,
|
|
32
|
+
body: undefined,
|
|
33
|
+
} as unknown as Context;
|
|
34
|
+
return ctx;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('buildModelObject', () => {
|
|
38
|
+
it('returns the base OpenAI model shape with no override', () => {
|
|
39
|
+
const model = buildModelObject('svc/gpt-4o', CREATED, 'My Service');
|
|
40
|
+
expect(model).toEqual({
|
|
41
|
+
id: 'svc/gpt-4o',
|
|
42
|
+
object: 'model',
|
|
43
|
+
created: CREATED,
|
|
44
|
+
owned_by: 'My Service',
|
|
45
|
+
});
|
|
46
|
+
// No override → no context_window / active field is added.
|
|
47
|
+
expect(model).not.toHaveProperty('context_window');
|
|
48
|
+
expect(model).not.toHaveProperty('active');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('emits context window under both context_window and context_length', () => {
|
|
52
|
+
const model = buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: 100_000 });
|
|
53
|
+
expect(model.context_window).toBe(100_000);
|
|
54
|
+
expect(model.context_length).toBe(100_000);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('overrides owned_by, display name and description', () => {
|
|
58
|
+
const model = buildModelObject('svc/m', CREATED, 'Svc', {
|
|
59
|
+
ownedByOverride: 'Acme',
|
|
60
|
+
displayName: 'Acme Turbo',
|
|
61
|
+
description: 'Fast model',
|
|
62
|
+
});
|
|
63
|
+
expect(model.owned_by).toBe('Acme');
|
|
64
|
+
expect(model.display_name).toBe('Acme Turbo');
|
|
65
|
+
expect(model.name).toBe('Acme Turbo');
|
|
66
|
+
expect(model.description).toBe('Fast model');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('falls back to the service label when ownedByOverride is empty', () => {
|
|
70
|
+
const model = buildModelObject('svc/m', CREATED, 'Svc', { ownedByOverride: '' });
|
|
71
|
+
expect(model.owned_by).toBe('Svc');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('emits max_completion_tokens only when positive', () => {
|
|
75
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { maxCompletionTokens: 4096 }).max_completion_tokens).toBe(4096);
|
|
76
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { maxCompletionTokens: 0 })).not.toHaveProperty(
|
|
77
|
+
'max_completion_tokens',
|
|
78
|
+
);
|
|
79
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { maxCompletionTokens: null })).not.toHaveProperty(
|
|
80
|
+
'max_completion_tokens',
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('ignores non-positive or non-integer context windows', () => {
|
|
85
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: 0 })).not.toHaveProperty('context_window');
|
|
86
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: -5 })).not.toHaveProperty('context_window');
|
|
87
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: null })).not.toHaveProperty('context_window');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('surfaces active flag whenever an override row exists', () => {
|
|
91
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { enabled: true }).active).toBe(true);
|
|
92
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { enabled: false }).active).toBe(false);
|
|
93
|
+
// Override present with enabled undefined → treated as active.
|
|
94
|
+
expect(buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: 10 }).active).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('model catalog permission lookup failures', () => {
|
|
99
|
+
it('returns a retryable 503 when listing models', async () => {
|
|
100
|
+
const ctx = permissionLookupFailureContext();
|
|
101
|
+
|
|
102
|
+
await handleListModels(ctx, undefined as never);
|
|
103
|
+
|
|
104
|
+
expect(ctx.status).toBe(503);
|
|
105
|
+
expect(ctx.body).toMatchObject({ error: { code: 'permission_check_failed' } });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns a retryable 503 when retrieving a model', async () => {
|
|
109
|
+
const ctx = permissionLookupFailureContext();
|
|
110
|
+
|
|
111
|
+
await handleGetModel(ctx, 'openai/gpt-4o', undefined as never);
|
|
112
|
+
|
|
113
|
+
expect(ctx.status).toBe(503);
|
|
114
|
+
expect(ctx.body).toMatchObject({ error: { code: 'permission_check_failed' } });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { toOpenAIResponse, toOpenAIStreamChunk } from '../utils/openai-format';
|
|
1
|
+
import { toOpenAIResponse, toOpenAIStreamChunk, toOpenAIUsageChunk } from '../utils/openai-format';
|
|
2
2
|
import { isStreamingRequested } from '../utils/streaming';
|
|
3
3
|
import { applyProviderRequestParameters, getProviderRequestParameters } from '../routes/chat-completions';
|
|
4
4
|
|
|
@@ -52,6 +52,57 @@ describe('AI API OpenAI tool-call formatting', () => {
|
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
describe('AI API OpenAI usage-only streaming chunks', () => {
|
|
56
|
+
it('includes a null usage field for normal chunks', () => {
|
|
57
|
+
const chunk = toOpenAIStreamChunk({
|
|
58
|
+
id: 'chatcmpl-1',
|
|
59
|
+
model: 'service/model',
|
|
60
|
+
delta: { content: 'Hello' },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(chunk.choices).toHaveLength(1);
|
|
64
|
+
expect(chunk).toHaveProperty('usage', null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('includes a null usage field for finish chunks', () => {
|
|
68
|
+
const chunk = toOpenAIStreamChunk({
|
|
69
|
+
id: 'chatcmpl-1',
|
|
70
|
+
model: 'service/model',
|
|
71
|
+
delta: {},
|
|
72
|
+
finishReason: 'stop',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(chunk.choices[0].finish_reason).toBe('stop');
|
|
76
|
+
expect(chunk).toHaveProperty('usage', null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('formats a usage-only chat completion chunk with an empty choices array', () => {
|
|
80
|
+
const chunk = toOpenAIUsageChunk({
|
|
81
|
+
id: 'chatcmpl-1',
|
|
82
|
+
model: 'service/model',
|
|
83
|
+
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
|
|
84
|
+
object: 'chat.completion.chunk',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(chunk.object).toBe('chat.completion.chunk');
|
|
88
|
+
expect(chunk.choices).toEqual([]);
|
|
89
|
+
expect(chunk.usage).toEqual({ prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('formats a usage-only legacy text completion chunk', () => {
|
|
93
|
+
const chunk = toOpenAIUsageChunk({
|
|
94
|
+
id: 'cmpl-1',
|
|
95
|
+
model: 'service/model',
|
|
96
|
+
usage: { prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 },
|
|
97
|
+
object: 'text_completion',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(chunk.object).toBe('text_completion');
|
|
101
|
+
expect(chunk.choices).toEqual([]);
|
|
102
|
+
expect(chunk.usage).toEqual({ prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
55
106
|
describe('AI API provider parameter forwarding', () => {
|
|
56
107
|
it('forwards model and tool-call parameters not managed by the gateway', () => {
|
|
57
108
|
const parameters = getProviderRequestParameters({
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Context } from '@nocobase/actions';
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { PluginAiApiServer } from '../plugin';
|
|
13
|
+
import { invalidateUserPermissionCache, resolveUserAccessScope } from '../utils/user-permissions';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Covers the cross-node half of permission revocation.
|
|
17
|
+
*
|
|
18
|
+
* syncMessageManager hardcodes `skipSelf: true` (sync-message-manager.ts:59,73), so the node
|
|
19
|
+
* that writes the change never receives its own broadcast. That makes two things load-bearing
|
|
20
|
+
* and easy to regress: the writer must invalidate its own cache locally, and every other node
|
|
21
|
+
* must invalidate on receipt. Neither is observable from a single-node test of the cache alone.
|
|
22
|
+
*/
|
|
23
|
+
function mockContext(userId: number, row: unknown) {
|
|
24
|
+
const findOne = vi.fn(async () => row);
|
|
25
|
+
const ctx = {
|
|
26
|
+
state: { currentUser: { id: userId } },
|
|
27
|
+
db: { getRepository: () => ({ findOne }) },
|
|
28
|
+
app: { name: 'main' },
|
|
29
|
+
log: { warn: vi.fn(), error: vi.fn() },
|
|
30
|
+
} as unknown as Context;
|
|
31
|
+
return { ctx, findOne };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function row(values: Record<string, unknown>) {
|
|
35
|
+
return { get: (key: string) => values[key] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
invalidateUserPermissionCache();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('cross-node permission invalidation', () => {
|
|
43
|
+
it("drops the receiving node's cached scope", async () => {
|
|
44
|
+
const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
|
|
45
|
+
const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
|
|
46
|
+
|
|
47
|
+
await resolveUserAccessScope(ctx);
|
|
48
|
+
await resolveUserAccessScope(ctx);
|
|
49
|
+
expect(findOne).toHaveBeenCalledTimes(1);
|
|
50
|
+
|
|
51
|
+
await plugin.handleSyncMessage({ type: 'invalidateUserPermissions', userId: 1 });
|
|
52
|
+
|
|
53
|
+
await resolveUserAccessScope(ctx);
|
|
54
|
+
expect(findOne).toHaveBeenCalledTimes(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('ignores unrelated message types and other users', async () => {
|
|
58
|
+
const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
|
|
59
|
+
const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
|
|
60
|
+
await resolveUserAccessScope(ctx);
|
|
61
|
+
|
|
62
|
+
await plugin.handleSyncMessage({ type: 'somethingElse', userId: 1 });
|
|
63
|
+
await plugin.handleSyncMessage({ type: 'invalidateUserPermissions', userId: 2 });
|
|
64
|
+
|
|
65
|
+
await resolveUserAccessScope(ctx);
|
|
66
|
+
expect(findOne).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('tolerates a malformed message instead of throwing into the subscriber', async () => {
|
|
70
|
+
const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
|
|
71
|
+
await expect(plugin.handleSyncMessage(undefined as never)).resolves.toBeUndefined();
|
|
72
|
+
await expect(plugin.handleSyncMessage({} as never)).resolves.toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('invalidates locally as well as broadcasting, since the publisher is skipped', async () => {
|
|
76
|
+
const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
|
|
77
|
+
const sendSyncMessage = vi.fn(async () => undefined);
|
|
78
|
+
Object.assign(plugin, { sendSyncMessage });
|
|
79
|
+
|
|
80
|
+
const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
|
|
81
|
+
await resolveUserAccessScope(ctx);
|
|
82
|
+
expect(findOne).toHaveBeenCalledTimes(1);
|
|
83
|
+
|
|
84
|
+
// revokeUserPermissions is private; reach it the way the db hook does.
|
|
85
|
+
(plugin as unknown as { revokeUserPermissions: (id: unknown, tx?: unknown) => void }).revokeUserPermissions(1);
|
|
86
|
+
|
|
87
|
+
// Local cache cleared without any message coming back to us.
|
|
88
|
+
await resolveUserAccessScope(ctx);
|
|
89
|
+
expect(findOne).toHaveBeenCalledTimes(2);
|
|
90
|
+
expect(sendSyncMessage).toHaveBeenCalledWith(
|
|
91
|
+
{ type: 'invalidateUserPermissions', userId: 1 },
|
|
92
|
+
{ transaction: undefined },
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('defers the broadcast to the transaction so other nodes cannot re-cache the old row', async () => {
|
|
97
|
+
const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
|
|
98
|
+
const sendSyncMessage = vi.fn(async () => undefined);
|
|
99
|
+
Object.assign(plugin, { sendSyncMessage });
|
|
100
|
+
const transaction = { id: 'tx-1' };
|
|
101
|
+
|
|
102
|
+
(plugin as unknown as { revokeUserPermissions: (id: unknown, tx?: unknown) => void }).revokeUserPermissions(
|
|
103
|
+
7,
|
|
104
|
+
transaction,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(sendSyncMessage).toHaveBeenCalledWith({ type: 'invalidateUserPermissions', userId: 7 }, { transaction });
|
|
108
|
+
});
|
|
109
|
+
});
|