Preserve exact selected edges across unrelated route revisions, demote corroborated bad relay parents, and parameterize the bounded endpoint cap while retaining the production default of two.
433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { createRoomResponseSchema } from "../src/shared/protocol.ts";
|
|
import {
|
|
createScreenerServer,
|
|
type CreateServerOptions,
|
|
type ScreenerServer,
|
|
} from "../src/server/app.ts";
|
|
import type { ServerConfig } from "../src/server/config.ts";
|
|
|
|
const allowedOrigin = "http://allowed.test";
|
|
const siteAccessPassword = "instance-access-password";
|
|
let runningServer: ScreenerServer | undefined;
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await runningServer?.close();
|
|
runningServer = undefined;
|
|
for (const directory of temporaryDirectories.splice(0)) {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function temporaryDatabasePath(): string {
|
|
const directory = mkdtempSync(join(tmpdir(), "screener-http-"));
|
|
temporaryDirectories.push(directory);
|
|
return join(directory, "rooms.sqlite");
|
|
}
|
|
|
|
function testConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
|
|
return {
|
|
nodeEnv: "test",
|
|
port: 0,
|
|
listenHost: "127.0.0.1",
|
|
publicBaseUrl: new URL("https://share.example.test"),
|
|
allowedOrigins: new Set([allowedOrigin]),
|
|
siteAccessPassword,
|
|
roomTtlMs: 14_400_000,
|
|
maxRooms: 10,
|
|
maxViewersPerRoom: 8,
|
|
peerAssistedMedia: false,
|
|
maxPeerRelayDownstreamEdges: 2,
|
|
stunUrls: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function start(
|
|
config = testConfig(),
|
|
options: Pick<
|
|
CreateServerOptions,
|
|
"now" | "siteAccessTtlSeconds" | "sfuTokenIssuer"
|
|
> = {},
|
|
): Promise<string> {
|
|
runningServer = await createScreenerServer({
|
|
...options,
|
|
config,
|
|
serveFrontend: false,
|
|
});
|
|
const port = await runningServer.listen(0, "127.0.0.1");
|
|
return `http://127.0.0.1:${port}`;
|
|
}
|
|
|
|
function cookiePair(response: Response): string {
|
|
const setCookie = response.headers.get("set-cookie");
|
|
if (!setCookie) {
|
|
throw new Error("Expected a Set-Cookie header");
|
|
}
|
|
return setCookie.split(";", 1)[0]!;
|
|
}
|
|
|
|
async function login(baseUrl: string): Promise<Response> {
|
|
return fetch(`${baseUrl}/api/site-access`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${siteAccessPassword}`,
|
|
Origin: allowedOrigin,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function createRoom(
|
|
baseUrl: string,
|
|
cookie?: string,
|
|
viewerPolicy: "private-link" | "public-watch" = "private-link",
|
|
hostClaimTtlSeconds?: number,
|
|
): Promise<Response> {
|
|
return fetch(`${baseUrl}/api/rooms`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Origin: allowedOrigin,
|
|
...(cookie ? { Cookie: cookie } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
viewerPolicy,
|
|
...(hostClaimTtlSeconds === undefined ? {} : { hostClaimTtlSeconds }),
|
|
}),
|
|
});
|
|
}
|
|
|
|
describe("site access", () => {
|
|
it("reports status and issues a stateless 12-hour cookie", async () => {
|
|
const baseUrl = await start();
|
|
const initial = await fetch(`${baseUrl}/api/site-access`);
|
|
|
|
expect(initial.status).toBe(200);
|
|
expect(initial.headers.get("cache-control")).toBe("no-store");
|
|
expect(await initial.json()).toEqual({
|
|
required: true,
|
|
authenticated: false,
|
|
});
|
|
|
|
const denied = await fetch(`${baseUrl}/api/site-access`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer wrong-password",
|
|
Origin: allowedOrigin,
|
|
},
|
|
});
|
|
expect(denied.status).toBe(401);
|
|
expect(denied.headers.get("set-cookie")).toBeNull();
|
|
|
|
const authenticated = await login(baseUrl);
|
|
const setCookie = authenticated.headers.get("set-cookie") ?? "";
|
|
expect(authenticated.status).toBe(200);
|
|
expect(setCookie).toContain("screener-site-access=v1.");
|
|
expect(setCookie).toContain("Path=/");
|
|
expect(setCookie).toContain("Max-Age=43200");
|
|
expect(setCookie).toContain("HttpOnly");
|
|
expect(setCookie).toContain("SameSite=Strict");
|
|
expect(setCookie).not.toContain("Secure");
|
|
expect(setCookie).not.toContain(siteAccessPassword);
|
|
|
|
const status = await fetch(`${baseUrl}/api/site-access`, {
|
|
headers: { Cookie: cookiePair(authenticated) },
|
|
});
|
|
expect(await status.json()).toEqual({
|
|
required: true,
|
|
authenticated: true,
|
|
});
|
|
});
|
|
|
|
it("uses a secure __Host- cookie for production HTTPS", async () => {
|
|
const baseUrl = await start(testConfig({ nodeEnv: "production" }));
|
|
const response = await login(baseUrl);
|
|
const setCookie = response.headers.get("set-cookie") ?? "";
|
|
|
|
expect(setCookie).toContain("__Host-screener-site-access=");
|
|
expect(setCookie).toContain("Secure");
|
|
expect(setCookie).toContain("HttpOnly");
|
|
expect(setCookie).toContain("SameSite=Strict");
|
|
expect(setCookie).toContain("Path=/");
|
|
expect(setCookie).not.toContain("Domain=");
|
|
});
|
|
|
|
it("requires an allowed Origin and an empty login body", async () => {
|
|
const baseUrl = await start();
|
|
const authorization = {
|
|
Authorization: `Bearer ${siteAccessPassword}`,
|
|
};
|
|
|
|
expect(
|
|
(
|
|
await fetch(`${baseUrl}/api/site-access`, {
|
|
method: "POST",
|
|
headers: authorization,
|
|
})
|
|
).status,
|
|
).toBe(403);
|
|
expect(
|
|
(
|
|
await fetch(`${baseUrl}/api/site-access`, {
|
|
method: "POST",
|
|
headers: { ...authorization, Origin: "https://foreign.test" },
|
|
})
|
|
).status,
|
|
).toBe(403);
|
|
expect(
|
|
(
|
|
await fetch(`${baseUrl}/api/site-access`, {
|
|
method: "POST",
|
|
headers: { ...authorization, Origin: `${allowedOrigin}/path` },
|
|
})
|
|
).status,
|
|
).toBe(403);
|
|
expect(
|
|
(
|
|
await fetch(`${baseUrl}/api/site-access`, {
|
|
method: "POST",
|
|
headers: { ...authorization, Origin: allowedOrigin },
|
|
body: "{}",
|
|
})
|
|
).status,
|
|
).toBe(400);
|
|
});
|
|
|
|
it("rejects an expired or modified cookie", async () => {
|
|
let now = 1_000;
|
|
const baseUrl = await start(testConfig(), {
|
|
now: () => now,
|
|
siteAccessTtlSeconds: 1,
|
|
});
|
|
const authenticated = await login(baseUrl);
|
|
const cookie = cookiePair(authenticated);
|
|
|
|
const modified = await fetch(`${baseUrl}/api/site-access`, {
|
|
headers: { Cookie: `${cookie}x` },
|
|
});
|
|
expect((await modified.json()).authenticated).toBe(false);
|
|
|
|
now = 2_000;
|
|
const expired = await fetch(`${baseUrl}/api/site-access`, {
|
|
headers: { Cookie: cookie },
|
|
});
|
|
expect(await expired.json()).toEqual({
|
|
required: true,
|
|
authenticated: false,
|
|
});
|
|
});
|
|
|
|
it("is immediately authenticated when the access password is empty", async () => {
|
|
const baseUrl = await start(
|
|
testConfig({ siteAccessPassword: undefined }),
|
|
);
|
|
const status = await fetch(`${baseUrl}/api/site-access`);
|
|
|
|
expect(await status.json()).toEqual({
|
|
required: false,
|
|
authenticated: true,
|
|
});
|
|
const post = await fetch(`${baseUrl}/api/site-access`, {
|
|
method: "POST",
|
|
headers: { Origin: allowedOrigin },
|
|
});
|
|
expect(post.status).toBe(200);
|
|
expect(post.headers.get("set-cookie")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("room HTTP API", () => {
|
|
it("requires a logged-in browser when access protection is enabled", async () => {
|
|
const baseUrl = await start();
|
|
const anonymous = await createRoom(baseUrl);
|
|
expect(anonymous.status).toBe(401);
|
|
expect(anonymous.headers.get("www-authenticate")).toBeNull();
|
|
|
|
const bearerBypass = await fetch(`${baseUrl}/api/rooms`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${siteAccessPassword}`,
|
|
"Content-Type": "application/json",
|
|
Origin: allowedOrigin,
|
|
},
|
|
body: JSON.stringify({ viewerPolicy: "private-link" }),
|
|
});
|
|
expect(bearerBypass.status).toBe(401);
|
|
});
|
|
|
|
it("creates a private room with a fragment-only Viewer grant", async () => {
|
|
const baseUrl = await start();
|
|
const authenticated = await login(baseUrl);
|
|
const response = await createRoom(baseUrl, cookiePair(authenticated));
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
const body = createRoomResponseSchema.parse(await response.json());
|
|
const invite = new URL(body.inviteUrl);
|
|
expect(body.roomId).toMatch(/^\d{12}$/);
|
|
expect(invite.origin).toBe("https://share.example.test");
|
|
expect(invite.pathname).toBe(`/r/${body.roomId}`);
|
|
expect(invite.hash).toMatch(
|
|
new RegExp(`^#v=g1\\.${body.roomId}\\.\\d+\\.[A-Za-z0-9_-]{43}$`),
|
|
);
|
|
expect(invite.search).toBe("");
|
|
expect(body.viewerPolicy).toBe("private-link");
|
|
expect(body.viewerGrantExpiresAt).toBeTruthy();
|
|
expect("iceConfig" in body).toBe(false);
|
|
});
|
|
|
|
it("accepts only the admitted fixed provisional Host lease", async () => {
|
|
const baseUrl = await start();
|
|
expect((await createRoom(baseUrl, undefined, "private-link", 300)).status).toBe(
|
|
401,
|
|
);
|
|
|
|
const authenticated = await login(baseUrl);
|
|
const cookie = cookiePair(authenticated);
|
|
expect((await createRoom(baseUrl, cookie, "private-link", 300)).status).toBe(
|
|
201,
|
|
);
|
|
expect((await createRoom(baseUrl, cookie, "private-link", 301)).status).toBe(
|
|
400,
|
|
);
|
|
expect((await createRoom(baseUrl, cookie, "private-link", 299)).status).toBe(
|
|
400,
|
|
);
|
|
});
|
|
|
|
it("allows explicit public-watch creation without site access in local mode", async () => {
|
|
const baseUrl = await start(
|
|
testConfig({ siteAccessPassword: undefined }),
|
|
);
|
|
const response = await createRoom(baseUrl, undefined, "public-watch");
|
|
expect(response.status).toBe(201);
|
|
const body = createRoomResponseSchema.parse(await response.json());
|
|
expect(body).toMatchObject({
|
|
viewerPolicy: "public-watch",
|
|
viewerGrantExpiresAt: null,
|
|
});
|
|
expect(new URL(body.inviteUrl).hash).toBe("");
|
|
});
|
|
|
|
it("persists sequential protected rooms across server restarts", async () => {
|
|
const config = testConfig({ roomDatabasePath: temporaryDatabasePath() });
|
|
let baseUrl = await start(config);
|
|
const authenticated = await login(baseUrl);
|
|
const cookie = cookiePair(authenticated);
|
|
const create = () => createRoom(baseUrl, cookie);
|
|
|
|
const responses = await Promise.all([create(), create()]);
|
|
const rooms = await Promise.all(
|
|
responses.map(async (response) =>
|
|
createRoomResponseSchema.parse(await response.json()),
|
|
),
|
|
);
|
|
expect(
|
|
rooms.map((room) => Number(room.roomId)).sort((left, right) => left - right),
|
|
).toEqual([1, 2]);
|
|
expect(rooms.every((room) => room.expiresAt === null)).toBe(true);
|
|
|
|
await runningServer?.close();
|
|
runningServer = undefined;
|
|
baseUrl = await start(config);
|
|
const third = await createRoom(baseUrl, cookie);
|
|
expect(createRoomResponseSchema.parse(await third.json())).toMatchObject({
|
|
roomId: "3",
|
|
expiresAt: null,
|
|
});
|
|
});
|
|
|
|
it("rejects malformed room requests and foreign browser origins", async () => {
|
|
const baseUrl = await start(
|
|
testConfig({ siteAccessPassword: undefined }),
|
|
);
|
|
const withBody = await fetch(`${baseUrl}/api/rooms`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Origin: allowedOrigin,
|
|
},
|
|
body: "{}",
|
|
});
|
|
expect(withBody.status).toBe(400);
|
|
|
|
const foreign = await fetch(`${baseUrl}/api/rooms`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Origin: "https://foreign.test" },
|
|
body: JSON.stringify({ viewerPolicy: "private-link" }),
|
|
});
|
|
expect(foreign.status).toBe(403);
|
|
});
|
|
|
|
it("returns service unavailable at the global room bound", async () => {
|
|
const baseUrl = await start(
|
|
testConfig({ siteAccessPassword: undefined, maxRooms: 1 }),
|
|
);
|
|
const create = () => createRoom(baseUrl);
|
|
|
|
expect((await create()).status).toBe(201);
|
|
expect((await create()).status).toBe(503);
|
|
});
|
|
});
|
|
|
|
describe("server HTTP listener and health", () => {
|
|
it("starts with an injected optional SFU token issuer", async () => {
|
|
const baseUrl = await start(
|
|
testConfig({
|
|
peerAssistedMedia: true,
|
|
livekitFallback: {
|
|
url: "ws://livekit.test:7880",
|
|
apiKey: "test-key",
|
|
apiSecret: "s".repeat(32),
|
|
maxSfuRootsPerRoom: 2,
|
|
},
|
|
}),
|
|
{
|
|
sfuTokenIssuer: {
|
|
issueToken: async () => "unused-test-token",
|
|
},
|
|
},
|
|
);
|
|
|
|
const response = await fetch(`${baseUrl}/healthz`);
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("uses the configured listen host by default", async () => {
|
|
runningServer = await createScreenerServer({
|
|
config: testConfig(),
|
|
serveFrontend: false,
|
|
});
|
|
await runningServer.listen(0);
|
|
const address = runningServer.httpServer.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Expected a TCP server address");
|
|
}
|
|
expect(address.address).toBe("127.0.0.1");
|
|
});
|
|
|
|
it("reports process liveness without access checks", async () => {
|
|
const baseUrl = await start();
|
|
const response = await fetch(`${baseUrl}/healthz`, {
|
|
headers: { Origin: "https://foreign.test" },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(await response.json()).toEqual({ status: "ok" });
|
|
});
|
|
|
|
it("only accepts GET health checks", async () => {
|
|
const baseUrl = await start();
|
|
const response = await fetch(`${baseUrl}/healthz`, { method: "POST" });
|
|
expect(response.status).toBe(405);
|
|
expect(response.headers.get("allow")).toBe("GET");
|
|
});
|
|
});
|