432 lines
13 KiB
TypeScript
432 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,
|
|
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");
|
|
});
|
|
});
|