Files
OmniRoute/tests/unit/audio-provider-nodes-selection.test.ts
Felipe Almeman 697c7b96a9 fix(audio): let the audio routes use audio-typed provider nodes, and gate remote ones behind a default-off flag (#9101)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log
2026-08-05 21:43:31 -03:00

145 lines
6.1 KiB
TypeScript

// Regression tests for provider-node eligibility on the /v1/audio/* routes.
//
// All three audio routes carried an identical filter that accepted only nodes typed
// `chat`/`responses`. A node explicitly typed `audio-transcriptions` was therefore
// rejected by the very route it exists for, its models never entered the dynamic
// provider list, and a bare model name fell through to the hardcoded registry —
// where an unrelated provider owning a model literally named `whisper` silently won.
//
// The second axis is the host guard: loopback nodes stay always-eligible, remote
// nodes are opt-in via AUDIO_REMOTE_PROVIDER_NODES (default OFF) because routing
// audio to an arbitrary remote host changes egress identity.
import test from "node:test";
import assert from "node:assert/strict";
import {
isLocalAudioNodeHost,
selectAudioProviderNodes,
} from "@/app/api/v1/_shared/audioProviderNodes";
import type { ProviderNodeRow } from "@omniroute/open-sse/config/audioRegistry.ts";
const LOCAL_AUDIO_NODE: ProviderNodeRow = {
id: "openai-compatible-audio-transcriptions-local",
prefix: "localstt",
name: "Local STT",
baseUrl: "http://localhost:9000/v1",
apiType: "audio-transcriptions",
};
const REMOTE_AUDIO_NODE: ProviderNodeRow = {
id: "openai-compatible-audio-transcriptions-remote",
prefix: "remotestt",
name: "Remote STT",
baseUrl: "https://stt.example.com/v1",
apiType: "audio-transcriptions",
};
const LOCAL_CHAT_NODE: ProviderNodeRow = {
id: "openai-compatible-chat-local",
prefix: "localchat",
name: "Local multimodal gateway",
baseUrl: "http://127.0.0.1:11434/v1",
apiType: "chat",
};
const LOCAL_EMBEDDINGS_NODE: ProviderNodeRow = {
id: "openai-compatible-embeddings-local",
prefix: "localembed",
name: "Local embeddings",
baseUrl: "http://localhost:9100/v1",
apiType: "embeddings",
};
function select(nodes: ProviderNodeRow[], allowRemote = false) {
return selectAudioProviderNodes(nodes, {
audioPath: "/audio/transcriptions",
nodeApiType: "audio-transcriptions",
allowRemote,
});
}
// Each eligible node is registered twice — once under its prefix, once under its row
// id — so both addressing forms parse. Assert on the id set rather than the count.
function idsOf(providers: ReturnType<typeof select>) {
return providers.map((p) => p.id).sort();
}
test("audio node types are eligible on the audio route (the bug)", () => {
const selected = select([LOCAL_AUDIO_NODE]);
assert.ok(selected.length > 0, "an audio-transcriptions node must not be filtered out");
assert.ok(idsOf(selected).includes("localstt"));
assert.equal(selected[0].baseUrl, "http://localhost:9000/v1/audio/transcriptions");
});
test("chat/responses gateways stay eligible (no regression)", () => {
assert.ok(idsOf(select([LOCAL_CHAT_NODE])).includes("localchat"));
});
test("unrelated node types are never eligible", () => {
assert.equal(select([LOCAL_EMBEDDINGS_NODE]).length, 0);
});
test("remote nodes are excluded by default (fail-closed egress)", () => {
assert.equal(select([REMOTE_AUDIO_NODE], false).length, 0);
// ...and a loopback node alongside it is still selected.
const mixed = idsOf(select([REMOTE_AUDIO_NODE, LOCAL_AUDIO_NODE], false));
assert.ok(mixed.includes("localstt"));
assert.ok(!mixed.includes("remotestt"), "remote must stay out while the flag is off");
assert.ok(!mixed.includes(REMOTE_AUDIO_NODE.id!), "not even under its id form");
});
test("remote nodes become eligible when explicitly allowed, and carry real credentials", () => {
const selected = select([REMOTE_AUDIO_NODE], true);
// Addressed by prefix (what the caller types) and by id (what combos store).
assert.deepEqual(idsOf(selected), [REMOTE_AUDIO_NODE.id, "remotestt"].sort());
for (const provider of selected) {
// Credentials always resolve under the node id, where connections are stored.
assert.equal(provider.credentialProviderId, REMOTE_AUDIO_NODE.id);
// A remote node must present its key — "none" would send an unauthenticated request.
assert.equal(provider.authType, "apikey");
assert.equal(provider.authHeader, "bearer");
}
});
test("loopback nodes keep authType none so local engines need no key", () => {
const provider = select([LOCAL_AUDIO_NODE])[0];
assert.equal(provider.authType, "none");
});
test("isLocalAudioNodeHost matches loopback and the Docker private range only", () => {
assert.equal(isLocalAudioNodeHost("http://localhost:1234"), true);
assert.equal(isLocalAudioNodeHost("http://127.0.0.1:1234"), true);
assert.equal(isLocalAudioNodeHost("http://172.17.0.2:1234"), true);
assert.equal(isLocalAudioNodeHost("http://172.15.0.2:1234"), false);
assert.equal(isLocalAudioNodeHost("http://172.32.0.2:1234"), false);
assert.equal(isLocalAudioNodeHost("https://stt.example.com"), false);
// ::1 stays excluded, matching the previous SSRF hardening.
assert.equal(isLocalAudioNodeHost("http://[::1]:1234"), false);
assert.equal(isLocalAudioNodeHost("not-a-url"), false);
});
test("a node is addressable by prefix AND by its row id", () => {
// Combos store targets as `<node-id>/<model>`, and /v1/models advertises that form
// too. Registering only the prefix made the advertised id parse as an unknown
// provider and 400 — including right after a combo was expanded.
const selected = select([LOCAL_AUDIO_NODE]);
const ids = selected.map((p) => p.id).sort();
assert.deepEqual(ids, [LOCAL_AUDIO_NODE.id, "localstt"].sort());
// Both entries must reach the same endpoint and share credential resolution.
for (const p of selected) {
assert.equal(p.baseUrl, "http://localhost:9000/v1/audio/transcriptions");
assert.equal(p.credentialProviderId, LOCAL_AUDIO_NODE.id);
}
});
test("no duplicate entry when the prefix already equals the node id", () => {
const same: ProviderNodeRow = { ...LOCAL_AUDIO_NODE, id: "localstt", prefix: "localstt" };
assert.equal(select([same]).length, 1);
});
test("nodes without a baseUrl are skipped instead of throwing", () => {
const broken = { id: "x", prefix: "x", name: "x", baseUrl: "", apiType: "audio-transcriptions" };
assert.equal(select([broken as ProviderNodeRow]).length, 0);
});