Implements all reviewer comments (code-review, red-team, and test-strategy audit), accepting the recommended variants. Server — realtime service (ai-realtime.service.ts): - SSRF: pin the validated IP via a WebSocket `lookup` hook that re-checks every resolved address with isIpAllowed (mirrors external-mcp buildPinnedDispatcher), closing the TOCTOU/DNS-rebinding window; fix the misleading comment. - no-silent-loss: on Stop, drain the in-flight segment (bounded 2.5s) and deliver the final via onFinal before closing instead of dropping the tail. - fail-closed deriveRealtimeUrl: a non-empty unparseable base now THROWS (no silent api.openai.com fallback that would leak a self-hosted key); http://ws:// bases rejected (plaintext key). Path normalization preserved. - parseUpstreamEvent keys the accumulator by item_id+content_index so GA segments don't concatenate. - inject a wsFactory seam for testing; also fix a latent bug — `import WebSocket from 'ws'` resolved to undefined at runtime (no esModuleInterop) -> import=require. - unref idle/max/drain timers. Server — realtime gateway (ai-realtime.gateway.ts, session-limits.ts): - reject revoked/disabled users and inactive sessions (mirror jwt.strategy: findById+isUserDisabled + findActiveById) with NO counter increment. - CSWSH: Origin allowlist (matching APP_URL, or no Origin for native clients) before auth, no increment. - extract SessionCounters (delete-at-zero, never negative) + pure canConnect (both caps >= checked before any increment); document the per-process/in-memory cap caveat (single-replica only). Client: - dictation-group: realtime final now inserts at the captured rangeRef SNAPSHOT (not the live caret) and guards editor.isEditable; single-space separator. - use-realtime-dictation/realtime-dictation-client: stop-during-acquisition tears down the mic (no leak / button reset); reconnect re-emits start (double-start guarded); interim ghost cleared on teardown; io() options de-duplicated. - pcm16-worklet: flush the partial sub-frame tail on stop; one-pole anti-aliasing low-pass before 48k->24k. - extract shared mic-capture (acquireMicStream/mapGetUserMediaError, used by batch + realtime), pure DSP (pcm16-dsp.ts), and the session reducer/baseLanguageSubtag; extract applyInterimMeta/clampRange/resolveUrl/appendFinalToDraft. Tests + infra: +~150 server tests (deriveRealtimeUrl, parseUpstreamEvent branches, openSession/lifecycle/timers/testConnection via fake ws, gateway auth/caps/no-leak, realtime-test admin contract, AiSettings update/resolve, DTO boolean, SSRF deny) and +~140 client tests (DSP property/edge, resampler continuity, framing, reducer, mic-capture, RealtimeDictationClient/MicButton, ProseMirror interim regression + history guards, appendFinalToDraft, resolveKeyField, route contract). Added @vitest/coverage-v8. CHANGELOG [Unreleased] entry incl. the single-replica caveat. Review: APPROVE WITH SUGGESTIONS (no critical/regression); applied the drain-timer unref. Server tsc clean + 358 tests; client tsc clean + 201 tests; vite build ok. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
import { AiSettingsService } from './ai-settings.service';
|
|
|
|
// Unit tests for the partial-merge behaviour of AiSettingsService.update and the
|
|
// key-fallback behaviour of resolve. Constructed directly with stub deps (no
|
|
// Nest graph): we assert exactly which repo calls fire for a given partial DTO,
|
|
// proving the realtime STT fields merge in without clobbering chat fields and
|
|
// that an empty patch writes nothing.
|
|
|
|
interface Deps {
|
|
updateAiProviderSettings: jest.Mock;
|
|
readProviderResult?: Record<string, unknown>;
|
|
getMaskedResult?: unknown;
|
|
}
|
|
|
|
function buildService(deps: Deps) {
|
|
const workspaceRepo = {
|
|
updateAiProviderSettings: deps.updateAiProviderSettings,
|
|
// findById feeds the private readProvider() (target-driver resolution).
|
|
findById: jest.fn().mockResolvedValue({
|
|
settings: { ai: { provider: deps.readProviderResult ?? {} } },
|
|
}),
|
|
};
|
|
const aiProviderCredentialsRepo = {
|
|
find: jest.fn(),
|
|
upsert: jest.fn(),
|
|
clearKey: jest.fn(),
|
|
upsertEmbeddingKey: jest.fn(),
|
|
clearEmbeddingKey: jest.fn(),
|
|
upsertSttKey: jest.fn(),
|
|
clearSttKey: jest.fn(),
|
|
};
|
|
const secretBox = {
|
|
encryptSecret: jest.fn((v: string) => `enc(${v})`),
|
|
decryptSecret: jest.fn((v: string) => `dec(${v})`),
|
|
};
|
|
const pageEmbeddingRepo = { countIndexedPages: jest.fn().mockResolvedValue(0) };
|
|
const pageRepo = { countEmbeddablePages: jest.fn().mockResolvedValue(0) };
|
|
|
|
const service = new AiSettingsService(
|
|
workspaceRepo as any,
|
|
{} as any, // aiAgentRoleRepo
|
|
aiProviderCredentialsRepo as any,
|
|
pageEmbeddingRepo as any,
|
|
pageRepo as any,
|
|
secretBox as any,
|
|
{} as any, // aiQueue
|
|
);
|
|
|
|
// getMasked is exercised at the end of update(); stub it so update() resolves
|
|
// without a second repo round-trip we don't care about here.
|
|
jest
|
|
.spyOn(service, 'getMasked')
|
|
.mockResolvedValue((deps.getMaskedResult ?? {}) as any);
|
|
|
|
return { service, workspaceRepo, aiProviderCredentialsRepo, secretBox };
|
|
}
|
|
|
|
describe('AiSettingsService.update partial merge', () => {
|
|
it('a DTO with only realtime fields patches exactly those keys', async () => {
|
|
const updateAiProviderSettings = jest.fn().mockResolvedValue(undefined);
|
|
const { service } = buildService({ updateAiProviderSettings });
|
|
|
|
await service.update('w1', {
|
|
sttRealtimeModel: 'gpt-4o-realtime',
|
|
sttRealtimeBaseUrl: 'https://api.example.com/v1',
|
|
});
|
|
|
|
expect(updateAiProviderSettings).toHaveBeenCalledTimes(1);
|
|
const [, patch] = updateAiProviderSettings.mock.calls[0];
|
|
expect(Object.keys(patch).sort()).toEqual(
|
|
['sttRealtimeBaseUrl', 'sttRealtimeModel'].sort(),
|
|
);
|
|
});
|
|
|
|
it('a DTO with chatModel does NOT clobber realtime fields (only chatModel patched)', async () => {
|
|
const updateAiProviderSettings = jest.fn().mockResolvedValue(undefined);
|
|
const { service } = buildService({ updateAiProviderSettings });
|
|
|
|
await service.update('w1', { chatModel: 'gpt-4o' });
|
|
|
|
const [, patch] = updateAiProviderSettings.mock.calls[0];
|
|
expect(patch).toEqual({ chatModel: 'gpt-4o' });
|
|
expect(patch).not.toHaveProperty('sttRealtimeModel');
|
|
expect(patch).not.toHaveProperty('sttRealtimeBaseUrl');
|
|
});
|
|
|
|
it('an empty patch never calls updateAiProviderSettings', async () => {
|
|
const updateAiProviderSettings = jest.fn().mockResolvedValue(undefined);
|
|
const { service } = buildService({ updateAiProviderSettings });
|
|
|
|
await service.update('w1', {});
|
|
|
|
expect(updateAiProviderSettings).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('AiSettingsService.resolve STT key fallback', () => {
|
|
it('uses the STT-specific key when sttApiKeyEnc is present (decrypt)', async () => {
|
|
const { service, aiProviderCredentialsRepo, secretBox } = buildService({
|
|
updateAiProviderSettings: jest.fn(),
|
|
readProviderResult: { driver: 'openai', chatModel: 'gpt-4o' },
|
|
});
|
|
aiProviderCredentialsRepo.find.mockResolvedValue({
|
|
apiKeyEnc: 'CHAT',
|
|
sttApiKeyEnc: 'STT',
|
|
});
|
|
|
|
const cfg = await service.resolve('w1');
|
|
|
|
expect(cfg?.sttApiKey).toBe('dec(STT)');
|
|
expect(secretBox.decryptSecret).toHaveBeenCalledWith('STT');
|
|
});
|
|
|
|
it('falls back to the chat apiKey when sttApiKeyEnc is absent', async () => {
|
|
const { service, aiProviderCredentialsRepo } = buildService({
|
|
updateAiProviderSettings: jest.fn(),
|
|
readProviderResult: { driver: 'openai', chatModel: 'gpt-4o' },
|
|
});
|
|
aiProviderCredentialsRepo.find.mockResolvedValue({
|
|
apiKeyEnc: 'CHAT',
|
|
// no sttApiKeyEnc
|
|
});
|
|
|
|
const cfg = await service.resolve('w1');
|
|
|
|
// sttApiKey === the resolved chat apiKey (dec(CHAT)).
|
|
expect(cfg?.sttApiKey).toBe('dec(CHAT)');
|
|
expect(cfg?.apiKey).toBe('dec(CHAT)');
|
|
});
|
|
});
|