Follow-up fixes on the agent-roles feature: - ai.service: a cross-driver override to the ollama driver (when the workspace driver is not ollama) now fails with an explicit 503 instead of silently reusing the workspace base URL, which belongs to a different provider. Same-driver ollama and openai/gemini overrides are unchanged. - migration: add a partial unique index on (workspace_id, name) WHERE deleted_at IS NULL so role names are unique per workspace without soft-deleted rows blocking re-creation; map Postgres 23505 to a 409 ConflictException on create/update. - dto: validate the role id as @IsUUID instead of @IsString. - roles list: do not expose instructions/modelConfig to non-admin members. The list endpoint now returns a picker view (id/name/emoji/description/ enabled) to members and the full view only to admins (same gate as the CRUD endpoints). Client IAiRole fields made optional accordingly. Adds tests for the cross-driver-ollama throw, the 23505->409 mapping, and the non-admin picker-view security invariant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
6.6 KiB
TypeScript
175 lines
6.6 KiB
TypeScript
import { AiService } from './ai.service';
|
|
import { AiNotConfiguredException } from './ai-not-configured.exception';
|
|
|
|
/**
|
|
* Unit test for the role model-override 503 path of AiService.getChatModel.
|
|
*
|
|
* AiService's constructor body is trivial (it only stores its deps), so it can
|
|
* be unit-constructed with stubbed collaborators — no Nest module graph, which
|
|
* the src-rooted jest setup cannot fully resolve for the heavier specs. We stub:
|
|
* - aiSettings.resolve -> a workspace configured for openai (so cfg.driver is
|
|
* set and we pass the first guard),
|
|
* - aiProviderCredentialsRepo.find -> undefined (the override driver has NO
|
|
* configured credentials),
|
|
* - secretBox -> unused on this path (no creds to decrypt).
|
|
*
|
|
* With a role override pointing at a DIFFERENT driver ('gemini') that has no
|
|
* creds, getChatModel must throw AiNotConfiguredException (503) and the message
|
|
* must name the override driver (and the role) so an admin can fix it.
|
|
*/
|
|
describe('AiService.getChatModel role model override', () => {
|
|
function makeService(opts: {
|
|
workspaceDriver: string;
|
|
credsApiKeyEnc?: string;
|
|
}) {
|
|
const aiSettings = {
|
|
resolve: jest.fn().mockResolvedValue({
|
|
driver: opts.workspaceDriver,
|
|
chatModel: 'gpt-4o-mini',
|
|
apiKey: 'workspace-key',
|
|
baseUrl: undefined,
|
|
}),
|
|
};
|
|
const aiProviderCredentialsRepo = {
|
|
find: jest.fn().mockResolvedValue(
|
|
opts.credsApiKeyEnc ? { apiKeyEnc: opts.credsApiKeyEnc } : undefined,
|
|
),
|
|
};
|
|
const secretBox = {
|
|
decryptSecret: jest.fn().mockReturnValue('decrypted'),
|
|
};
|
|
const service = new AiService(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
aiSettings as any,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
aiProviderCredentialsRepo as any,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
secretBox as any,
|
|
);
|
|
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
|
|
}
|
|
|
|
it('throws AiNotConfiguredException (503) naming the override driver when its creds are missing', async () => {
|
|
const { service, aiProviderCredentialsRepo } = makeService({
|
|
workspaceDriver: 'openai',
|
|
});
|
|
|
|
await expect(
|
|
service.getChatModel('ws-1', {
|
|
driver: 'gemini',
|
|
chatModel: 'gemini-2.0-flash',
|
|
roleName: 'Researcher',
|
|
}),
|
|
).rejects.toBeInstanceOf(AiNotConfiguredException);
|
|
|
|
// Re-run to assert the message names the driver (and role) for the admin.
|
|
await service
|
|
.getChatModel('ws-1', {
|
|
driver: 'gemini',
|
|
chatModel: 'gemini-2.0-flash',
|
|
roleName: 'Researcher',
|
|
})
|
|
.then(
|
|
() => {
|
|
throw new Error('expected getChatModel to throw');
|
|
},
|
|
(err: unknown) => {
|
|
expect(err).toBeInstanceOf(AiNotConfiguredException);
|
|
const message = (err as AiNotConfiguredException).message;
|
|
expect(message).toContain('gemini');
|
|
expect(message).toContain('Researcher');
|
|
},
|
|
);
|
|
|
|
// The override driver's creds were looked up for the right driver.
|
|
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
|
|
});
|
|
|
|
it('cross-driver override with creds present: resolves without throwing, using the OVERRIDE driver creds', async () => {
|
|
// Workspace driver is openai; the role overrides to gemini, which HAS creds.
|
|
const { service, aiProviderCredentialsRepo, secretBox } = makeService({
|
|
workspaceDriver: 'openai',
|
|
credsApiKeyEnc: 'enc-gemini-key',
|
|
});
|
|
|
|
const model = await service.getChatModel('ws-1', {
|
|
driver: 'gemini',
|
|
chatModel: 'gemini-2.0-flash',
|
|
roleName: 'Researcher',
|
|
});
|
|
|
|
// A real LanguageModel was built (no 503).
|
|
expect(model).toBeDefined();
|
|
// Creds were fetched for the OVERRIDE driver, then decrypted.
|
|
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
|
|
expect(secretBox.decryptSecret).toHaveBeenCalledWith('enc-gemini-key');
|
|
});
|
|
|
|
it('cross-driver override to ollama (workspace driver != ollama): throws 503, does NOT silently reuse the workspace baseUrl', async () => {
|
|
// Workspace driver is openai with a configured (gateway) baseUrl. A role that
|
|
// overrides to ollama has no dedicated ollama endpoint, so pointing the
|
|
// ollama client at the workspace's openai baseUrl would be wrong — it must
|
|
// fail explicitly instead.
|
|
const aiSettings = {
|
|
resolve: jest.fn().mockResolvedValue({
|
|
driver: 'openai',
|
|
chatModel: 'gpt-4o-mini',
|
|
apiKey: 'workspace-key',
|
|
baseUrl: 'https://openrouter.example/v1',
|
|
}),
|
|
};
|
|
const aiProviderCredentialsRepo = { find: jest.fn() };
|
|
const secretBox = { decryptSecret: jest.fn() };
|
|
const service = new AiService(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
aiSettings as any,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
aiProviderCredentialsRepo as any,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
secretBox as any,
|
|
);
|
|
|
|
await service
|
|
.getChatModel('ws-1', {
|
|
driver: 'ollama',
|
|
chatModel: 'llama3',
|
|
roleName: 'Local',
|
|
})
|
|
.then(
|
|
() => {
|
|
throw new Error('expected getChatModel to throw');
|
|
},
|
|
(err: unknown) => {
|
|
expect(err).toBeInstanceOf(AiNotConfiguredException);
|
|
const message = (err as AiNotConfiguredException).message;
|
|
// Names the role and the workspace driver, and mentions ollama.
|
|
expect(message).toContain('ollama');
|
|
expect(message).toContain('openai');
|
|
expect(message).toContain('Local');
|
|
// Must NOT leak / reuse the workspace gateway baseUrl in the path.
|
|
expect(message).not.toContain('openrouter.example');
|
|
},
|
|
);
|
|
|
|
// No ollama creds lookup happens (ollama needs no key); we fail before that.
|
|
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('chatModel-only override (no driver): reuses the workspace driver+creds, no creds lookup/decrypt', async () => {
|
|
// No override.driver => the workspace openai driver + its apiKey are reused;
|
|
// ai_provider_credentials must NOT be queried and nothing is decrypted.
|
|
const { service, aiProviderCredentialsRepo, secretBox } = makeService({
|
|
workspaceDriver: 'openai',
|
|
});
|
|
|
|
const model = await service.getChatModel('ws-1', {
|
|
chatModel: 'gpt-4o',
|
|
roleName: 'Writer',
|
|
});
|
|
|
|
expect(model).toBeDefined();
|
|
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
|
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
|
});
|
|
});
|