diff --git a/Overlord-Server/src/clientManager.test.ts b/Overlord-Server/src/clientManager.test.ts new file mode 100644 index 0000000..8228fc6 --- /dev/null +++ b/Overlord-Server/src/clientManager.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { + addClient, + getClient, + deleteClient, + hasClient, + getAllClients, + getClientCount, + getOnlineClients, +} from "./clientManager"; +import type { ClientInfo } from "./types"; + +function makeClient(id: string, overrides: Partial = {}): ClientInfo { + return { + id, + lastSeen: Date.now(), + role: "client", + ws: { send: () => {}, close: () => {} }, + ...overrides, + }; +} + +function uniqueId(): string { + return `test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +describe("clientManager", () => { + const ids: string[] = []; + + function tracked(id: string) { + ids.push(id); + return id; + } + + afterEach(() => { + for (const id of ids) deleteClient(id); + ids.length = 0; + }); + + test("addClient + getClient round-trip", () => { + const id = tracked(uniqueId()); + const info = makeClient(id); + addClient(id, info); + expect(getClient(id)).toBe(info); + }); + + test("getClient returns undefined for unknown id", () => { + expect(getClient("nonexistent-client-xyz")).toBeUndefined(); + }); + + test("hasClient returns true/false correctly", () => { + const id = tracked(uniqueId()); + expect(hasClient(id)).toBe(false); + addClient(id, makeClient(id)); + expect(hasClient(id)).toBe(true); + }); + + test("deleteClient removes and returns true", () => { + const id = tracked(uniqueId()); + addClient(id, makeClient(id)); + expect(deleteClient(id)).toBe(true); + expect(hasClient(id)).toBe(false); + }); + + test("deleteClient returns false for unknown id", () => { + expect(deleteClient("nonexistent-client-xyz")).toBe(false); + }); + + test("getClientCount reflects add/delete", () => { + const before = getClientCount(); + const id = tracked(uniqueId()); + addClient(id, makeClient(id)); + expect(getClientCount()).toBe(before + 1); + deleteClient(id); + expect(getClientCount()).toBe(before); + }); + + test("getAllClients returns the backing map", () => { + const id = tracked(uniqueId()); + addClient(id, makeClient(id)); + const all = getAllClients(); + expect(all instanceof Map).toBe(true); + expect(all.has(id)).toBe(true); + }); + + test("addClient overwrites existing entry", () => { + const id = tracked(uniqueId()); + const first = makeClient(id, { host: "first" }); + const second = makeClient(id, { host: "second" }); + addClient(id, first); + addClient(id, second); + expect(getClient(id)?.host).toBe("second"); + }); + + test("getOnlineClients only returns recently-seen clients", () => { + const freshId = tracked(uniqueId()); + const staleId = tracked(uniqueId()); + + addClient(freshId, makeClient(freshId, { lastSeen: Date.now() })); + addClient(staleId, makeClient(staleId, { lastSeen: Date.now() - 120_000 })); + + const online = getOnlineClients(); + const onlineIds = online.map((c) => c.id); + expect(onlineIds).toContain(freshId); + expect(onlineIds).not.toContain(staleId); + }); + + test("getOnlineClients returns empty when no clients are recent", () => { + const id = tracked(uniqueId()); + addClient(id, makeClient(id, { lastSeen: Date.now() - 120_000 })); + const online = getOnlineClients(); + expect(online.find((c) => c.id === id)).toBeUndefined(); + }); +}); diff --git a/Overlord-Server/src/httpHandlers.test.ts b/Overlord-Server/src/httpHandlers.test.ts new file mode 100644 index 0000000..73fbfe5 --- /dev/null +++ b/Overlord-Server/src/httpHandlers.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { handleCommand, handleClientsRequest } from "./httpHandlers"; +import * as clientManager from "./clientManager"; +import type { ClientInfo } from "./types"; + +function makeClient(id: string, overrides: Partial = {}): ClientInfo { + const sent: any[] = []; + return { + id, + lastSeen: Date.now(), + role: "client", + ws: { + send(data: any) { sent.push(data); }, + close() {}, + _sent: sent, + }, + ...overrides, + }; +} + +function uniqueId(): string { + return `hh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +describe("handleCommand", () => { + const ids: string[] = []; + + afterEach(() => { + for (const id of ids) clientManager.deleteClient(id); + ids.length = 0; + }); + + function tracked(id: string) { + ids.push(id); + return id; + } + + test("ping sets lastPingSent and lastPingNonce on the target", () => { + const id = tracked(uniqueId()); + const client = makeClient(id); + clientManager.addClient(id, client); + + const req = new Request("https://localhost/api/command?action=ping"); + const res = handleCommand(client, "ping", req); + expect(res.status).toBe(200); + expect(client.lastPingSent).toBeGreaterThan(0); + expect(typeof client.lastPingNonce).toBe("number"); + expect((client.ws as any)._sent.length).toBe(1); + }); + + test("simple commands return ok and send message", () => { + for (const action of ["desktop_start", "desktop_stop", "disconnect", "reconnect"]) { + const id = tracked(uniqueId()); + const client = makeClient(id); + + const req = new Request(`https://localhost/api/command?action=${action}`); + const res = handleCommand(client, action, req); + expect(res.status).toBe(200); + expect((client.ws as any)._sent.length).toBe(1); + } + }); + + test("payload commands return ok", () => { + for (const action of ["desktop_select_display", "desktop_enable_mouse", "desktop_enable_keyboard"]) { + const id = tracked(uniqueId()); + const client = makeClient(id); + + const req = new Request(`https://localhost/api/command?action=${action}`); + const res = handleCommand(client, action, req); + expect(res.status).toBe(200); + expect((client.ws as any)._sent.length).toBe(1); + } + }); + + test("file commands pass path from query string", () => { + for (const action of ["file_list", "file_download", "file_delete", "file_mkdir", "file_zip"]) { + const id = tracked(uniqueId()); + const client = makeClient(id); + + const req = new Request(`https://localhost/api/command?action=${action}&path=C%3A%5CUsers`); + const res = handleCommand(client, action, req); + expect(res.status).toBe(200); + expect((client.ws as any)._sent.length).toBe(1); + } + }); + + test("unknown action returns 400", () => { + const id = tracked(uniqueId()); + const client = makeClient(id); + + const req = new Request("https://localhost/api/command?action=bogus_action"); + const res = handleCommand(client, "bogus_action", req); + expect(res.status).toBe(400); + expect((client.ws as any)._sent.length).toBe(0); + }); +}); + +describe("handleClientsRequest", () => { + test("returns valid JSON with expected shape", () => { + const req = new Request("https://localhost/api/clients?page=1&pageSize=5"); + const res = handleClientsRequest(req); + expect(res.status).toBe(200); + const ct = res.headers.get("content-type") || ""; + expect(ct).toContain("json"); + }); +}); diff --git a/Overlord-Server/src/server/agent-auth.test.ts b/Overlord-Server/src/server/agent-auth.test.ts new file mode 100644 index 0000000..36d1835 --- /dev/null +++ b/Overlord-Server/src/server/agent-auth.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { isAuthorizedAgentRequest } from "./agent-auth"; + +function makeReq(headers: Record = {}): Request { + return new Request("https://localhost/ws", { headers }); +} + +function makeUrl(query: Record = {}): URL { + const u = new URL("https://localhost/ws"); + for (const [k, v] of Object.entries(query)) u.searchParams.set(k, v); + return u; +} + +describe("isAuthorizedAgentRequest", () => { + const originalEnv: Record = {}; + + function setEnv(key: string, value: string | undefined) { + if (!(key in originalEnv)) originalEnv[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + + afterEach(() => { + for (const [k, v] of Object.entries(originalEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + for (const k of Object.keys(originalEnv)) delete originalEnv[k]; + }); + + test("returns true when agentToken is empty/undefined", () => { + expect(isAuthorizedAgentRequest(makeReq(), makeUrl(), "")).toBe(true); + expect(isAuthorizedAgentRequest(makeReq(), makeUrl(), undefined)).toBe(true); + }); + + test("authenticates via x-agent-token header", () => { + const token = "secret-agent-token-abc123"; + const req = makeReq({ "x-agent-token": token }); + expect(isAuthorizedAgentRequest(req, makeUrl(), token)).toBe(true); + }); + + test("authenticates via query string token", () => { + const token = "secret-agent-token-abc123"; + const url = makeUrl({ token }); + expect(isAuthorizedAgentRequest(makeReq(), url, token)).toBe(true); + }); + + test("rejects wrong header token", () => { + const req = makeReq({ "x-agent-token": "wrong" }); + expect(isAuthorizedAgentRequest(req, makeUrl(), "correct-token")).toBe(false); + }); + + test("rejects wrong query token", () => { + const url = makeUrl({ token: "wrong" }); + expect(isAuthorizedAgentRequest(makeReq(), url, "correct-token")).toBe(false); + }); + + test("rejects when no token is provided but agentToken is set", () => { + expect(isAuthorizedAgentRequest(makeReq(), makeUrl(), "required-token")).toBe(false); + }); + + test("OVERLORD_DISABLE_AGENT_AUTH bypasses in non-production", () => { + setEnv("OVERLORD_DISABLE_AGENT_AUTH", "true"); + setEnv("NODE_ENV", "development"); + expect(isAuthorizedAgentRequest(makeReq(), makeUrl(), "secret")).toBe(true); + }); + + test("OVERLORD_DISABLE_AGENT_AUTH is ignored in production", () => { + setEnv("OVERLORD_DISABLE_AGENT_AUTH", "true"); + setEnv("NODE_ENV", "production"); + expect(isAuthorizedAgentRequest(makeReq(), makeUrl(), "secret")).toBe(false); + }); + + test("header token takes priority when both header and query are present", () => { + const token = "the-real-token"; + const req = makeReq({ "x-agent-token": token }); + const url = makeUrl({ token: "wrong-query-token" }); + expect(isAuthorizedAgentRequest(req, url, token)).toBe(true); + }); +}); diff --git a/Overlord-Server/src/server/http-security.test.ts b/Overlord-Server/src/server/http-security.test.ts new file mode 100644 index 0000000..dccc516 --- /dev/null +++ b/Overlord-Server/src/server/http-security.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { CORS_HEADERS, SECURITY_HEADERS } from "./http-security"; + +describe("CORS_HEADERS", () => { + test("has restrictive Access-Control-Allow-Origin", () => { + expect(CORS_HEADERS["Access-Control-Allow-Origin"]).toBe("null"); + }); + + test("limits allowed methods", () => { + expect(CORS_HEADERS["Access-Control-Allow-Methods"]).toContain("GET"); + expect(CORS_HEADERS["Access-Control-Allow-Methods"]).toContain("OPTIONS"); + }); + + test("allows Content-Type header", () => { + expect(CORS_HEADERS["Access-Control-Allow-Headers"]).toContain("Content-Type"); + }); +}); + +describe("SECURITY_HEADERS", () => { + test("sets Content-Security-Policy with self default-src", () => { + const csp = SECURITY_HEADERS["Content-Security-Policy"]; + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("script-src 'self'"); + expect(csp).toContain("frame-ancestors 'none'"); + }); + + test("prevents MIME sniffing", () => { + expect(SECURITY_HEADERS["X-Content-Type-Options"]).toBe("nosniff"); + }); + + test("denies framing", () => { + expect(SECURITY_HEADERS["X-Frame-Options"]).toBe("DENY"); + }); + + test("enables XSS protection", () => { + expect(SECURITY_HEADERS["X-XSS-Protection"]).toBe("1; mode=block"); + }); + + test("sets strict referrer policy", () => { + expect(SECURITY_HEADERS["Referrer-Policy"]).toBe("strict-origin-when-cross-origin"); + }); + + test("enables HSTS with long max-age", () => { + const hsts = SECURITY_HEADERS["Strict-Transport-Security"]; + expect(hsts).toContain("max-age="); + expect(hsts).toContain("includeSubDomains"); + const maxAge = Number(hsts.match(/max-age=(\d+)/)?.[1] || 0); + expect(maxAge).toBeGreaterThanOrEqual(31536000); + }); + + test("disables camera, geolocation, payment via Permissions-Policy", () => { + const pp = SECURITY_HEADERS["Permissions-Policy"]; + expect(pp).toContain("camera=()"); + expect(pp).toContain("geolocation=()"); + expect(pp).toContain("payment=()"); + }); +}); diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index a405503..3208f63 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -1058,8 +1058,13 @@ export async function handlePluginRoutes( injected = raw.replace(headMatch[0], `${headMatch[0]}\n ${baseTag}`); } + const etag = `"${file.size.toString(36)}-${file.lastModified.toString(36)}"`; + if (req.headers.get("if-none-match") === etag) { + return new Response(null, { status: 304 }); + } + return new Response(injected, { - headers: deps.secureHeaders("text/html; charset=utf-8"), + headers: { ...deps.secureHeaders("text/html; charset=utf-8"), "Cache-Control": "no-cache", ETag: etag }, }); } @@ -1089,10 +1094,19 @@ export async function handlePluginRoutes( const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`); const file = Bun.file(htmlFile); + const htmlExists = await file.exists(); + + const etag = htmlExists + ? `"page-${file.size.toString(36)}-${file.lastModified.toString(36)}"` + : `"page-empty"`; + if (req.headers.get("if-none-match") === etag) { + return new Response(null, { status: 304 }); + } + let pluginBody = ""; let pluginHeadExtras = ""; - if (await file.exists()) { + if (htmlExists) { const raw = await file.text(); const bodyMatch = raw.match(/]*>([\s\S]*?)<\/body>/i); @@ -1143,7 +1157,9 @@ export async function handlePluginRoutes( `; - return new Response(html, { headers: deps.secureHeaders("text/html; charset=utf-8") }); + return new Response(html, { + headers: { ...deps.secureHeaders("text/html; charset=utf-8"), "Cache-Control": "no-cache", ETag: etag }, + }); } const pluginAssetMatch = url.pathname.match(/^\/plugins\/([^/]+)\/assets\/(.+)$/); @@ -1180,7 +1196,13 @@ export async function handlePluginRoutes( const file = Bun.file(resolvedPath); if (await file.exists()) { - return new Response(file, { headers: deps.secureHeaders(deps.mimeType(assetPath)) }); + const etag = `"${file.size.toString(36)}-${file.lastModified.toString(36)}"`; + if (req.headers.get("if-none-match") === etag) { + return new Response(null, { status: 304 }); + } + return new Response(file, { + headers: { ...deps.secureHeaders(deps.mimeType(assetPath)), "Cache-Control": "no-cache", ETag: etag }, + }); } return new Response("Not found", { status: 404 }); } diff --git a/Overlord-Server/src/server/stale-prune.test.ts b/Overlord-Server/src/server/stale-prune.test.ts new file mode 100644 index 0000000..1e96d80 --- /dev/null +++ b/Overlord-Server/src/server/stale-prune.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import { pruneStaleClients } from "./stale-prune"; +import type { ClientInfo } from "../types"; + +function makeClient(id: string, lastSeen: number, role: "client" | "viewer" = "client"): ClientInfo { + const calls: string[] = []; + return { + id, + lastSeen, + role, + ws: { + close(...args: any[]) { calls.push(`close:${args.join(",")}`); }, + send() {}, + _calls: calls, + }, + } as any; +} + +describe("pruneStaleClients", () => { + test("does nothing when all clients are fresh", () => { + const clients = new Map(); + clients.set("a", makeClient("a", Date.now())); + + const offlined: string[] = []; + const deleted: string[] = []; + + pruneStaleClients({ + clients, + staleMs: 60_000, + pruneBatch: 10, + setOnlineState: (id, online) => { if (!online) offlined.push(id); }, + deleteClient: (id) => { deleted.push(id); }, + }); + + expect(offlined).toEqual([]); + expect(deleted).toEqual([]); + }); + + test("closes and marks offline stale role=client entries without deleting", () => { + const clients = new Map(); + const staleClient = makeClient("stale-c", Date.now() - 120_000, "client"); + clients.set("stale-c", staleClient); + + const offlined: string[] = []; + const deleted: string[] = []; + + pruneStaleClients({ + clients, + staleMs: 60_000, + pruneBatch: 10, + setOnlineState: (id, online) => { if (!online) offlined.push(id); }, + deleteClient: (id) => { deleted.push(id); }, + }); + + expect(offlined).toContain("stale-c"); + expect(deleted).not.toContain("stale-c"); + expect((staleClient.ws as any)._calls[0]).toStartWith("close:"); + }); + + test("closes and deletes stale non-client (viewer) entries", () => { + const clients = new Map(); + const viewer = makeClient("stale-v", Date.now() - 120_000, "viewer"); + clients.set("stale-v", viewer); + + const offlined: string[] = []; + const deleted: string[] = []; + + pruneStaleClients({ + clients, + staleMs: 60_000, + pruneBatch: 10, + setOnlineState: (id, online) => { if (!online) offlined.push(id); }, + deleteClient: (id) => { deleted.push(id); }, + }); + + expect(deleted).toContain("stale-v"); + expect(offlined).toContain("stale-v"); + }); + + test("respects pruneBatch limit", () => { + const clients = new Map(); + for (let i = 0; i < 5; i++) { + clients.set(`s-${i}`, makeClient(`s-${i}`, Date.now() - 200_000, "viewer")); + } + + let pruned = 0; + + pruneStaleClients({ + clients, + staleMs: 60_000, + pruneBatch: 2, + setOnlineState: () => { pruned++; }, + deleteClient: () => {}, + }); + + expect(pruned).toBe(2); + }); + + test("mixes fresh and stale correctly", () => { + const clients = new Map(); + clients.set("fresh", makeClient("fresh", Date.now())); + clients.set("stale", makeClient("stale", Date.now() - 200_000, "viewer")); + + const offlined: string[] = []; + const deleted: string[] = []; + + pruneStaleClients({ + clients, + staleMs: 60_000, + pruneBatch: 10, + setOnlineState: (id, online) => { if (!online) offlined.push(id); }, + deleteClient: (id) => { deleted.push(id); }, + }); + + expect(offlined).toContain("stale"); + expect(offlined).not.toContain("fresh"); + expect(deleted).toContain("stale"); + expect(deleted).not.toContain("fresh"); + }); +});