forked from retoor/devplacepy
Zo doen wijdat in oost
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Fetcher } from "../src/Fetcher.js";
|
||||
import { Limits } from "../src/Limits.js";
|
||||
import { Policy } from "../src/Policy.js";
|
||||
import { Usage } from "../src/Usage.js";
|
||||
import { MAX_REDIRECTS, REFUSALS } from "../src/spec.js";
|
||||
|
||||
function memoryArea() {
|
||||
const store = {};
|
||||
return {
|
||||
async get(key) {
|
||||
return key in store ? { [key]: store[key] } : {};
|
||||
},
|
||||
async set(values) {
|
||||
Object.assign(store, values);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function build(fetchImpl, local = {}) {
|
||||
const limits = new Limits({ local });
|
||||
const usage = new Usage(memoryArea());
|
||||
const fetcher = new Fetcher({ policy: new Policy(), limits, usage, fetchImpl });
|
||||
return { fetcher, limits, usage };
|
||||
}
|
||||
|
||||
const job = (url = "https://www.reddit.com/r/x.json", extra = {}) => ({
|
||||
job_id: "j1",
|
||||
method: "GET",
|
||||
url,
|
||||
timeout: 5,
|
||||
...extra,
|
||||
});
|
||||
|
||||
function response(body, init = {}) {
|
||||
return new Response(body, { status: 200, ...init });
|
||||
}
|
||||
|
||||
test("success returns a job_result with base64 body and no donor credentials", async () => {
|
||||
const calls = [];
|
||||
const { fetcher, usage } = build(async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return response("hello", { headers: { "content-type": "text/plain", "set-cookie": "a=b" } });
|
||||
});
|
||||
const result = await fetcher.execute(
|
||||
job(undefined, { headers: { Cookie: "x=1", Authorization: "Bearer z", Accept: "text/html" } }),
|
||||
);
|
||||
assert.equal(result.type, "job_result");
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(Buffer.from(result.body_b64, "base64").toString(), "hello");
|
||||
assert.equal(result.truncated, false);
|
||||
assert.equal(result.headers["content-type"], "text/plain");
|
||||
assert.equal("set-cookie" in result.headers, false);
|
||||
assert.equal(calls[0].options.credentials, "omit");
|
||||
assert.equal(calls[0].options.redirect, "manual");
|
||||
assert.deepEqual(calls[0].options.headers, { accept: "text/html" });
|
||||
assert.equal(usage.snapshot().requests, 1);
|
||||
assert.equal(usage.snapshot().bytesDown, 5);
|
||||
});
|
||||
|
||||
test("a disallowed url never reaches fetch", async () => {
|
||||
let called = 0;
|
||||
const { fetcher, limits } = build(async () => {
|
||||
called += 1;
|
||||
return response("x");
|
||||
});
|
||||
for (const url of ["https://evilreddit.com/", "http://reddit.com/", "https://reddit.com@evil.net/"]) {
|
||||
const result = await fetcher.execute(job(url));
|
||||
assert.equal(result.type, "job_error");
|
||||
assert.equal(result.code, REFUSALS.HOST_NOT_ALLOWED);
|
||||
}
|
||||
assert.equal(called, 0);
|
||||
assert.equal(limits.inflight, 0);
|
||||
});
|
||||
|
||||
test("body over the cap is truncated and flagged", async () => {
|
||||
const big = "a".repeat(5000);
|
||||
const { fetcher, limits } = build(async () => response(big));
|
||||
limits.caps.max_body_bytes = 1000;
|
||||
const result = await fetcher.execute(job());
|
||||
assert.equal(result.type, "job_result");
|
||||
assert.equal(result.truncated, true);
|
||||
assert.equal(Buffer.from(result.body_b64, "base64").length, 1000);
|
||||
});
|
||||
|
||||
test("the local bandwidth budget also bounds the body", async () => {
|
||||
const { fetcher, usage } = build(async () => response("b".repeat(3 * 1024 * 1024)), { bandwidthMb: 1 });
|
||||
const result = await fetcher.execute(job());
|
||||
assert.equal(result.truncated, true);
|
||||
assert.ok(Buffer.from(result.body_b64, "base64").length < 1024 * 1024);
|
||||
assert.ok(usage.usedBytes <= 1024 * 1024);
|
||||
});
|
||||
|
||||
test("exhausted bandwidth refuses before fetching", async () => {
|
||||
let called = 0;
|
||||
const { fetcher, usage } = build(
|
||||
async () => {
|
||||
called += 1;
|
||||
return response("x");
|
||||
},
|
||||
{ bandwidthMb: 1 },
|
||||
);
|
||||
await usage.record({ ok: true, up: 0, down: 1024 * 1024 });
|
||||
const result = await fetcher.execute(job());
|
||||
assert.equal(result.code, REFUSALS.BANDWIDTH_EXHAUSTED);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
|
||||
test("concurrency cap refuses the extra job", async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { fetcher } = build(
|
||||
async () => {
|
||||
await gate;
|
||||
return response("x");
|
||||
},
|
||||
{ maxConcurrent: 1 },
|
||||
);
|
||||
const first = fetcher.execute(job());
|
||||
const second = await fetcher.execute(job());
|
||||
assert.equal(second.code, REFUSALS.CONCURRENCY_EXCEEDED);
|
||||
release();
|
||||
assert.equal((await first).type, "job_result");
|
||||
});
|
||||
|
||||
test("timeout aborts the request and reports timeout", async () => {
|
||||
const { fetcher, limits } = build(
|
||||
(url, options) =>
|
||||
new Promise((resolve, reject) => {
|
||||
options.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
||||
}),
|
||||
);
|
||||
limits.caps.job_timeout_seconds = 1;
|
||||
const started = Date.now();
|
||||
const result = await fetcher.execute(job(undefined, { timeout: 1 }));
|
||||
assert.equal(result.code, REFUSALS.TIMEOUT);
|
||||
assert.ok(Date.now() - started < 3000);
|
||||
assert.equal(limits.inflight, 0);
|
||||
});
|
||||
|
||||
test("a redirect leaving the allowlist is refused and not followed", async () => {
|
||||
const urls = [];
|
||||
const { fetcher } = build(async (url) => {
|
||||
urls.push(url);
|
||||
return response("", { status: 302, headers: { location: "https://evil.net/steal" } });
|
||||
});
|
||||
const result = await fetcher.execute(job());
|
||||
assert.equal(result.type, "job_error");
|
||||
assert.equal(result.code, REFUSALS.REDIRECT_NOT_ALLOWED);
|
||||
assert.equal(urls.length, 1);
|
||||
});
|
||||
|
||||
test("an opaque redirect fails closed", async () => {
|
||||
const opaque = { type: "opaqueredirect", status: 0, headers: new Headers(), body: null };
|
||||
const { fetcher } = build(async () => opaque);
|
||||
const result = await fetcher.execute(job());
|
||||
assert.equal(result.code, REFUSALS.REDIRECT_NOT_ALLOWED);
|
||||
});
|
||||
|
||||
test("a same-allowlist redirect is followed, with the policy checked on every hop", async () => {
|
||||
const urls = [];
|
||||
const { fetcher } = build(async (url) => {
|
||||
urls.push(url);
|
||||
if (urls.length === 1) {
|
||||
return response("", { status: 301, headers: { location: "https://old.reddit.com/r/x" } });
|
||||
}
|
||||
return response("final");
|
||||
});
|
||||
const result = await fetcher.execute(job());
|
||||
assert.equal(result.type, "job_result");
|
||||
assert.equal(Buffer.from(result.body_b64, "base64").toString(), "final");
|
||||
assert.deepEqual(urls, ["https://www.reddit.com/r/x.json", "https://old.reddit.com/r/x"]);
|
||||
});
|
||||
|
||||
test("redirect loops are bounded", async () => {
|
||||
let count = 0;
|
||||
const { fetcher } = build(async () => {
|
||||
count += 1;
|
||||
return response("", { status: 302, headers: { location: "https://www.reddit.com/loop" } });
|
||||
});
|
||||
const result = await fetcher.execute(job());
|
||||
assert.equal(result.code, REFUSALS.REDIRECT_NOT_ALLOWED);
|
||||
assert.equal(count, MAX_REDIRECTS + 1);
|
||||
});
|
||||
|
||||
test("a network failure is a loud job_error", async () => {
|
||||
const { fetcher, usage } = build(async () => {
|
||||
throw new TypeError("network down");
|
||||
});
|
||||
const result = await fetcher.execute(job());
|
||||
assert.equal(result.type, "job_error");
|
||||
assert.equal(result.code, "fetch_failed");
|
||||
assert.equal(usage.snapshot().failed, 1);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Limits } from "../src/Limits.js";
|
||||
import { LIMITS, REFUSALS } from "../src/spec.js";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function at(hour, minute = 0) {
|
||||
return () => new Date(2026, 0, 15, hour, minute);
|
||||
}
|
||||
|
||||
test("defaults come from the baked spec", () => {
|
||||
const limits = new Limits();
|
||||
assert.equal(limits.concurrency, LIMITS.max_concurrent_per_node);
|
||||
assert.equal(limits.bandwidthBytes, LIMITS.default_donor_mb_per_day * MB);
|
||||
assert.equal(limits.maxBodyBytes, LIMITS.max_body_bytes);
|
||||
});
|
||||
|
||||
test("local settings are clamped", () => {
|
||||
const limits = new Limits({ local: { bandwidthMb: 10 ** 9, maxConcurrent: 99 } });
|
||||
assert.equal(limits.local.bandwidthMb, LIMITS.max_donor_mb_per_day);
|
||||
assert.equal(limits.local.maxConcurrent, LIMITS.max_concurrent_per_node);
|
||||
limits.setLocal({ bandwidthMb: -5, maxConcurrent: 0 });
|
||||
assert.equal(limits.local.bandwidthMb, LIMITS.default_donor_mb_per_day);
|
||||
assert.equal(limits.local.maxConcurrent, LIMITS.max_concurrent_per_node);
|
||||
limits.setLocal({ activeFrom: "25:99", activeTo: "10:00" });
|
||||
assert.equal(limits.local.activeFrom, "");
|
||||
});
|
||||
|
||||
test("a welcome can lower caps and never raise them", () => {
|
||||
const limits = new Limits();
|
||||
limits.applyWelcome(
|
||||
{
|
||||
max_body_bytes: 10 ** 9,
|
||||
max_concurrent_per_node: 1,
|
||||
job_timeout_seconds: 5,
|
||||
ping_seconds: 999,
|
||||
reconnect_min_seconds: 2,
|
||||
reconnect_max_seconds: 10 ** 6,
|
||||
},
|
||||
{ bandwidth_mb_day: 5, max_concurrent: 99 },
|
||||
);
|
||||
assert.equal(limits.maxBodyBytes, LIMITS.max_body_bytes);
|
||||
assert.equal(limits.concurrency, 1);
|
||||
assert.equal(limits.timeoutSeconds(25), 5);
|
||||
assert.equal(limits.pingSeconds, LIMITS.ping_seconds);
|
||||
assert.equal(limits.bandwidthBytes, 5 * MB);
|
||||
assert.equal(limits.reconnectMin, 2);
|
||||
assert.equal(limits.reconnectMax, LIMITS.reconnect_max_seconds);
|
||||
});
|
||||
|
||||
test("concurrency cap admits and releases", () => {
|
||||
const limits = new Limits({ local: { maxConcurrent: 1 } });
|
||||
assert.equal(limits.admit(0), null);
|
||||
assert.equal(limits.admit(0), REFUSALS.CONCURRENCY_EXCEEDED);
|
||||
limits.release();
|
||||
assert.equal(limits.admit(0), null);
|
||||
limits.release();
|
||||
limits.release();
|
||||
assert.equal(limits.inflight, 0);
|
||||
});
|
||||
|
||||
test("bandwidth cap refuses when used up", () => {
|
||||
const limits = new Limits({ local: { bandwidthMb: 1 } });
|
||||
assert.equal(limits.admit(MB - 1), null);
|
||||
limits.release();
|
||||
assert.equal(limits.admit(MB), REFUSALS.BANDWIDTH_EXHAUSTED);
|
||||
assert.equal(limits.remainingBytes(2 * MB), 0);
|
||||
});
|
||||
|
||||
test("active hours, including a window that wraps midnight", () => {
|
||||
const day = new Limits({ clock: at(12), local: { activeFrom: "09:00", activeTo: "17:00" } });
|
||||
assert.equal(day.admit(0), null);
|
||||
const early = new Limits({ clock: at(8, 59), local: { activeFrom: "09:00", activeTo: "17:00" } });
|
||||
assert.equal(early.admit(0), REFUSALS.OUTSIDE_HOURS);
|
||||
const end = new Limits({ clock: at(17), local: { activeFrom: "09:00", activeTo: "17:00" } });
|
||||
assert.equal(end.admit(0), REFUSALS.OUTSIDE_HOURS);
|
||||
const wrapIn = new Limits({ clock: at(23, 30), local: { activeFrom: "22:00", activeTo: "06:00" } });
|
||||
assert.equal(wrapIn.inActiveHours(), true);
|
||||
const wrapEarly = new Limits({ clock: at(3), local: { activeFrom: "22:00", activeTo: "06:00" } });
|
||||
assert.equal(wrapEarly.inActiveHours(), true);
|
||||
const wrapOut = new Limits({ clock: at(12), local: { activeFrom: "22:00", activeTo: "06:00" } });
|
||||
assert.equal(wrapOut.inActiveHours(), false);
|
||||
const always = new Limits({ clock: at(12) });
|
||||
assert.equal(always.inActiveHours(), true);
|
||||
});
|
||||
|
||||
test("hello limits advertise the effective concurrency", () => {
|
||||
const limits = new Limits({ local: { maxConcurrent: 1 } });
|
||||
assert.deepEqual(limits.helloLimits(), { max_concurrent: 1 });
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Policy } from "../src/Policy.js";
|
||||
import { DESTINATIONS, REFUSALS } from "../src/spec.js";
|
||||
|
||||
const policy = new Policy();
|
||||
|
||||
test("allows https on the allowlisted hosts and subdomains", () => {
|
||||
for (const url of [
|
||||
"https://reddit.com/r/python.json",
|
||||
"https://www.reddit.com/r/python.json",
|
||||
"https://old.reddit.com/",
|
||||
"https://redd.it/abc",
|
||||
"https://REDDIT.COM./x",
|
||||
]) {
|
||||
assert.equal(policy.check("GET", url).ok, true, url);
|
||||
}
|
||||
});
|
||||
|
||||
test("suffix matching is dot safe", () => {
|
||||
for (const url of [
|
||||
"https://evilreddit.com/",
|
||||
"https://reddit.com.evil.net/",
|
||||
"https://notredd.it/",
|
||||
"https://reddit.co/",
|
||||
]) {
|
||||
const verdict = policy.check("GET", url);
|
||||
assert.equal(verdict.ok, false, url);
|
||||
assert.equal(verdict.code, REFUSALS.HOST_NOT_ALLOWED);
|
||||
}
|
||||
});
|
||||
|
||||
test("userinfo tricks are refused", () => {
|
||||
for (const url of [
|
||||
"https://reddit.com@evil.net/",
|
||||
"https://user:pass@reddit.com/",
|
||||
"https://evil.net\\@reddit.com/",
|
||||
]) {
|
||||
assert.equal(policy.check("GET", url).ok, false, url);
|
||||
}
|
||||
});
|
||||
|
||||
test("non https schemes are refused", () => {
|
||||
for (const url of ["http://reddit.com/", "ftp://reddit.com/", "ws://reddit.com/", "file:///etc/passwd"]) {
|
||||
const verdict = policy.check("GET", url);
|
||||
assert.equal(verdict.ok, false, url);
|
||||
}
|
||||
});
|
||||
|
||||
test("only port 443 passes over https", () => {
|
||||
assert.equal(policy.check("GET", "https://reddit.com:443/").ok, true);
|
||||
for (const url of ["https://reddit.com:80/", "https://reddit.com:8443/", "https://reddit.com:22/"]) {
|
||||
const verdict = policy.check("GET", url);
|
||||
assert.equal(verdict.ok, false, url);
|
||||
assert.equal(verdict.code, REFUSALS.PORT_NOT_ALLOWED);
|
||||
}
|
||||
});
|
||||
|
||||
test("method must be permitted", () => {
|
||||
const verdict = policy.check("POST", "https://reddit.com/");
|
||||
assert.equal(verdict.code, REFUSALS.METHOD_NOT_ALLOWED);
|
||||
assert.equal(policy.check("get", "https://reddit.com/").ok, true);
|
||||
});
|
||||
|
||||
test("ip literals, junk and oversize urls are refused", () => {
|
||||
for (const url of ["https://127.0.0.1/", "https://[::1]/", "https://10.0.0.1/", "nonsense", "", null, 5]) {
|
||||
assert.equal(policy.check("GET", url).ok, false, String(url));
|
||||
}
|
||||
assert.equal(policy.check("GET", "https://reddit.com/" + "a".repeat(3000)).ok, false);
|
||||
});
|
||||
|
||||
test("a redirect leaving the allowlist is refused with redirect_not_allowed", () => {
|
||||
const base = new URL("https://www.reddit.com/r/x");
|
||||
const bad = policy.checkRedirect("https://evil.net/landing", base, "GET");
|
||||
assert.equal(bad.ok, false);
|
||||
assert.equal(bad.code, REFUSALS.REDIRECT_NOT_ALLOWED);
|
||||
assert.equal(policy.checkRedirect("http://reddit.com/x", base, "GET").code, REFUSALS.REDIRECT_NOT_ALLOWED);
|
||||
assert.equal(policy.checkRedirect("//evilreddit.com/x", base, "GET").code, REFUSALS.REDIRECT_NOT_ALLOWED);
|
||||
assert.equal(policy.checkRedirect("/r/y", base, "GET").ok, true);
|
||||
assert.equal(policy.checkRedirect("https://old.reddit.com/r/y", base, "GET").ok, true);
|
||||
});
|
||||
|
||||
test("a refreshed allowlist may only be equal or narrower", () => {
|
||||
const narrower = [{ ...DESTINATIONS[0], suffix: "old.reddit.com" }];
|
||||
const kept = Policy.narrow(DESTINATIONS, narrower);
|
||||
assert.equal(kept.destinations.length, 1);
|
||||
assert.equal(kept.rejected.length, 0);
|
||||
|
||||
const equal = Policy.narrow(DESTINATIONS, DESTINATIONS.map((item) => ({ ...item })));
|
||||
assert.equal(equal.destinations.length, DESTINATIONS.length);
|
||||
|
||||
const widened = Policy.narrow(DESTINATIONS, [
|
||||
{ suffix: "evil.net", adapter: "x", methods: ["GET"], ports: [443], modes: ["fetch"] },
|
||||
{ suffix: "reddit.com", adapter: "reddit", methods: ["GET", "POST"], ports: [443], modes: ["fetch"] },
|
||||
{ suffix: "reddit.com", adapter: "reddit", methods: ["GET"], ports: [443, 8080], modes: ["fetch"] },
|
||||
{ suffix: "reddit.com", adapter: "reddit", methods: ["GET"], ports: [443], modes: ["tunnel"] },
|
||||
{ suffix: "evilreddit.com", adapter: "reddit", methods: ["GET"], ports: [443], modes: ["fetch"] },
|
||||
]);
|
||||
assert.equal(widened.destinations.length, 0);
|
||||
assert.equal(widened.rejected.length, 5);
|
||||
|
||||
const empty = Policy.narrow(DESTINATIONS, []);
|
||||
assert.equal(empty.destinations.length, 0);
|
||||
assert.equal(Policy.narrow(DESTINATIONS, "x").destinations.length, 0);
|
||||
});
|
||||
|
||||
test("a narrowed policy refuses what the baked policy would allow", () => {
|
||||
const { policy: narrow } = Policy.fromRefreshed(DESTINATIONS, [
|
||||
{ ...DESTINATIONS[0], suffix: "old.reddit.com" },
|
||||
]);
|
||||
assert.equal(narrow.check("GET", "https://old.reddit.com/").ok, true);
|
||||
assert.equal(narrow.check("GET", "https://www.reddit.com/").ok, false);
|
||||
assert.equal(narrow.check("GET", "https://redd.it/x").ok, false);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DESTINATIONS, LIMITS, REFUSALS, ALLOWED_PORTS, PROTOCOL_VERSION } from "../src/spec.js";
|
||||
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL("../../../devplacepy/services/nodes/spec.py", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function evaluate(expression) {
|
||||
const clean = expression.replaceAll("_", "").trim();
|
||||
assert.match(clean, /^[\d\s*]+$/, expression);
|
||||
return clean.split("*").reduce((product, part) => product * Number(part), 1);
|
||||
}
|
||||
|
||||
test("baked allowlist matches the server specification", () => {
|
||||
const suffixes = [...source.matchAll(/DestinationSpec\("([^"]+)"/g)].map((match) => match[1]);
|
||||
assert.deepEqual(DESTINATIONS.map((item) => item.suffix), suffixes);
|
||||
});
|
||||
|
||||
test("baked ports, protocol and refusal codes match the server specification", () => {
|
||||
const ports = /ALLOWED_PORTS = \(([^)]*)\)/.exec(source)[1].split(",").filter(Boolean).map(Number);
|
||||
assert.deepEqual([...ALLOWED_PORTS], ports);
|
||||
assert.equal(PROTOCOL_VERSION, Number(/PROTOCOL_VERSION = (\d+)/.exec(source)[1]));
|
||||
const codes = [...source.matchAll(/RefusalSpec\("([a-z_]+)"/g)].map((match) => match[1]);
|
||||
assert.deepEqual(Object.values(REFUSALS).sort(), codes.sort());
|
||||
});
|
||||
|
||||
test("baked limits match the server LimitSpec defaults", () => {
|
||||
for (const [key, value] of Object.entries(LIMITS)) {
|
||||
const pattern = new RegExp(`^\\s+${key}: int = ([\\d_ *]+)$`, "m");
|
||||
const match = pattern.exec(source);
|
||||
assert.ok(match, `${key} missing from spec.py`);
|
||||
assert.equal(value, evaluate(match[1]), key);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Fetcher } from "../src/Fetcher.js";
|
||||
import { Limits } from "../src/Limits.js";
|
||||
import { Policy } from "../src/Policy.js";
|
||||
import { State, STATES } from "../src/State.js";
|
||||
import { Usage } from "../src/Usage.js";
|
||||
import { WsClient } from "../src/WsClient.js";
|
||||
import { DESTINATIONS } from "../src/spec.js";
|
||||
|
||||
function memoryArea() {
|
||||
const store = {};
|
||||
return {
|
||||
async get(key) {
|
||||
return key in store ? { [key]: store[key] } : {};
|
||||
},
|
||||
async set(values) {
|
||||
Object.assign(store, values);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeSocket {
|
||||
static instances = [];
|
||||
|
||||
constructor(address) {
|
||||
this.address = address;
|
||||
this.readyState = 0;
|
||||
this.sent = [];
|
||||
this.closed = null;
|
||||
FakeSocket.instances.push(this);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = 1;
|
||||
this.onopen();
|
||||
}
|
||||
|
||||
send(text) {
|
||||
this.sent.push(JSON.parse(text));
|
||||
}
|
||||
|
||||
close(code, reason) {
|
||||
this.readyState = 3;
|
||||
this.closed = { code, reason };
|
||||
}
|
||||
|
||||
receive(frame) {
|
||||
return this.onmessage({ data: typeof frame === "string" ? frame : JSON.stringify(frame) });
|
||||
}
|
||||
|
||||
serverClose(code, reason = "") {
|
||||
this.readyState = 3;
|
||||
return this.onclose({ code, reason });
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTimers {
|
||||
constructor() {
|
||||
this.timeouts = [];
|
||||
this.intervals = [];
|
||||
}
|
||||
setTimeout(fn, ms) {
|
||||
const handle = { fn, ms, cleared: false };
|
||||
this.timeouts.push(handle);
|
||||
return handle;
|
||||
}
|
||||
clearTimeout(handle) {
|
||||
handle.cleared = true;
|
||||
}
|
||||
setInterval(fn, ms) {
|
||||
const handle = { fn, ms, cleared: false };
|
||||
this.intervals.push(handle);
|
||||
return handle;
|
||||
}
|
||||
clearInterval(handle) {
|
||||
handle.cleared = true;
|
||||
}
|
||||
}
|
||||
|
||||
function build(overrides = {}) {
|
||||
FakeSocket.instances = [];
|
||||
const timers = new FakeTimers();
|
||||
const limits = new Limits();
|
||||
const usage = new Usage(memoryArea());
|
||||
const state = new State(memoryArea());
|
||||
const fetchLog = [];
|
||||
const jobFetch = overrides.jobFetch || (async () => new Response("page", { status: 200 }));
|
||||
const fetcher = new Fetcher({ policy: new Policy(), limits, usage, fetchImpl: jobFetch });
|
||||
const ticketFetch =
|
||||
overrides.ticketFetch ||
|
||||
(async (url, options) => {
|
||||
fetchLog.push({ url, options });
|
||||
return new Response(JSON.stringify({ ticket: "tk1", expires_in: 30, ws_path: "/nodes/ws" }), {
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
const client = new WsClient({
|
||||
config: { credentials: () => ({ serverUrl: "https://devplace.net", secret: "dpn_secretsecretsecret" }) },
|
||||
limits,
|
||||
fetcher,
|
||||
state,
|
||||
fetchImpl: ticketFetch,
|
||||
WebSocketImpl: FakeSocket,
|
||||
timers,
|
||||
random: overrides.random || (() => 0.5),
|
||||
clientVersion: "9.9.9",
|
||||
platform: "firefox",
|
||||
});
|
||||
return { client, timers, state, fetcher, limits, usage, fetchLog };
|
||||
}
|
||||
|
||||
const welcome = (extra = {}) => ({
|
||||
type: "welcome",
|
||||
device_uid: "dev1",
|
||||
limits: { max_concurrent_per_node: 2, ping_seconds: 20 },
|
||||
destinations: DESTINATIONS.map((item) => ({ ...item })),
|
||||
settings: { bandwidth_mb_day: 500, max_concurrent: 2 },
|
||||
...extra,
|
||||
});
|
||||
|
||||
async function online(context) {
|
||||
await context.client.start();
|
||||
const socket = FakeSocket.instances.at(-1);
|
||||
socket.open();
|
||||
await socket.receive(welcome());
|
||||
return socket;
|
||||
}
|
||||
|
||||
test("start fetches a ticket with the bearer secret and opens the socket", async () => {
|
||||
const context = build();
|
||||
await context.client.start();
|
||||
assert.equal(context.fetchLog[0].url, "https://devplace.net/nodes/ticket");
|
||||
assert.equal(context.fetchLog[0].options.headers.Authorization, "Bearer dpn_secretsecretsecret");
|
||||
assert.equal(context.fetchLog[0].options.credentials, "omit");
|
||||
const socket = FakeSocket.instances[0];
|
||||
assert.equal(socket.address, "wss://devplace.net/nodes/ws?ticket=tk1");
|
||||
});
|
||||
|
||||
test("hello is sent on open with extension kind and fetch mode only", async () => {
|
||||
const context = build();
|
||||
await context.client.start();
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket.open();
|
||||
const hello = socket.sent[0];
|
||||
assert.equal(hello.type, "hello");
|
||||
assert.equal(hello.protocol, 1);
|
||||
assert.equal(hello.kind, "extension");
|
||||
assert.deepEqual(hello.modes, ["fetch"]);
|
||||
assert.equal(hello.client_version, "9.9.9");
|
||||
assert.equal(hello.platform, "firefox");
|
||||
assert.deepEqual(hello.limits, { max_concurrent: 2 });
|
||||
});
|
||||
|
||||
test("welcome puts the client online and starts the keepalive", async () => {
|
||||
const context = build();
|
||||
await online(context);
|
||||
assert.equal(context.state.snapshot().state, STATES.ONLINE);
|
||||
assert.equal(context.state.snapshot().deviceUid, "dev1");
|
||||
assert.equal(context.timers.intervals[0].ms, 20000);
|
||||
});
|
||||
|
||||
test("keepalive timer sends pong and a server ping is answered with pong", async () => {
|
||||
const context = build();
|
||||
const socket = await online(context);
|
||||
context.timers.intervals[0].fn();
|
||||
await socket.receive({ type: "ping" });
|
||||
assert.deepEqual(
|
||||
socket.sent.filter((frame) => frame.type === "pong"),
|
||||
[{ type: "pong" }, { type: "pong" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("a job is executed and answered with job_result then usage", async () => {
|
||||
const context = build();
|
||||
const socket = await online(context);
|
||||
await socket.receive({
|
||||
type: "job",
|
||||
job_id: "j9",
|
||||
method: "GET",
|
||||
url: "https://www.reddit.com/r/x.json",
|
||||
timeout: 5,
|
||||
});
|
||||
await context.client.idle();
|
||||
const result = socket.sent.find((frame) => frame.type === "job_result");
|
||||
assert.equal(result.job_id, "j9");
|
||||
assert.equal(Buffer.from(result.body_b64, "base64").toString(), "page");
|
||||
assert.equal(socket.sent.some((frame) => frame.type === "usage"), true);
|
||||
});
|
||||
|
||||
test("a job for a forbidden host is answered with job_error", async () => {
|
||||
const context = build();
|
||||
const socket = await online(context);
|
||||
await socket.receive({ type: "job", job_id: "j2", method: "GET", url: "https://evilreddit.com/", timeout: 5 });
|
||||
await context.client.idle();
|
||||
const error = socket.sent.find((frame) => frame.type === "job_error");
|
||||
assert.deepEqual(error, { type: "job_error", job_id: "j2", code: "host_not_allowed" });
|
||||
});
|
||||
|
||||
test("a welcome that widens the allowlist is ignored for the widened entries", async () => {
|
||||
const context = build();
|
||||
await context.client.start();
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket.open();
|
||||
await socket.receive(
|
||||
welcome({
|
||||
destinations: [
|
||||
...DESTINATIONS.map((item) => ({ ...item })),
|
||||
{ suffix: "evil.net", adapter: "x", methods: ["GET"], ports: [443], modes: ["fetch"] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.match(context.state.snapshot().lastError, /evil\.net/);
|
||||
await socket.receive({ type: "job", job_id: "j3", method: "GET", url: "https://evil.net/", timeout: 5 });
|
||||
await context.client.idle();
|
||||
assert.equal(socket.sent.find((frame) => frame.type === "job_error").code, "host_not_allowed");
|
||||
});
|
||||
|
||||
test("a job before welcome is a protocol fault and closes with 4400", async () => {
|
||||
const context = build();
|
||||
await context.client.start();
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket.open();
|
||||
await socket.receive({ type: "job", job_id: "j", method: "GET", url: "https://reddit.com/", timeout: 5 });
|
||||
assert.equal(socket.closed.code, 4400);
|
||||
assert.equal(context.state.snapshot().state, STATES.BACKOFF);
|
||||
});
|
||||
|
||||
test("malformed frames are rejected loudly", async () => {
|
||||
const context = build();
|
||||
const socket = await online(context);
|
||||
for (const bad of ["not json", "[]", JSON.stringify({ type: "nope" }), JSON.stringify({ type: "job", job_id: "x" })]) {
|
||||
await assert.rejects(context.client.handleMessage(bad));
|
||||
}
|
||||
await assert.rejects(context.client.handleMessage(new ArrayBuffer(4)));
|
||||
assert.equal(socket.sent.filter((frame) => frame.type === "job_result").length, 0);
|
||||
});
|
||||
|
||||
test("kick halts without reconnecting", async () => {
|
||||
const context = build();
|
||||
const socket = await online(context);
|
||||
await socket.receive({ type: "kick", reason: "replaced" });
|
||||
assert.equal(context.state.snapshot().state, STATES.HALTED);
|
||||
assert.match(context.state.snapshot().detail, /replaced/);
|
||||
assert.equal(context.timers.timeouts.filter((handle) => !handle.cleared).length, 0);
|
||||
await context.client.ensure();
|
||||
assert.equal(FakeSocket.instances.length, 1);
|
||||
});
|
||||
|
||||
test("a normal close schedules a full jitter reconnect within the bounds", async () => {
|
||||
const context = build({ random: () => 0.999 });
|
||||
const socket = await online(context);
|
||||
await socket.serverClose(1006);
|
||||
const timer = context.timers.timeouts.at(-1);
|
||||
assert.ok(timer.ms >= 1000 && timer.ms <= 60000, String(timer.ms));
|
||||
assert.equal(context.state.snapshot().state, STATES.BACKOFF);
|
||||
timer.fn();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(FakeSocket.instances.length, 2);
|
||||
});
|
||||
|
||||
test("backoff grows with attempts and never exceeds reconnect_max", async () => {
|
||||
const context = build({ random: () => 1 });
|
||||
await context.client.start();
|
||||
const delays = [];
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const socket = FakeSocket.instances.at(-1);
|
||||
await socket.serverClose(1006);
|
||||
const timer = context.timers.timeouts.at(-1);
|
||||
delays.push(timer.ms);
|
||||
timer.fn();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.ok(delays[1] > delays[0]);
|
||||
assert.ok(Math.max(...delays) <= 60000);
|
||||
assert.equal(delays.at(-1), 60000);
|
||||
});
|
||||
|
||||
test("retry owner close reconnects at the minimum delay", async () => {
|
||||
const context = build({ random: () => 1 });
|
||||
const socket = await online(context);
|
||||
await socket.serverClose(4013);
|
||||
assert.equal(context.timers.timeouts.at(-1).ms, 1000);
|
||||
});
|
||||
|
||||
test("a consent or device refusal halts instead of hammering the server", async () => {
|
||||
const context = build();
|
||||
await context.client.start();
|
||||
const socket = FakeSocket.instances[0];
|
||||
await socket.serverClose(1008, "no_consent");
|
||||
assert.equal(context.state.snapshot().state, STATES.HALTED);
|
||||
assert.match(context.state.snapshot().detail, /no_consent/);
|
||||
});
|
||||
|
||||
test("a ticket 401 halts, a ticket 500 retries", async () => {
|
||||
const halted = build({
|
||||
ticketFetch: async () => new Response(JSON.stringify({ error: "x" }), { status: 401 }),
|
||||
});
|
||||
await halted.client.start();
|
||||
assert.equal(halted.state.snapshot().state, STATES.HALTED);
|
||||
assert.equal(FakeSocket.instances.length, 0);
|
||||
|
||||
const retried = build({ ticketFetch: async () => new Response("boom", { status: 500 }) });
|
||||
await retried.client.start();
|
||||
assert.equal(retried.state.snapshot().state, STATES.BACKOFF);
|
||||
});
|
||||
|
||||
test("stop closes the socket and clears timers", async () => {
|
||||
const context = build();
|
||||
const socket = await online(context);
|
||||
await context.client.stop("user");
|
||||
assert.equal(socket.closed.code, 1000);
|
||||
assert.equal(context.timers.intervals[0].cleared, true);
|
||||
assert.equal(context.state.snapshot().state, STATES.OFF);
|
||||
});
|
||||
|
||||
test("settings_update can only lower the effective limits", async () => {
|
||||
const context = build();
|
||||
const socket = await online(context);
|
||||
await socket.receive({ type: "settings_update", settings: { bandwidth_mb_day: 10, max_concurrent: 1 } });
|
||||
assert.equal(context.limits.concurrency, 1);
|
||||
assert.equal(context.limits.bandwidthBytes, 10 * 1024 * 1024);
|
||||
});
|
||||
Reference in New Issue
Block a user