422 lines
13 KiB
TypeScript
422 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { loadConfig } from "../src/server/config.ts";
|
|
|
|
describe("server configuration", () => {
|
|
it("allows local development without STUN and defaults the origin", () => {
|
|
const config = loadConfig({ NODE_ENV: "development", PORT: "9123" });
|
|
|
|
expect(config.listenHost).toBe("0.0.0.0");
|
|
expect(config.publicBaseUrl.href).toBe("http://localhost:9123/");
|
|
expect(config.allowedOrigins).toEqual(new Set(["http://localhost:9123"]));
|
|
expect(config.stunUrls).toEqual([]);
|
|
expect(config.maxViewersPerRoom).toBe(8);
|
|
expect(config.peerAssistedMedia).toBe(false);
|
|
expect(config.livekitFallback).toBeUndefined();
|
|
expect(config.selectedEdgeTurn).toBeUndefined();
|
|
});
|
|
|
|
it("enables selected-edge TURN only from its complete post-SFU tuple", () => {
|
|
const base = {
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
LIVEKIT_URL: "wss://livekit.test",
|
|
LIVEKIT_API_KEY: "test-key",
|
|
LIVEKIT_API_SECRET: "s".repeat(32),
|
|
};
|
|
const turn = {
|
|
SELECTED_EDGE_TURN_URLS: "turn:turn.test:3478?transport=udp",
|
|
SELECTED_EDGE_TURN_SHARED_SECRET: "t".repeat(32),
|
|
SELECTED_EDGE_TURN_CREDENTIAL_TTL_SECONDS: "120",
|
|
};
|
|
expect(loadConfig({ ...base, ...turn }).selectedEdgeTurn).toEqual({
|
|
urls: [turn.SELECTED_EDGE_TURN_URLS],
|
|
sharedSecret: turn.SELECTED_EDGE_TURN_SHARED_SECRET,
|
|
credentialTtlSeconds: 120,
|
|
});
|
|
expect(loadConfig({ ...base, ...turn }).stunUrls).toEqual([]);
|
|
for (const name of Object.keys(turn)) {
|
|
const partial = { ...base, ...turn };
|
|
delete partial[name as keyof typeof partial];
|
|
expect(() => loadConfig(partial)).toThrow("must be configured together");
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
"turn:turn.test:3478",
|
|
"turns:turn.test:5349?transport=udp",
|
|
"turn:user@turn.test:3478?transport=udp",
|
|
"turn:turn.test:3478?transport=tcp",
|
|
])("rejects a non-canonical selected-edge TURN URL: %s", (url) => {
|
|
expect(() =>
|
|
loadConfig({
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
LIVEKIT_URL: "wss://livekit.test",
|
|
LIVEKIT_API_KEY: "test-key",
|
|
LIVEKIT_API_SECRET: "s".repeat(32),
|
|
SELECTED_EDGE_TURN_URLS: url,
|
|
SELECTED_EDGE_TURN_SHARED_SECRET: "t".repeat(32),
|
|
SELECTED_EDGE_TURN_CREDENTIAL_TTL_SECONDS: "120",
|
|
}),
|
|
).toThrow("one UDP TURN URL");
|
|
});
|
|
|
|
it("enables LiveKit fallback only for a complete credential tuple", () => {
|
|
const config = loadConfig({
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
LIVEKIT_URL: " ws://livekit.test:7880 ",
|
|
LIVEKIT_API_KEY: " test-key ",
|
|
LIVEKIT_API_SECRET: ` ${"s".repeat(32)} `,
|
|
});
|
|
|
|
expect(config.livekitFallback).toEqual({
|
|
url: "ws://livekit.test:7880",
|
|
apiKey: "test-key",
|
|
apiSecret: "s".repeat(32),
|
|
maxSfuRootsPerRoom: 2,
|
|
});
|
|
});
|
|
|
|
it.each(["1", "2"])(
|
|
"accepts an SFU root limit of %s when fallback is configured",
|
|
(maxSfuRootsPerRoom) => {
|
|
const config = loadConfig({
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
LIVEKIT_URL: "wss://livekit.test",
|
|
LIVEKIT_API_KEY: "test-key",
|
|
LIVEKIT_API_SECRET: "s".repeat(32),
|
|
MAX_SFU_ROOTS_PER_ROOM: maxSfuRootsPerRoom,
|
|
});
|
|
|
|
expect(config.livekitFallback?.maxSfuRootsPerRoom).toBe(
|
|
Number(maxSfuRootsPerRoom),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("does not activate fallback from the root limit alone", () => {
|
|
expect(
|
|
loadConfig({ MAX_SFU_ROOTS_PER_ROOM: "2" }).livekitFallback,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("requires peer-assisted media for LiveKit fallback", () => {
|
|
expect(() =>
|
|
loadConfig({
|
|
PEER_ASSISTED_MEDIA: "false",
|
|
LIVEKIT_URL: "wss://livekit.test",
|
|
LIVEKIT_API_KEY: "test-key",
|
|
LIVEKIT_API_SECRET: "s".repeat(32),
|
|
}),
|
|
).toThrow("LiveKit fallback requires PEER_ASSISTED_MEDIA=true");
|
|
});
|
|
|
|
it.each([
|
|
{ LIVEKIT_URL: "wss://livekit.test" },
|
|
{ LIVEKIT_API_KEY: "test-key" },
|
|
{ LIVEKIT_API_SECRET: "s".repeat(32) },
|
|
{
|
|
LIVEKIT_URL: "wss://livekit.test",
|
|
LIVEKIT_API_KEY: "test-key",
|
|
},
|
|
])("rejects a partial LiveKit credential tuple", (partial) => {
|
|
expect(() => loadConfig(partial)).toThrow(
|
|
"LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"https://livekit.test",
|
|
"wss://user:pass@livekit.test",
|
|
"wss://livekit.test/rtc",
|
|
"wss://livekit.test?token=value",
|
|
"wss://livekit.test#fragment",
|
|
])("rejects an invalid LiveKit origin: %s", (url) => {
|
|
expect(() =>
|
|
loadConfig({
|
|
LIVEKIT_URL: url,
|
|
LIVEKIT_API_KEY: "test-key",
|
|
LIVEKIT_API_SECRET: "s".repeat(32),
|
|
}),
|
|
).toThrow("LIVEKIT_URL");
|
|
});
|
|
|
|
it("requires wss for LiveKit fallback in production", () => {
|
|
expect(() =>
|
|
loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
STUN_URLS: "stun:stun.test:3478",
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
LIVEKIT_URL: "ws://livekit.test:7880",
|
|
LIVEKIT_API_KEY: "test-key",
|
|
LIVEKIT_API_SECRET: "s".repeat(32),
|
|
}),
|
|
).toThrow("LIVEKIT_URL must use wss in production");
|
|
});
|
|
|
|
it("rejects a short LiveKit API secret and invalid root limits", () => {
|
|
const fallback = {
|
|
LIVEKIT_URL: "wss://livekit.test",
|
|
LIVEKIT_API_KEY: "test-key",
|
|
LIVEKIT_API_SECRET: "s".repeat(32),
|
|
};
|
|
|
|
expect(() =>
|
|
loadConfig({ ...fallback, LIVEKIT_API_SECRET: "too-short" }),
|
|
).toThrow("LIVEKIT_API_SECRET must contain at least 32 bytes");
|
|
for (const maxSfuRootsPerRoom of ["0", "3", "1.5"]) {
|
|
expect(() =>
|
|
loadConfig({
|
|
...fallback,
|
|
MAX_SFU_ROOTS_PER_ROOM: maxSfuRootsPerRoom,
|
|
}),
|
|
).toThrow("MAX_SFU_ROOTS_PER_ROOM");
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
SITE_ACCESS_PASSWORD: "x".repeat(32),
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
LIVEKIT_URL: "wss://livekit.test",
|
|
LIVEKIT_API_KEY: "test-key",
|
|
LIVEKIT_API_SECRET: "x".repeat(32),
|
|
},
|
|
{
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
LIVEKIT_URL: "wss://livekit.test",
|
|
LIVEKIT_API_KEY: "x".repeat(32),
|
|
LIVEKIT_API_SECRET: "x".repeat(32),
|
|
},
|
|
])("rejects reused infrastructure secrets", (environment) => {
|
|
expect(() => loadConfig(environment)).toThrow(
|
|
"must use independent values",
|
|
);
|
|
});
|
|
|
|
it("requires an explicit boolean to enable peer-assisted media", () => {
|
|
expect(
|
|
loadConfig({
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
}).peerAssistedMedia,
|
|
).toBe(true);
|
|
expect(loadConfig({ PEER_ASSISTED_MEDIA: "false" }).peerAssistedMedia).toBe(
|
|
false,
|
|
);
|
|
expect(() => loadConfig({ PEER_ASSISTED_MEDIA: "1" })).toThrow(
|
|
"PEER_ASSISTED_MEDIA must be true or false",
|
|
);
|
|
expect(() =>
|
|
loadConfig({
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
MAX_VIEWERS_PER_ROOM: "9",
|
|
}),
|
|
).toThrow(
|
|
"PEER_ASSISTED_MEDIA currently supports at most 8 viewers per room",
|
|
);
|
|
});
|
|
|
|
it("enables the hybrid controller for every room when selected", () => {
|
|
expect(loadConfig({ PEER_ASSISTED_MEDIA: "true" }).peerAssistedMedia).toBe(
|
|
true,
|
|
);
|
|
expect(() =>
|
|
loadConfig({ PEER_ASSISTED_ROOM_IDS: "1" }),
|
|
).toThrow("PEER_ASSISTED_ROOM_IDS is no longer supported");
|
|
});
|
|
|
|
it("allows an explicit loopback listen host", () => {
|
|
expect(loadConfig({ LISTEN_HOST: " 127.0.0.1 " }).listenHost).toBe(
|
|
"127.0.0.1",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"https://user:pass@share.test",
|
|
"https://share.test/path",
|
|
"https://share.test?query=1",
|
|
"https://share.test#fragment",
|
|
])("rejects a PUBLIC_BASE_URL that is not a plain origin: %s", (url) => {
|
|
expect(() => loadConfig({ PUBLIC_BASE_URL: url })).toThrow(
|
|
"PUBLIC_BASE_URL must be an origin",
|
|
);
|
|
});
|
|
|
|
it("requires STUN but not TURN in production", () => {
|
|
expect(() =>
|
|
loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
SITE_ACCESS_PASSWORD: "host-password-12",
|
|
}),
|
|
).toThrow("STUN is required in production");
|
|
|
|
const config = loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
SITE_ACCESS_PASSWORD: "easy-key",
|
|
STUN_URLS: "stun:stun.test:3478",
|
|
});
|
|
expect(config.stunUrls).toEqual(["stun:stun.test:3478"]);
|
|
});
|
|
|
|
it("allows STUN-only production with LiveKit configured", () => {
|
|
const config = loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
SITE_ACCESS_PASSWORD: "host-password-12",
|
|
STUN_URLS: "stun:stun.test:3478",
|
|
PEER_ASSISTED_MEDIA: "true",
|
|
LIVEKIT_URL: "wss://livekit.test",
|
|
LIVEKIT_API_KEY: "test-key",
|
|
LIVEKIT_API_SECRET: "s".repeat(32),
|
|
});
|
|
|
|
expect(config.livekitFallback?.url).toBe("wss://livekit.test");
|
|
});
|
|
|
|
it.each([1, 16])(
|
|
"accepts a per-room viewer limit at boundary %i",
|
|
(maxViewersPerRoom) => {
|
|
expect(
|
|
loadConfig({ MAX_VIEWERS_PER_ROOM: String(maxViewersPerRoom) })
|
|
.maxViewersPerRoom,
|
|
).toBe(maxViewersPerRoom);
|
|
},
|
|
);
|
|
|
|
it.each(["0", "17", "1.5"])(
|
|
"rejects invalid per-room viewer limit %s",
|
|
(maxViewersPerRoom) => {
|
|
expect(() =>
|
|
loadConfig({ MAX_VIEWERS_PER_ROOM: maxViewersPerRoom }),
|
|
).toThrow("MAX_VIEWERS_PER_ROOM");
|
|
},
|
|
);
|
|
|
|
it("accepts uppercase STUN schemes and IPv6 hosts", () => {
|
|
expect(
|
|
loadConfig({ STUN_URLS: "STUN:[2001:db8::1]:3478" }).stunUrls,
|
|
).toEqual(["STUN:[2001:db8::1]:3478"]);
|
|
});
|
|
|
|
it.each([
|
|
"stun:stun.test/path",
|
|
"stun:stun.test?transport=udp",
|
|
"stun:stun.test#fragment",
|
|
"stun:",
|
|
"stun:stun.test:",
|
|
"stun:stun.test:0",
|
|
"stun:user@stun.test:3478",
|
|
])("rejects a malformed STUN URL: %s", (invalidUrl) => {
|
|
expect(() => loadConfig({ STUN_URLS: invalidUrl })).toThrow(
|
|
"STUN_URLS contains an invalid STUN URL",
|
|
);
|
|
});
|
|
|
|
it("rejects non-STUN schemes in the ordinary ICE configuration", () => {
|
|
expect(() => loadConfig({ STUN_URLS: "turn:turn.test:3478" })).toThrow(
|
|
"STUN_URLS contains an invalid STUN URL",
|
|
);
|
|
expect(() => loadConfig({ STUN_URLS: "stuns:stun.test:5349" })).toThrow(
|
|
"STUN_URLS contains an invalid STUN URL",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"TURN_URLS",
|
|
"TURN_SHARED_SECRET",
|
|
"TURN_CREDENTIAL_TTL_SECONDS",
|
|
"PEER_ICE_TURN_URLS",
|
|
"PEER_ICE_TURN_SHARED_SECRET",
|
|
"PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS",
|
|
] as const)("rejects removed TURN configuration even when %s is blank", (name) => {
|
|
expect(() => loadConfig({ [name]: "" })).toThrow(
|
|
`${name} is no longer supported`,
|
|
);
|
|
});
|
|
|
|
it("rejects more STUN URLs than the authenticated wire can carry", () => {
|
|
expect(() =>
|
|
loadConfig({
|
|
STUN_URLS: Array.from(
|
|
{ length: 9 },
|
|
(_, index) => `stun:stun-${index}.test:3478`,
|
|
).join(","),
|
|
}),
|
|
).toThrow("STUN_URLS must contain at most 8 URLs");
|
|
});
|
|
|
|
it("requires and accepts a bounded production site access password", () => {
|
|
expect(() =>
|
|
loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
STUN_URLS: "stun:stun.test:3478",
|
|
}),
|
|
).toThrow("SITE_ACCESS_PASSWORD is required in production");
|
|
const config = loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
SITE_ACCESS_PASSWORD: "easy-key",
|
|
STUN_URLS: "stun:stun.test:3478",
|
|
});
|
|
expect(config.siteAccessPassword).toBe("easy-key");
|
|
});
|
|
|
|
it("requires site access protection for a persistent room database", () => {
|
|
expect(() => loadConfig({ ROOM_DATABASE_PATH: "rooms.sqlite" })).toThrow(
|
|
"ROOM_DATABASE_PATH requires SITE_ACCESS_PASSWORD",
|
|
);
|
|
|
|
const config = loadConfig({
|
|
SITE_ACCESS_PASSWORD: "host-password-12",
|
|
ROOM_DATABASE_PATH: "rooms.sqlite",
|
|
});
|
|
expect(config.roomDatabasePath).toBe("rooms.sqlite");
|
|
});
|
|
|
|
it("rejects an in-memory room database in production", () => {
|
|
expect(() =>
|
|
loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
SITE_ACCESS_PASSWORD: "host-password-12",
|
|
ROOM_DATABASE_PATH: ":memory:",
|
|
STUN_URLS: "stun:stun.test:3478",
|
|
}),
|
|
).toThrow("ROOM_DATABASE_PATH must be file-backed in production");
|
|
});
|
|
|
|
it.each(["x".repeat(7), "密码密码密码密码", "contains spaces", "x".repeat(129)])(
|
|
"rejects a site access password outside the visible ASCII boundary",
|
|
(siteAccessPassword) => {
|
|
expect(() =>
|
|
loadConfig({ SITE_ACCESS_PASSWORD: siteAccessPassword }),
|
|
).toThrow(
|
|
"SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([undefined, "", "legacy-password"])(
|
|
"rejects the removed ACCESS_PASSWORD configuration",
|
|
(accessPassword) => {
|
|
expect(() => loadConfig({ ACCESS_PASSWORD: accessPassword })).toThrow(
|
|
"ACCESS_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([undefined, "", "legacy-password"])(
|
|
"rejects the removed HOST_ADMISSION_PASSWORD configuration",
|
|
(removedPassword) => {
|
|
expect(() =>
|
|
loadConfig({ HOST_ADMISSION_PASSWORD: removedPassword }),
|
|
).toThrow(
|
|
"HOST_ADMISSION_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD",
|
|
);
|
|
},
|
|
);
|
|
});
|