jest.mock("../src/api/api", () => ({ authService: jest.fn(), })); import { EventEmitter } from "events"; import { SofyaTranscriber } from "../src/services/transcription/SofyaTranscriber"; import { TranscriptionServiceFactory } from "../src/services/transcription/TranscriptionServiceFactory"; import { SofyaAuthError, redactAuthConfig, resolveRealtimeProtocols, } from "../src/services/transcription/auth"; import { MockWebSocket } from "./helpers/MockWebSocket"; import { advanceTimersAndFlush, flushMicrotasks } from "./helpers/testUtils"; // Standard JWT: base64url segments, no padding. const JWT = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkciJ9.c2lnbmF0dXJl"; const createService = (config: Record) => TranscriptionServiceFactory.create("sofya_as_service", { language: "pt-BR", endpoint: { endpoint: "wss://scribe.test/ws/transcriber" }, ...config, }); const expectAuthError = async (promise: Promise, code: string) => { const error = await promise.then( () => null, (reason) => reason ); expect(error).toBeInstanceOf(SofyaAuthError); expect(error.code).toBe(code); }; describe('direct mode auth type "jwt"', () => { const originalWebSocket = global.WebSocket; let logSpy: jest.SpyInstance; beforeEach(() => { jest.useFakeTimers(); MockWebSocket.reset(); logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined); Object.defineProperty(global, "WebSocket", { value: MockWebSocket, writable: true, configurable: true, }); }); afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); logSpy.mockRestore(); delete (global as { fetch?: typeof fetch }).fetch; Object.defineProperty(global, "WebSocket", { value: originalWebSocket, writable: true, configurable: true, }); }); describe("websocket", () => { it("sends the JWT as the only subprotocol when no protocols are set", async () => { await createService({ auth: { type: "jwt", token: JWT } }); await advanceTimersAndFlush(0); const socket = MockWebSocket.latest(); expect(socket.protocols).toEqual([JWT]); expect(new URL(socket.url).searchParams.has("x-api-key")).toBe(false); }); it("puts the JWT before the integrator protocols", () => { const auth = { type: "jwt", token: JWT } as const; expect(resolveRealtimeProtocols("soap", auth)).toEqual([JWT, "soap"]); expect(resolveRealtimeProtocols(["a", "b"], auth)).toEqual([JWT, "a", "b"]); }); it("repeats the JWT first on every reconnection", async () => { await createService({ protocols: ["soap"], auth: { type: "jwt", token: JWT }, resilience: { connectTimeoutMs: 50, reconnectBaseDelayMs: 10, reconnectMaxDelayMs: 10, minConnectionUptimeMs: 0, }, }); await advanceTimersAndFlush(0); const firstSocket = MockWebSocket.latest(); firstSocket.open(); await advanceTimersAndFlush(0); firstSocket.fail(1006, "connection lost"); await advanceTimersAndFlush(10); expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2); MockWebSocket.instances.forEach((socket) => { expect(socket.protocols).toEqual([JWT, "soap"]); }); }); }); describe("http calls", () => { it("uses the JWT as the bearer of the batch reprocess and audit calls", async () => { const service = await createService({ headers: { "x-api-key": "gateway-key" }, auth: { type: "jwt", token: JWT }, }); expect((service as any).buildBatchReprocessHeaders()).toEqual({ authorization: `Bearer ${JWT}`, "x-api-key": "gateway-key", }); expect( (service as any).buildSttAuditIngestionHeaders("https://scribe.test/v1/stt/audits") ).toEqual({ headers: { authorization: `Bearer ${JWT}`, "content-type": "application/json", "x-api-key": "gateway-key", }, }); }); it("skips the audit with missing_api_key when only the JWT is set", async () => { const service = await createService({ auth: { type: "jwt", token: JWT } }); const result = (service as any).buildSttAuditIngestionHeaders( "https://scribe.test/v1/stt/audits" ); expect(result.warning.code).toBe("missing_api_key"); }); it("uses the JWT as the bearer of the primary batch request", async () => { const service = await createService({ mode: "batch", auth: { type: "jwt", token: JWT }, }); expect((service as any).buildBatchHeaders()).toEqual({ authorization: `Bearer ${JWT}`, }); expect(MockWebSocket.instances).toHaveLength(0); }); it("sends no credential on the final upload", async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200, headers: new Headers(), }); (global as { fetch?: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; const service = await createService({ headers: { "x-api-key": "gateway-key" }, auth: { type: "jwt", token: JWT }, finalUpload: { endpoint: "https://storage.test/upload", format: "wav" }, }); (service as any).durableAudioSession = { buildFinalUpload: jest .fn() .mockResolvedValue(new Blob([new Uint8Array([1])], { type: "audio/wav" })), markArchiveCommitted: jest.fn().mockResolvedValue(undefined), }; (service as any).refreshConsultationStorageStatus = jest .fn() .mockResolvedValue(undefined); await (service as any).uploadFinalArchiveIfConfigured(); expect(fetchMock).toHaveBeenCalledWith( "https://storage.test/upload", expect.objectContaining({ headers: { "Content-Type": "audio/wav" } }) ); }); it("prefers auth.token over the deprecated config.token", async () => { const service = await createService({ token: "legacy-jwt", auth: { type: "jwt", token: JWT }, }); expect((service as any).resolvedConnectionContext.authToken).toBe(JWT); }); it("keeps config.token as the bearer when auth is api_key", async () => { const service = await createService({ token: "legacy-jwt", auth: { type: "api_key", key: "sk-key" }, }); expect((service as any).buildBatchReprocessHeaders()).toEqual({ authorization: "Bearer legacy-jwt", "x-api-key": "sk-key", }); }); it("keeps an Authorization header configured by the integrator", async () => { const service = await createService({ token: "legacy-jwt", headers: { Authorization: "Custom abc" }, auth: { type: "jwt", token: JWT }, }); expect((service as any).buildBatchReprocessHeaders()).toEqual({ authorization: "Custom abc", }); }); }); describe("validation", () => { it("throws INVALID_JWT before connecting when the token is empty", async () => { await expectAuthError( createService({ auth: { type: "jwt", token: "" } }), "INVALID_JWT" ); expect(MockWebSocket.instances).toHaveLength(0); }); it.each(["Bearer ", "bearer ", "BEARER "])( 'throws INVALID_JWT when the token starts with "%s"', async (prefix) => { await expectAuthError( createService({ auth: { type: "jwt", token: `${prefix}${JWT}` } }), "INVALID_JWT" ); expect(MockWebSocket.instances).toHaveLength(0); } ); it("never strips the Bearer prefix silently", async () => { const error = await createService({ auth: { type: "jwt", token: `Bearer ${JWT}` }, }).catch((reason) => reason); expect(error.message).toContain('without the "Bearer " prefix'); }); it("throws INVALID_JWT_CHARACTERS for a realtime token the browser would refuse", async () => { await expectAuthError( createService({ auth: { type: "jwt", token: `${JWT}==` } }), "INVALID_JWT_CHARACTERS" ); expect(MockWebSocket.instances).toHaveLength(0); }); it("accepts any character in batch mode, where the JWT is only a header", async () => { const service = await createService({ mode: "batch", auth: { type: "jwt", token: `${JWT}==` }, }); expect((service as any).buildBatchHeaders()).toEqual({ authorization: `Bearer ${JWT}==`, }); }); }); describe("logs", () => { it("never prints the JWT in the factory debug output", async () => { await createService({ auth: { type: "jwt", token: JWT } }); const printed = JSON.stringify(logSpy.mock.calls); expect(printed).not.toContain(JWT); expect(printed).toContain("[redacted]"); }); it("redacts auth.token in the auth config", () => { expect(redactAuthConfig({ auth: { type: "jwt", token: JWT } })).toEqual({ auth: { type: "jwt", token: "[redacted]" }, }); }); }); }); describe("SofyaTranscriber config.token deprecation", () => { let warnSpy: jest.SpyInstance; beforeEach(() => { warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined); jest .spyOn(TranscriptionServiceFactory, "create") .mockResolvedValue(new EventEmitter() as any); }); afterEach(() => { jest.restoreAllMocks(); }); const deprecationWarnings = () => warnSpy.mock.calls.filter(([message]) => String(message).includes("config.token is deprecated") ); it("warns once per instance when config.token is used", async () => { new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://deprecation-test", config: { language: "pt-BR", token: "legacy-jwt" }, } as any); await flushMicrotasks(); expect(deprecationWarnings()).toHaveLength(1); expect(deprecationWarnings()[0][0]).toContain("1.0.0"); }); it("does not warn with auth.jwt", async () => { new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://deprecation-test", config: { language: "pt-BR", auth: { type: "jwt", token: JWT } }, } as any); await flushMicrotasks(); expect(deprecationWarnings()).toHaveLength(0); }); it("throws synchronously for an invalid JWT", () => { expect( () => new SofyaTranscriber({ provider: "sofya_as_service", endpoint: "ws://deprecation-test", config: { language: "pt-BR", auth: { type: "jwt", token: `Bearer ${JWT}` } }, } as any) ).toThrow(SofyaAuthError); }); });