293 lines
9.2 KiB
TypeScript
293 lines
9.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { loadConfig } from "../src/server/config.ts";
|
|
|
|
const requiredProductionTurnUrls =
|
|
"turn:turn.test:3478?transport=udp,turn:turn.test:3478?transport=tcp";
|
|
|
|
describe("server configuration", () => {
|
|
it("allows development without TURN 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.turnUrls).toEqual([]);
|
|
expect(config.maxViewersPerRoom).toBe(8);
|
|
expect(config.peerAssistedMedia).toBe(false);
|
|
});
|
|
|
|
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("allows an explicit loopback listen host", () => {
|
|
const config = loadConfig({ LISTEN_HOST: " 127.0.0.1 " });
|
|
|
|
expect(config.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("allows an optional access password but requires TURN in production", () => {
|
|
expect(() =>
|
|
loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test" }),
|
|
).toThrow("TURN is required");
|
|
|
|
expect(() =>
|
|
loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
ACCESS_PASSWORD: "c".repeat(12),
|
|
}),
|
|
).toThrow("TURN is required");
|
|
|
|
expect(() =>
|
|
loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
ACCESS_PASSWORD: "c".repeat(12),
|
|
TURN_URLS: "turn:turn.test:3478",
|
|
TURN_SHARED_SECRET: "t".repeat(32),
|
|
}),
|
|
).toThrow("STUN is required");
|
|
});
|
|
|
|
it("accepts complete production configuration", () => {
|
|
const config = loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
STUN_URLS: "stun:turn.test:3478,stuns:turn.test:5349",
|
|
TURN_URLS: requiredProductionTurnUrls,
|
|
TURN_SHARED_SECRET: "t".repeat(32),
|
|
});
|
|
|
|
expect(config.accessPassword).toBeUndefined();
|
|
expect(config.roomDatabasePath).toBeUndefined();
|
|
expect(config.turnUrls).toHaveLength(2);
|
|
expect(config.turnSharedSecret).toBe("t".repeat(32));
|
|
});
|
|
|
|
it.each([1, 16])(
|
|
"accepts a per-room viewer limit at boundary %i",
|
|
(maxViewersPerRoom) => {
|
|
const config = loadConfig({
|
|
MAX_VIEWERS_PER_ROOM: String(maxViewersPerRoom),
|
|
});
|
|
|
|
expect(config.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.each([5349, 443])(
|
|
"accepts optional TURN/TLS over TCP on production port %i",
|
|
(tlsPort) => {
|
|
const config = loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
ACCESS_PASSWORD: "c".repeat(12),
|
|
STUN_URLS: "stun:turn.test:3478",
|
|
TURN_URLS: `${requiredProductionTurnUrls},turns:turn.test:${tlsPort}?transport=tcp`,
|
|
TURN_SHARED_SECRET: "t".repeat(32),
|
|
});
|
|
|
|
expect(config.turnUrls.at(-1)).toBe(
|
|
`turns:turn.test:${tlsPort}?transport=tcp`,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("does not require transport coverage outside production", () => {
|
|
const config = loadConfig({
|
|
TURN_URLS: "turn:turn.test:3478",
|
|
TURN_SHARED_SECRET: "development-secret",
|
|
});
|
|
|
|
expect(config.turnUrls).toEqual(["turn:turn.test:3478"]);
|
|
});
|
|
|
|
it("accepts a valid uppercase scheme, IPv6 host, and secure UDP URL", () => {
|
|
const config = loadConfig({
|
|
TURN_URLS: "TURNS:[2001:db8::1]:443?transport=udp",
|
|
TURN_SHARED_SECRET: "development-secret",
|
|
});
|
|
|
|
expect(config.turnUrls).toEqual([
|
|
"TURNS:[2001:db8::1]:443?transport=udp",
|
|
]);
|
|
});
|
|
|
|
it("rejects every malformed ICE URL, including extras after valid TURN URLs", () => {
|
|
const invalidTurnUrls = [
|
|
"turn:turn.test/path?transport=udp",
|
|
"turn:turn.test:3478/?transport=udp",
|
|
"turn:turn.test:3478?transport=udp#",
|
|
"turn:turn.test:3478?transport=udp&",
|
|
"turn:turn.test:3478?",
|
|
"turn:turn.test:?transport=udp",
|
|
"turn:turn.test:0?transport=udp",
|
|
"turn:user@turn.test:3478?transport=udp",
|
|
"turn:turn.test:3478?transport=UDP",
|
|
"turn:turn.test:3478?transport=%75dp",
|
|
];
|
|
for (const invalidUrl of invalidTurnUrls) {
|
|
expect(() =>
|
|
loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
ACCESS_PASSWORD: "c".repeat(12),
|
|
STUN_URLS: "stun:turn.test:3478",
|
|
TURN_URLS: `${requiredProductionTurnUrls},${invalidUrl}`,
|
|
TURN_SHARED_SECRET: "t".repeat(32),
|
|
}),
|
|
).toThrow("TURN_URLS contains an invalid ICE URL");
|
|
}
|
|
|
|
for (const invalidUrl of [
|
|
"stun:turn.test/path",
|
|
"stun:turn.test?transport=udp",
|
|
"stun:turn.test#",
|
|
]) {
|
|
expect(() => loadConfig({ STUN_URLS: invalidUrl })).toThrow(
|
|
"STUN_URLS contains an invalid ICE URL",
|
|
);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
missing: "TURN/UDP",
|
|
urls:
|
|
"turn:turn.test:3478,turn:turn.test:3478?transport=tcp,turns:turn.test:443?transport=tcp",
|
|
},
|
|
{
|
|
missing: "TURN/TCP",
|
|
urls:
|
|
"turn:turn.test:3478?transport=udp,turn:turn.test:3478",
|
|
},
|
|
])("rejects production TURN URLs missing $missing", ({ urls }) => {
|
|
expect(() =>
|
|
loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
ACCESS_PASSWORD: "c".repeat(12),
|
|
STUN_URLS: "stun:turn.test:3478",
|
|
TURN_URLS: urls,
|
|
TURN_SHARED_SECRET: "t".repeat(32),
|
|
}),
|
|
).toThrow(
|
|
"TURN_URLS must include explicit TURN/UDP and TURN/TCP endpoints in production",
|
|
);
|
|
});
|
|
|
|
it("accepts a short production access password", () => {
|
|
const config = loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
ACCESS_PASSWORD: "short-pass1",
|
|
STUN_URLS: "stun:turn.test:3478",
|
|
TURN_URLS: requiredProductionTurnUrls,
|
|
TURN_SHARED_SECRET: "t".repeat(32),
|
|
});
|
|
|
|
expect(config.accessPassword).toBe("short-pass1");
|
|
});
|
|
|
|
it("rejects a weak production TURN secret", () => {
|
|
expect(() =>
|
|
loadConfig({
|
|
NODE_ENV: "production",
|
|
PUBLIC_BASE_URL: "https://share.test",
|
|
ACCESS_PASSWORD: "x",
|
|
STUN_URLS: "stun:turn.test:3478",
|
|
TURN_URLS: requiredProductionTurnUrls,
|
|
TURN_SHARED_SECRET: "too-short",
|
|
}),
|
|
).toThrow("TURN_SHARED_SECRET must contain at least 32 bytes");
|
|
});
|
|
|
|
it("requires access protection for a persistent room database", () => {
|
|
expect(() =>
|
|
loadConfig({ ROOM_DATABASE_PATH: "rooms.sqlite" }),
|
|
).toThrow("ROOM_DATABASE_PATH requires ACCESS_PASSWORD");
|
|
|
|
const config = loadConfig({
|
|
ACCESS_PASSWORD: "x",
|
|
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",
|
|
ACCESS_PASSWORD: "x",
|
|
ROOM_DATABASE_PATH: ":memory:",
|
|
}),
|
|
).toThrow("ROOM_DATABASE_PATH must be file-backed in production");
|
|
});
|
|
|
|
it.each(["密码密码密码密码", "contains spaces", "x".repeat(129)])(
|
|
"rejects an access password that cannot be carried safely in a header",
|
|
(accessPassword) => {
|
|
expect(() => loadConfig({ ACCESS_PASSWORD: accessPassword })).toThrow(
|
|
"ACCESS_PASSWORD must contain at most 128 visible ASCII characters",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("rejects partial TURN settings and mismatched ICE schemes", () => {
|
|
expect(() => loadConfig({ TURN_URLS: "turn:turn.test:3478" })).toThrow(
|
|
"TURN_URLS and TURN_SHARED_SECRET",
|
|
);
|
|
expect(() => loadConfig({ STUN_URLS: "https://stun.test" })).toThrow(
|
|
"STUN_URLS contains an unsupported URL scheme",
|
|
);
|
|
expect(() =>
|
|
loadConfig({
|
|
TURN_URLS: "stun:turn.test:3478",
|
|
TURN_SHARED_SECRET: "turn-secret",
|
|
}),
|
|
).toThrow("TURN_URLS contains an unsupported URL scheme");
|
|
expect(() => loadConfig({ TURN_CREDENTIAL_TTL_SECONDS: "3601" })).toThrow(
|
|
"TURN_CREDENTIAL_TTL_SECONDS must be between 60 and 3600",
|
|
);
|
|
});
|
|
});
|