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>
48 lines
1.5 KiB
TypeScript
48 lines
1.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Mock the api client module so we can assert the route and unwrap behavior
|
|
// without a real network call.
|
|
const post = vi.fn();
|
|
vi.mock("@/lib/api-client", () => ({
|
|
default: { post: (...args: unknown[]) => post(...args) },
|
|
}));
|
|
|
|
import { testRealtimeConnection } from "./ai-settings-service";
|
|
|
|
describe("testRealtimeConnection", () => {
|
|
beforeEach(() => {
|
|
post.mockReset();
|
|
});
|
|
|
|
it("POSTs to the /ai-chat/realtime/test route (NOT the /workspace/ai-settings prefix)", async () => {
|
|
post.mockResolvedValue({ data: { ok: true } });
|
|
await testRealtimeConnection();
|
|
|
|
expect(post).toHaveBeenCalledTimes(1);
|
|
const [route] = post.mock.calls[0];
|
|
expect(route).toBe("/ai-chat/realtime/test");
|
|
expect(route).not.toContain("/workspace/ai-settings");
|
|
});
|
|
|
|
it("sends no request body", async () => {
|
|
post.mockResolvedValue({ data: { ok: true } });
|
|
await testRealtimeConnection();
|
|
|
|
// Only the route argument — no payload.
|
|
expect(post.mock.calls[0].length).toBe(1);
|
|
});
|
|
|
|
it("unwraps the { ok } envelope from res.data", async () => {
|
|
post.mockResolvedValue({ data: { ok: true } });
|
|
await expect(testRealtimeConnection()).resolves.toEqual({ ok: true });
|
|
});
|
|
|
|
it("surfaces the failure envelope (ok:false + error) verbatim", async () => {
|
|
post.mockResolvedValue({ data: { ok: false, error: "unreachable" } });
|
|
await expect(testRealtimeConnection()).resolves.toEqual({
|
|
ok: false,
|
|
error: "unreachable",
|
|
});
|
|
});
|
|
});
|