more tests + half cache plugins
This commit is contained in:
@@ -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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, test, afterEach } from "bun:test";
|
||||||
|
import { isAuthorizedAgentRequest } from "./agent-auth";
|
||||||
|
|
||||||
|
function makeReq(headers: Record<string, string> = {}): Request {
|
||||||
|
return new Request("https://localhost/ws", { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUrl(query: Record<string, string> = {}): 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<string, string | undefined> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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=()");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1058,8 +1058,13 @@ export async function handlePluginRoutes(
|
|||||||
injected = raw.replace(headMatch[0], `${headMatch[0]}\n ${baseTag}`);
|
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, {
|
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 htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`);
|
||||||
const file = Bun.file(htmlFile);
|
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 pluginBody = "";
|
||||||
let pluginHeadExtras = "";
|
let pluginHeadExtras = "";
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (htmlExists) {
|
||||||
const raw = await file.text();
|
const raw = await file.text();
|
||||||
|
|
||||||
const bodyMatch = raw.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
const bodyMatch = raw.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
||||||
@@ -1143,7 +1157,9 @@ export async function handlePluginRoutes(
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
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\/(.+)$/);
|
const pluginAssetMatch = url.pathname.match(/^\/plugins\/([^/]+)\/assets\/(.+)$/);
|
||||||
@@ -1180,7 +1196,13 @@ export async function handlePluginRoutes(
|
|||||||
|
|
||||||
const file = Bun.file(resolvedPath);
|
const file = Bun.file(resolvedPath);
|
||||||
if (await file.exists()) {
|
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 });
|
return new Response("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, ClientInfo>();
|
||||||
|
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<string, ClientInfo>();
|
||||||
|
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<string, ClientInfo>();
|
||||||
|
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<string, ClientInfo>();
|
||||||
|
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<string, ClientInfo>();
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user