Something went wrong. Try again.
Something went wrong. Try again.
51 kB · 753 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754import { afterEach, beforeEach, describe, expect, it } from "vitest";import { execFileSync } from "node:child_process";import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path";import type { AddressInfo } from "node:net";import type { Server } from "node:http";import { Central } from "../../central/service";import { CentralStore } from "../../central/store";import { createCentralServer } from "../../central/http";import { Dispatcher } from "../../central/dispatcher";import { loadOrCreateAdminToken, ensureDataDir } from "../../central/keys";import { devKeys } from "../helpers/centralKeys";import { CentralClient } from "../../extension/worker/centralClient";import { bundleHash, parseBundle, signedText, verifyBundle } from "../../shared/bundle";import { denylistSignedText, verifyDenylist, verifyJob } from "../../shared/envelope";import { writeZip } from "../../shared/zip";import { createHash } from "node:crypto";import { request as httpRequest } from "node:http";
/** The central service over real HTTP, with a real data folder, real files and a real git repository for the tests it bundles. */
const ORIGIN = "http://127.0.0.1:4780";const SCRIPT = `test "send"\n start ${ORIGIN}/form.html\n step "s"\n sleep 1ms\n`;const enc = (s: string) => new TextEncoder().encode(s);const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
let dir: string;let repo: string;let server: Server;let base: string;let host: string;let admin: string;let store: CentralStore;let central: Central;
const git = (...args: string[]) => execFileSync("git", ["-C", repo, "-c", "user.email=t@aite.invalid", "-c", "user.name=t", ...args], { encoding: "utf8" });
const presenceTable: Record<string, { online: boolean; resources: string[]; caps?: object } | undefined> = {};
async function start(over: { maxArchiveBytes?: number; testRepo?: string | null; maxJsonBytes?: number; accessTtlMs?: number; dispatcher?: boolean } = {}) { const data = join(dir, "data"); ensureDataDir(data); admin = loadOrCreateAdminToken(data); store = new CentralStore(join(data, "central.db")); central = new Central({ store, dataDir: data, now: Date.now, ...devKeys(data).deps, leaseMs: 60_000, maxAttempts: 3, maxArchiveBytes: over.maxArchiveBytes ?? 5_000_000, ...(over.accessTtlMs !== undefined ? { accessTtlMs: over.accessTtlMs } : {}) }); const hosts: string[] = []; const dispatcher = over.dispatcher ? new Dispatcher({ central, presence: (id) => presenceTable[id] as never, now: Date.now, postCapPerDevicePerDay: 2, postCapPerDay: 3 }) : undefined; dispatcher?.attach(); server = createCentralServer({ central, adminToken: admin, hosts, ...(dispatcher ? { dispatcher, presence: (id: string) => presenceTable[id] } : {}), ...(over.testRepo === null ? {} : { testRepo: over.testRepo ?? repo }), maxArchiveBytes: over.maxArchiveBytes ?? 5_000_000, ...(over.maxJsonBytes !== undefined ? { maxJsonBytes: over.maxJsonBytes } : {}) }); await new Promise<void>((r) => server.listen(0, "127.0.0.1", r)); const port = (server.address() as AddressInfo).port; host = `127.0.0.1:${port}`; hosts.push(host, `localhost:${port}`); base = `http://${host}`;}
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "aite-centralhttp-")); repo = join(dir, "tests-repo"); mkdirSync(join(repo, "scripts/shared"), { recursive: true }); execFileSync("git", ["init", "-q", "-b", "main", repo]); writeFileSync(join(repo, "scripts/send.aite"), SCRIPT); writeFileSync(join(repo, "scripts/shared/s.aite"), "x"); writeFileSync(join(repo, "scripts/send.lock.json"), "{}"); writeFileSync(join(repo, "notes.txt"), "not a script"); git("add", "-A"); git("commit", "-q", "-m", "tests");});afterEach(async () => { await new Promise<void>((r) => { server?.closeAllConnections(); server?.close(() => r()); }); store?.close(); rmSync(dir, { recursive: true, force: true });});
const call = (path: string, init: RequestInit & { token?: string; adminAuth?: boolean; headers?: Record<string, string> } = {}) => fetch(`${base}${path}`, { ...init, headers: { ...(init.adminAuth ? { authorization: `Bearer ${admin}` } : init.token ? { authorization: `Bearer ${init.token}` } : {}), ...init.headers } });const adminJson = async (path: string, body?: unknown, method = body === undefined ? "GET" : "POST") => { const r = await call(path, { method, adminAuth: true, ...(body !== undefined ? { body: JSON.stringify(body), headers: { "content-type": "application/json" } } : {}) }); return { status: r.status, body: (await r.json().catch(() => undefined)) as any };};const enrol = async () => { const e = (await adminJson("/admin/enrolments", { person: "did:plc:alex", origins: [ORIGIN], name: "balrog" })).body as { code: string }; const r = await fetch(`${base}/api/enrol`, { method: "POST", body: JSON.stringify({ code: e.code }) }); const b = (await r.json()) as { device: { id: string }; credentials: { accessToken: string; refreshToken: string } }; return { device: b.device, token: b.credentials.accessToken, credentials: b.credentials };};
describe("who may talk to the service", () => { beforeEach(() => start()); it("answers health without a token, and nothing else", async () => { expect((await call("/healthz")).status).toBe(200); expect((await call("/api/jobs")).status).toBe(401); expect((await call("/admin/devices")).status).toBe(401); expect((await call("/")).status).toBe(404); }); it("refuses a Host header the service does not answer to, so a web page cannot reach it through DNS rebinding", async () => { // fetch does not let a script set Host, so this is a raw request, as a rebound DNS name would arrive const status = await new Promise<number>((resolve, reject) => { const req = httpRequest({ host: "127.0.0.1", port: Number(host.split(":")[1]), path: "/healthz", headers: { host: "evil.example" } }, (res) => { res.resume(); resolve(res.statusCode ?? 0); }); req.on("error", reject); req.end(); }); expect(status).toBe(421); }); it("sends no CORS headers, marks answers no-store and nosniff", async () => { const r = await call("/healthz", { headers: { origin: "https://evil.example" } }); expect([...r.headers.keys()].filter((k) => k.startsWith("access-control"))).toEqual([]); expect(r.headers.get("cache-control")).toBe("no-store"); expect(r.headers.get("x-content-type-options")).toBe("nosniff"); }); it("a device token does not open the admin API, and the admin token is not a device token", async () => { const { token } = await enrol(); expect((await call("/admin/devices", { token })).status).toBe(401); expect((await call("/api/jobs", { adminAuth: true })).status).toBe(401); }); it("wrong bearer tokens are refused every time and never lock anyone out: behind a tunnel every client is 127.0.0.1, so a limit by address would let one party shut out every device and the operator", async () => { const good = await enrol(); for (let i = 0; i < 60; i++) expect((await call(i % 2 ? "/api/jobs" : "/admin/devices", { token: `wrong-${i}` })).status).toBe(401); expect((await call("/api/jobs", { token: good.token })).status).toBe(200); expect((await adminJson("/admin/devices")).status).toBe(200); expect((await adminJson("/admin/status")).status).toBe(200); expect((await post("/api/token/refresh", { refreshToken: good.credentials.refreshToken })).status).toBe(200); }); it("a revoked device is refused at once", async () => { const { device, token } = await enrol(); expect((await call("/api/jobs", { token })).status).toBe(200); expect((await adminJson(`/admin/devices/${device.id}/revoke`, {})).status).toBe(200); expect((await call("/api/jobs", { token })).status).toBe(401); expect((await adminJson(`/admin/devices/${device.id}/revoke`, {})).status).toBe(404); }); it("the enrolment answer carries the credentials once; listing devices never shows them", async () => { const e = await enrol(); expect(e.token).toMatch(/^aite_at_/); const list = JSON.stringify((await adminJson("/admin/devices")).body); expect(list).not.toContain(e.token); expect(list).not.toContain(e.credentials.refreshToken); expect(list).toContain("did:plc:alex"); });});
const post = (path: string, body: unknown) => fetch(`${base}${path}`, { method: "POST", body: JSON.stringify(body) });
describe("enrolment and credentials over HTTP", () => { beforeEach(() => start({ accessTtlMs: 400 })); const code = async (over: object = {}) => ((await adminJson("/admin/enrolments", { person: "did:plc:alex", origins: [ORIGIN], name: "balrog", ...over })).body as { code: string; expiresAt: number }).code;
it("a person's code enrols a device with no other credential: the device is bound to that person and to the origins the code named", async () => { const r = await post("/api/enrol", { code: await code({ origins: [ORIGIN, "https://app.example"] }) }); expect(r.status).toBe(201); const b = (await r.json()) as any; expect(b.device).toMatchObject({ name: "balrog", person: "did:plc:alex", origins: [ORIGIN, "https://app.example"] }); expect(Object.keys(b.credentials).sort()).toEqual(["accessExpiresAt", "accessToken", "refreshExpiresAt", "refreshToken"]); expect(r.headers.get("cache-control")).toBe("no-store"); }); it("the device cannot choose its origins, its person or its name when it redeems a code", async () => { const r = await post("/api/enrol", { code: await code(), origins: ["https://evil.example"], person: "did:plc:mallory", name: "pwned" }); const b = (await r.json()) as any; expect(b.device).toMatchObject({ name: "balrog", person: "did:plc:alex", origins: [ORIGIN] }); }); it("a code works once, and a wrong code, a missing code and a code that is not text are all refused", async () => { const c = await code(); expect((await post("/api/enrol", { code: c })).status).toBe(201); expect((await post("/api/enrol", { code: c })).status).toBe(401); expect((await post("/api/enrol", { code: "AAAA-AAAA-AAAA-AAAA" })).status).toBe(401); expect((await post("/api/enrol", {})).status).toBe(400); expect((await post("/api/enrol", { code: 5 })).status).toBe(400); }); it("guessing enrolment codes (80 bits, so the only secret here worth guessing at) is rate limited: after 20 wrong codes from one address, enrolling from it is 429, and nothing else is affected", async () => { const e = await enrol(); const real = await code(); for (let i = 0; i < 20; i++) expect((await post("/api/enrol", { code: "AAAA-AAAA-AAAA-AAAA" })).status).toBe(401); expect((await post("/api/enrol", { code: real })).status).toBe(429); expect((await call("/api/jobs", { token: e.token })).status).toBe(200); expect((await adminJson("/admin/devices")).status).toBe(200); expect((await post("/api/token/refresh", { refreshToken: e.credentials.refreshToken })).status).toBe(200); }); it("guessing refresh tokens has its own limit, separate from enrolment's", async () => { const e = await enrol(); for (let i = 0; i < 20; i++) expect((await post("/api/token/refresh", { refreshToken: `aite_rt_guess${i}` })).status).toBe(401); expect((await post("/api/token/refresh", { refreshToken: e.credentials.refreshToken })).status).toBe(429); expect((await post("/api/enrol", { code: await code() })).status).toBe(201); }); it("an access token stops working when it expires; refreshing gives a working one, and the old pair is dead", async () => { const e = await enrol(); expect((await call("/api/jobs", { token: e.token })).status).toBe(200); await new Promise((r) => setTimeout(r, 450)); expect((await call("/api/jobs", { token: e.token })).status).toBe(401); // a stolen, expired token const r = await post("/api/token/refresh", { refreshToken: e.credentials.refreshToken }); expect(r.status).toBe(200); const fresh = ((await r.json()) as any).credentials; expect((await call("/api/jobs", { token: fresh.accessToken })).status).toBe(200); expect(fresh.refreshToken).not.toBe(e.credentials.refreshToken); }); it("using a refresh token a second time, after the new pair was used, revokes the device: even the newest token is refused", async () => { const e = await enrol(); const fresh = ((await (await post("/api/token/refresh", { refreshToken: e.credentials.refreshToken })).json()) as any).credentials; expect((await call("/api/jobs", { token: fresh.accessToken })).status).toBe(200); expect((await post("/api/token/refresh", { refreshToken: e.credentials.refreshToken })).status).toBe(401); expect((await call("/api/jobs", { token: fresh.accessToken })).status).toBe(401); expect((await post("/api/token/refresh", { refreshToken: fresh.refreshToken })).status).toBe(401); const devices = (await adminJson("/admin/devices")).body.devices as Array<{ revokedAt?: number }>; expect(devices[0]!.revokedAt).toBeDefined(); }); it("refresh refuses an unknown token, a missing one, and an access token in its place", async () => { const e = await enrol(); expect((await post("/api/token/refresh", { refreshToken: "aite_rt_nope" })).status).toBe(401); expect((await post("/api/token/refresh", {})).status).toBe(400); expect((await post("/api/token/refresh", { refreshToken: e.token })).status).toBe(401); }); it("revoking a device stops its refresh token as well as its access token", async () => { const e = await enrol(); await adminJson(`/admin/devices/${e.device.id}/revoke`, {}); expect((await call("/api/jobs", { token: e.token })).status).toBe(401); expect((await post("/api/token/refresh", { refreshToken: e.credentials.refreshToken })).status).toBe(401); }); it("making an enrolment needs the admin token and sensible input", async () => { expect((await call("/admin/enrolments", { method: "POST", body: "{}" })).status).toBe(401); expect((await adminJson("/admin/enrolments", { person: "", origins: [ORIGIN] })).status).toBe(400); expect((await adminJson("/admin/enrolments", { person: "p", origins: ["http://a.example/path"] })).status).toBe(400); expect((await adminJson("/admin/enrolments", { person: "p", origins: [ORIGIN], ttlMs: 999_999_999 })).status).toBe(400); expect((await adminJson("/admin/enrolments", { person: "p", origins: "nope" })).status).toBe(400); }); it("the log never shows a code or a token", async () => { const lines: string[] = []; await new Promise<void>((r) => { server.closeAllConnections(); server.close(() => r()); }); store.close(); const data = join(dir, "data"); store = new CentralStore(join(data, "central.db")); central = new Central({ store, dataDir: data, now: Date.now, ...devKeys(data).deps, leaseMs: 60_000, maxAttempts: 3, maxArchiveBytes: 1000 }); const hosts: string[] = []; server = createCentralServer({ central, adminToken: admin, hosts, maxArchiveBytes: 1000, log: (l) => void lines.push(l) }); await new Promise<void>((r) => server.listen(0, "127.0.0.1", r)); const port = (server.address() as AddressInfo).port; hosts.push(`127.0.0.1:${port}`); base = `http://127.0.0.1:${port}`; const c = await code(); const e = (await (await post("/api/enrol", { code: c })).json()) as any; await post("/api/token/refresh", { refreshToken: e.credentials.refreshToken }); for (const l of lines) for (const secret of [c, e.credentials.accessToken, e.credentials.refreshToken, admin]) expect(l).not.toContain(secret); expect(lines.some((l) => l.includes("POST /api/enrol"))).toBe(true); });});
describe("what a device is told about the doorbell", () => { beforeEach(() => start()); it("with no doorbell the config says so, and only a device with a token may ask", async () => { const e = await enrol(); expect(await (await call("/api/config", { token: e.token })).json()).toEqual({ xmpp: null }); expect((await call("/api/config")).status).toBe(401); });});
describe("dispatch, stop and status over HTTP", () => { beforeEach(() => { for (const k of Object.keys(presenceTable)) delete presenceTable[k]; return start({ dispatcher: true }); }); const online = (id: string, caps: object = { chrome: "155.0.0.0", extension: "0.1.0", state: "idle", origins: [ORIGIN], profiles: ["test-qian"], posts: true, spends: true }) => void (presenceTable[id.toLowerCase()] = { online: true, resources: ["ext"], caps }); const dispatch = (body: object) => adminJson("/admin/dispatch", { ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN], ...body });
it("picks an online matching device from a commit of the test repository, makes the job and names the dispatch and the devices it considered", async () => { const a = await enrol(); const b = await enrol(); online(a.device.id); const r = await dispatch({ session: "test-qian", name: "send" }); expect(r.status, JSON.stringify(r.body)).toBe(201); expect(r.body.jobs).toHaveLength(1); expect(r.body.jobs[0]).toMatchObject({ deviceId: a.device.id, state: "offered", dispatchId: r.body.dispatchId }); expect(r.body.commit).toBe(git("rev-parse", "main").trim()); expect(r.body.considered.find((x: { device: string }) => x.device === b.device.id)).toMatchObject({ ok: false, why: "offline" }); expect((await adminJson(`/admin/dispatch/${r.body.dispatchId}`)).body.jobs).toHaveLength(1); }); it("answers 409 with why for every device when nothing matches, and 404 for an unknown dispatch", async () => { const a = await enrol(); online(a.device.id, { chrome: "149.0.0.0", extension: "0.1.0", state: "idle", origins: [ORIGIN], profiles: [], posts: true, spends: true }); const r = await dispatch({ chromeMin: "150" }); expect(r.status).toBe(409); expect(r.body.error).toMatch(/no device/); expect(r.body.considered[0].why).toMatch(/Chrome 149/); expect((await adminJson("/admin/dispatch/dsp-none")).status).toBe(404); }); it("refuses a dispatch with no bundle or ref, a fan-out that is not one or all, and with no ref configured", async () => { expect((await adminJson("/admin/dispatch", { file: "f", start: "s", allow: [] })).status).toBe(400); expect((await dispatch({ fanout: "some" })).status).toBe(400); expect((await dispatch({ ref: "--upload-pack=x" })).status).toBe(400); }); it("reports a skipped run (the post cap) as a result, not as a failure", async () => { git("checkout", "-q", "-b", "posting"); writeFileSync(join(repo, "scripts/send.aite"), `test "send"\n posts\n start ${ORIGIN}/form.html\n step "s"\n sleep 1ms\n`); git("commit", "-q", "-am", "posting"); const a = await enrol(); online(a.device.id); expect((await dispatch({ ref: "posting", postsAllowed: 1 })).status).toBe(201); expect((await dispatch({ ref: "posting", postsAllowed: 1 })).status).toBe(201); const third = await dispatch({ ref: "posting", postsAllowed: 1 }); expect(third.status).toBe(201); expect(third.body.jobs).toEqual([]); expect(third.body.skipped[0]).toMatchObject({ state: "skipped", failReason: expect.stringContaining("post cap") }); expect(third.body.message).toMatch(/skipped/); }); it("a job made by hand for a named device is capped like a dispatched one, so the endpoint is no way round the cap (#71)", async () => { git("checkout", "-q", "-b", "posting-by-hand"); writeFileSync(join(repo, "scripts/send.aite"), `test "send"\n posts\n start ${ORIGIN}/form.html\n step "s"\n sleep 1ms\n`); git("commit", "-q", "-am", "posting"); const a = await enrol(); online(a.device.id); const job = () => adminJson("/admin/jobs", { device: a.device.id, ref: "posting-by-hand", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN], postsAllowed: 1 }); expect((await job()).status).toBe(201); expect((await job()).status).toBe(201); const third = await job(); expect(third.status).toBe(201); expect(third.body.job).toBeUndefined(); expect(third.body.skipped).toMatchObject({ state: "skipped", failReason: expect.stringContaining("post cap") }); expect(third.body.message).toContain("post cap"); }); it("a job made by hand is refused while the person at the device has posting switched off (#71)", async () => { git("checkout", "-q", "-b", "posting-off"); writeFileSync(join(repo, "scripts/send.aite"), `test "send"\n posts\n start ${ORIGIN}/form.html\n step "s"\n sleep 1ms\n`); git("commit", "-q", "-am", "posting"); const a = await enrol(); online(a.device.id, { chrome: "155.0.0.0", extension: "0.1.0", state: "idle", origins: [ORIGIN], profiles: [], posts: false, spends: true }); const r = await adminJson("/admin/jobs", { device: a.device.id, ref: "posting-off", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN], postsAllowed: 1 }); expect(r.status).toBe(409); expect(r.body.error).toContain("posting switched off"); }); describe("an open device (enrolled for any site) and the sites its person does not allow", () => { const OTHER = "https://other.example"; const open = async () => { const e = (await adminJson("/admin/enrolments", { person: "did:plc:alex", origins: ["*"], name: "open" })).body as { code: string }; const r = await fetch(`${base}/api/enrol`, { method: "POST", body: JSON.stringify({ code: e.code }) }); return ((await r.json()) as { device: { id: string; origins: string[] } }).device; }; const otherSite = () => { git("checkout", "-q", "-b", "elsewhere"); writeFileSync(join(repo, "scripts/send.aite"), `test "send"\n start ${OTHER}/form.html\n step "s"\n sleep 1ms\n`); git("commit", "-q", "-am", "elsewhere"); }; const caps = (over: object) => ({ chrome: "155.0.0.0", extension: "0.2.0", state: "idle", origins: ["*"], profiles: [], posts: false, spends: false, ...over }); it("the service enrols a device for any site, alone: mixed with named origins it is refused", async () => { expect((await open()).origins).toEqual(["*"]); expect((await adminJson("/admin/enrolments", { person: "p", origins: ["*", ORIGIN] })).status).toBe(400); }); it("is given a dispatched test on a site that no one listed, and passed over when its person does not allow that site, with the reason", async () => { otherSite(); const dev = await open(); online(dev.id, caps({})); const ok = await adminJson("/admin/dispatch", { ref: "elsewhere", file: "scripts/send.aite", start: `${OTHER}/form.html`, allow: [OTHER] }); expect(ok.status, JSON.stringify(ok.body)).toBe(201); expect(ok.body.jobs[0]).toMatchObject({ deviceId: dev.id, state: "offered" }); online(dev.id, caps({ denied: ["other.example"] })); const refused = await adminJson("/admin/dispatch", { ref: "elsewhere", file: "scripts/send.aite", start: `${OTHER}/form.html`, allow: [OTHER] }); expect(refused.status).toBe(409); expect(refused.body.considered[0].why).toContain("does not allow tests on https://other.example"); }); it("a job made by hand for it is refused with 409 when its person does not allow the site, and made when they do", async () => { otherSite(); const dev = await open(); online(dev.id, caps({ denied: ["other.example"] })); const job = () => adminJson("/admin/jobs", { device: dev.id, ref: "elsewhere", file: "scripts/send.aite", start: `${OTHER}/form.html`, allow: [OTHER] }); const refused = await job(); expect(refused.status).toBe(409); expect(refused.body.error).toContain("does not allow tests on"); online(dev.id, caps({})); expect((await job()).status).toBe(201); }); }); it("a test that spends is refused unless the dispatch allows spends, and the status shows the spend caps (#69)", async () => { git("checkout", "-q", "-b", "spending"); writeFileSync(join(repo, "scripts/send.aite"), `test "send"\n spends\n start ${ORIGIN}/form.html\n step "s"\n sleep 1ms\n`); git("commit", "-q", "-am", "spending"); const a = await enrol(); online(a.device.id); const refused = await dispatch({ ref: "spending", postsAllowed: 5 }); expect(refused.body.jobs ?? []).toEqual([]); expect(JSON.stringify(refused.body)).toContain("spends"); expect(refused.status).not.toBe(201); const allowed = await dispatch({ ref: "spending", spendsAllowed: 1 }); expect(allowed.status).toBe(201); expect(allowed.body.jobs[0]).toMatchObject({ spends: true, budget: { spendsAllowed: 1 } }); expect((await adminJson("/admin/status")).body.spendCaps).toEqual({ perDevice: 3, perDay: 5 }); }); it("stop ends everything: nothing is offered to the device, a heartbeat for a job in progress is refused, and status says why; resume brings it back", async () => { const a = await enrol(); online(a.device.id); const first = await dispatch({}); const jobId = first.body.jobs[0].id as string; const client = new CentralClient({ baseUrl: `${base}/api`, fetch, token: async () => a.token, timeoutMs: 5000 }); await client.accept(jobId); await client.heartbeat(jobId, 1); expect((await adminJson("/admin/stop", { reason: "deploying" })).status).toBe(200); await expect(client.heartbeat(jobId, 1)).rejects.toMatchObject({ status: 409 }); expect(await client.listOffered()).toEqual([]); const st = (await adminJson("/admin/status")).body; expect(st.stopped).toMatchObject({ reason: "deploying" }); expect(st.jobs.cancelled).toBe(1); expect(st.devices.find((d: { id: string }) => d.id === a.device.id).presence).toMatchObject({ online: true }); expect(st.postCaps).toEqual({ perDevice: 2, perDay: 3 }); const second = await dispatch({}); expect(second.body.jobs).toHaveLength(1); // a job can be made while stopped; it is held, not offered expect(await client.listOffered()).toEqual([]); expect((await adminJson("/admin/resume", {})).status).toBe(200); expect(((await client.listOffered()) as unknown[]).length).toBe(1); expect((await adminJson("/admin/status")).body.stopped).toBeNull(); }); it("stop needs a reason and the admin token", async () => { expect((await adminJson("/admin/stop", { reason: "" })).status).toBe(400); expect((await call("/admin/stop", { method: "POST", body: JSON.stringify({ reason: "x" }) })).status).toBe(401); expect((await call("/admin/status")).status).toBe(401); });});
describe("limits", () => { it("a JSON body over the limit is 413", async () => { await start({ maxJsonBytes: 200 }); const r = await call("/admin/enrolments", { method: "POST", adminAuth: true, body: JSON.stringify({ person: "x".repeat(500), origins: [ORIGIN] }) }); expect(r.status).toBe(413); }); it("a body that is not a JSON object is 400, not a crash", async () => { await start(); expect((await call("/admin/enrolments", { method: "POST", adminAuth: true, body: "[1,2]" })).status).toBe(400); expect((await call("/admin/enrolments", { method: "POST", adminAuth: true, body: "{nope" })).status).toBe(400); }); it("a job made by hand for a script that spends is refused without a spends allowance and made with one (#69)", async () => { await start(); const { device } = await enrol(); const b = await central.publishBundle({ "scripts/pay.aite": `test "pay"\n spends\n start ${ORIGIN}/form.html\n step "s"\n sleep 1ms\n` }, "c"); const body = { device: device.id, bundle: b.hash, file: "scripts/pay.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }; const refused = await adminJson("/admin/jobs", { ...body, postsAllowed: 3 }); expect(refused.status).toBe(400); expect(refused.body.error).toContain("spends"); const made = await adminJson("/admin/jobs", { ...body, spendsAllowed: 1 }); expect(made.status).toBe(201); expect(made.body.job).toMatchObject({ spends: true, budget: { spendsAllowed: 1 } }); }); it("an archive over the limit is refused with 413", async () => { await start({ maxArchiveBytes: 1000 }); const { device, token } = await enrol(); const b = await central.publishBundle({ "scripts/send.aite": SCRIPT }, "c"); const made = await adminJson("/admin/jobs", { device: device.id, bundle: b.hash, file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); const id = made.body.job.id; await call(`/api/jobs/${id}/accept`, { method: "POST", token }); const big = new Uint8Array(2000); const r = await call(`/api/jobs/${id}/archive`, { method: "PUT", token, body: big, headers: { "x-aite-sha256": sha(big) } }); expect(r.status).toBe(413); }); it("hostile archives are refused over HTTP and the job stays open: garbage, an overlapping zip bomb, an unsafe name, a manifest for another job", async () => { await start(); const { device, token } = await enrol(); const b = await central.publishBundle({ "scripts/send.aite": SCRIPT }, "c"); const made = await adminJson("/admin/jobs", { device: device.id, bundle: b.hash, file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); const id = made.body.job.id; await call(`/api/jobs/${id}/accept`, { method: "POST", token }); const put = (bytes: Uint8Array) => call(`/api/jobs/${id}/archive`, { method: "PUT", token, body: bytes as BodyInit, headers: { "x-aite-sha256": sha(bytes) } }); // many central directory entries pointing at one block of data const base = writeZip([{ name: "a", data: new Uint8Array(100_000) }]); const dv = new DataView(base.buffer); const cdOffset = dv.getUint32(base.length - 6, true); const cdEntry = base.slice(cdOffset, base.length - 22); const head = base.slice(0, cdOffset); const eocd = new Uint8Array(22); const ev = new DataView(eocd.buffer); ev.setUint32(0, 0x06054b50, true); ev.setUint16(8, 50, true); ev.setUint16(10, 50, true); ev.setUint32(12, cdEntry.length * 50, true); ev.setUint32(16, head.length, true); const parts = [head, ...Array.from({ length: 50 }, () => cdEntry), eocd]; const bomb = new Uint8Array(parts.reduce((n, x) => n + x.length, 0)); let at = 0; for (const x of parts) (bomb.set(x, at), (at += x.length)); const wrongJob = writeZip([{ name: "archive.json", data: enc(JSON.stringify({ schema: 1, jobId: "job-9999", attempt: 1, deviceId: device.id, bundle: b.hash, status: "passed" })) }, { name: "run.json", data: enc("{}") }]); for (const bytes of [new Uint8Array([1, 2, 3, 4]), bomb, wrongJob]) { const r = await put(bytes); expect(r.status, JSON.stringify(r.body)).toBeGreaterThanOrEqual(400); expect(r.status).toBeLessThan(500); } expect((await adminJson(`/admin/jobs/${id}`)).body.job.state).toBe("accepted"); });});
describe("jobs from a commit of the test repository", () => { beforeEach(() => start()); it("a ref is bundled from git's object store: scripts and lock files of that commit, signed, and the commit is recorded", async () => { const { device } = await enrol(); const made = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN], name: "send" }); expect(made.status, JSON.stringify(made.body)).toBe(201); expect(made.body.commit).toBe(git("rev-parse", "main").trim()); const row = store.bundleByHash(made.body.job.test.bundle)!; expect(Object.keys(row.files).sort()).toEqual(["scripts/send.aite", "scripts/send.lock.json", "scripts/shared/s.aite"]); // notes.txt is not a script expect(row.commit).toBe(made.body.commit); }); it("what is edited but not committed is not in the bundle, and a later commit gives a different bundle while the first stays", async () => { const { device } = await enrol(); const first = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); writeFileSync(join(repo, "scripts/send.aite"), SCRIPT + "# uncommitted\n"); const second = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); expect(second.body.job.test.bundle).toBe(first.body.job.test.bundle); git("commit", "-q", "-am", "edit"); const third = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); expect(third.body.job.test.bundle).not.toBe(first.body.job.test.bundle); expect(store.bundleByHash(first.body.job.test.bundle)!.files["scripts/send.aite"]).toBe(SCRIPT); }); it.each(["--upload-pack=touch /tmp/pwned", "../outside", "-x", "a b", "no-such-branch", ""])("refuses the ref %j", async (ref) => { const { device } = await enrol(); const r = await adminJson("/admin/jobs", { device: device.id, ref, file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); expect(r.status).toBe(400); }); it("without a configured test repository a ref is refused, and the request cannot name a path", async () => { await new Promise<void>((r) => server.close(() => r())); store.close(); await start({ testRepo: null }); const { device } = await enrol(); const r = await adminJson("/admin/jobs", { device: device.id, ref: "main", repo: "/etc", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); expect(r.status).toBe(400); expect(r.body.error).toContain("no test repository"); }); it("a commit with no scripts cannot be bundled", async () => { const { device } = await enrol(); git("checkout", "-q", "-b", "empty"); git("rm", "-rq", "scripts"); git("commit", "-q", "-m", "none"); const r = await adminJson("/admin/jobs", { device: device.id, ref: "empty", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); expect(r.status).toBe(400); expect(r.body.error).toContain("no scripts"); });});
describe("the device API with the extension's own client", () => { beforeEach(() => start()); it("lists, accepts, heartbeats, fetches a bundle whose signature verifies with the published key, uploads, and the admin downloads the same archive", async () => { const { device, token } = await enrol(); const made = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN], name: "send" }); const job = made.body.job; const client = new CentralClient({ baseUrl: `${base}/api`, fetch, token: async () => token, timeoutMs: 5000 });
const offered = (await client.listOffered()) as Array<{ id: string }>; expect(offered.map((j) => j.id)).toEqual([job.id]); expect(await client.accept(job.id)).toMatchObject({ ok: true }); expect(await client.accept(job.id)).toEqual({ ok: false, conflict: true }); await client.heartbeat(job.id, 1);
const got = await client.fetchBundle(job.test.bundle); const parsed = parseBundle(got.bundle); expect(parsed.ok).toBe(true); const publicKey = (await adminJson("/admin/key")).body.publicKey; expect(await verifyBundle({ hash: got.hash, signature: got.signature, notAfter: got.notAfter, bundle: (parsed as { ok: true; bundle: import("../../shared/bundle").Bundle }).bundle }, publicKey, job.test.bundle, Date.now())).toEqual({ ok: true });
const zip = writeZip([ { name: "archive.json", data: enc(JSON.stringify({ schema: 1, jobId: job.id, attempt: 1, deviceId: device.id, bundle: job.test.bundle, status: "passed" })) }, { name: "run.json", data: enc(JSON.stringify({ version: 1 })) }, ]); expect(await client.uploadArchive(job.id, zip, sha(zip))).toEqual({ sha256: sha(zip), bytes: zip.length }); const detail = await adminJson(`/admin/jobs/${job.id}`); expect(detail.body.job).toMatchObject({ state: "done", testStatus: "passed", archiveSha256: sha(zip) }); expect(detail.body.events.map((e: { kind: string }) => e.kind)).toEqual(["created", "accepted", "archive-stored"]); const down = await call(`/admin/jobs/${job.id}/archive`, { adminAuth: true }); expect(sha(new Uint8Array(await down.arrayBuffer()))).toBe(sha(zip)); expect(down.headers.get("content-type")).toBe("application/zip"); }); it("records the address it saw the device connect from, and not one the device writes in a forwarded-for header", async () => { const { device, token } = await enrol(); const made = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN], name: "send" }); const job = made.body.job; const accepted = await fetch(`${base}/api/jobs/${job.id}/accept`, { method: "POST", headers: { authorization: `Bearer ${token}`, "x-forwarded-for": "203.0.113.5", forwarded: "for=203.0.113.6", "x-real-ip": "203.0.113.7" } }); expect(accepted.status).toBe(200); const zip = writeZip([ { name: "archive.json", data: enc(JSON.stringify({ schema: 1, jobId: job.id, attempt: 1, deviceId: device.id, bundle: job.test.bundle, status: "passed" })) }, { name: "run.json", data: enc(JSON.stringify({ version: 1 })) }, ]); const up = await fetch(`${base}/api/jobs/${job.id}/archive`, { method: "PUT", body: new Blob([zip as BlobPart]), headers: { authorization: `Bearer ${token}`, "x-aite-sha256": sha(zip), "x-forwarded-for": "203.0.113.5" } }); expect(up.status).toBe(200); const events = (await adminJson(`/admin/jobs/${job.id}`)).body.events as Array<{ kind: string; detail: { seenFrom?: string } }>; for (const kind of ["accepted", "archive-stored"]) expect(events.find((e) => e.kind === kind)!.detail.seenFrom).toMatch(/^(::ffff:)?127\.0\.0\.1$/); expect(JSON.stringify(events)).not.toContain("203.0.113"); }); it("one device cannot see, take, fail, fetch the bundle of or upload to another device's job", async () => { const a = await enrol(); const b = await enrol(); const made = await adminJson("/admin/jobs", { device: a.device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); const job = made.body.job; const other = new CentralClient({ baseUrl: `${base}/api`, fetch, token: async () => b.token, timeoutMs: 5000 }); expect(await other.listOffered()).toEqual([]); await expect(other.accept(job.id)).rejects.toMatchObject({ status: 404 }); await expect(other.fetchBundle(job.test.bundle)).rejects.toMatchObject({ status: 404 }); await expect(other.uploadArchive(job.id, enc("x"), sha(enc("x")))).rejects.toMatchObject({ status: 404 }); expect((await call(`/api/jobs/${job.id}/fail`, { method: "POST", token: b.token, body: JSON.stringify({ attempt: 1 }) })).status).toBe(409); expect((await adminJson(`/admin/jobs/${job.id}`)).body.job.state).toBe("offered"); }); it("a heartbeat or a failure report must say which attempt it is about, and one for an attempt that is over is refused", async () => { const e = await enrol(); const made = await adminJson("/admin/jobs", { device: e.device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); const id = made.body.job.id as string; await call(`/api/jobs/${id}/accept`, { method: "POST", token: e.token }); const hb = (body: unknown) => call(`/api/jobs/${id}/heartbeat`, { method: "POST", token: e.token, body: JSON.stringify(body) }); expect((await hb({})).status).toBe(400); expect((await hb({ attempt: "1" })).status).toBe(400); expect((await hb({ attempt: 0 })).status).toBe(400); expect((await hb({ attempt: 2 })).status).toBe(409); // not this attempt expect((await hb({ attempt: 1 })).status).toBe(200); expect((await call(`/api/jobs/${id}/fail`, { method: "POST", token: e.token, body: JSON.stringify({ reason: "x" }) })).status).toBe(400); expect((await call(`/api/jobs/${id}/fail`, { method: "POST", token: e.token, body: JSON.stringify({ reason: "host-error: stale", attempt: 3 }) })).status).toBe(409); expect((await adminJson(`/admin/jobs/${id}`)).body.job.state).toBe("accepted"); }); it("only state=offered can be listed, and a job id in a path cannot climb", async () => { const { token } = await enrol(); expect((await call("/api/jobs?state=done", { token })).status).toBe(400); expect((await call("/api/jobs/..%2F..%2Fadmin/accept", { method: "POST", token })).status).toBe(404); }); it("a job the central system itself would not offer is refused at creation with the reason", async () => { const { device } = await enrol(); const r = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: "https://evil.example/", allow: ["https://evil.example"] }); expect(r.status).toBe(400); expect(r.body.error).toContain("not allowed on this device"); const missing = await adminJson("/admin/jobs", { device: device.id, file: "scripts/send.aite", start: `${ORIGIN}/`, allow: [ORIGIN] }); expect(missing.status).toBe(400); const none = await call("/admin/jobs/job-nope", { adminAuth: true }); expect(none.status).toBe(404); }); it("cancel and job listing work from the admin side", async () => { const { device } = await enrol(); const made = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); expect((await adminJson("/admin/jobs?state=offered")).body.jobs).toHaveLength(1); expect((await adminJson(`/admin/jobs/${made.body.job.id}/cancel`, {})).status).toBe(200); expect((await adminJson(`/admin/jobs/${made.body.job.id}/cancel`, {})).status).toBe(409); expect((await adminJson(`/admin/jobs?device=${device.id}`)).body.jobs[0].state).toBe("cancelled"); expect((await call(`/admin/jobs/${made.body.job.id}/archive`, { adminAuth: true })).status).toBe(404); });});
describe("persistence and secrets", () => { it("jobs, devices and archives survive a restart on the same data folder, and the signing key is the same one", async () => { await start(); const { device, token } = await enrol(); const made = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); const key1 = (await adminJson("/admin/key")).body.publicKey; await new Promise<void>((r) => { server.closeAllConnections(); server.close(() => r()); }); store.close(); await start(); expect((await adminJson("/admin/key")).body.publicKey).toBe(key1); const r = await call("/api/jobs", { token }); expect(((await r.json()) as { jobs: Array<{ id: string }> }).jobs.map((j) => j.id)).toEqual([made.body.job.id]); }); it("the data folder is private and the private key and admin token are files nobody else can read", async () => { await start(); const { statSync } = await import("node:fs"); const data = join(dir, "data"); expect(statSync(data).mode & 0o777).toBe(0o700); expect(statSync(join(data, "signing-key.pkcs8")).mode & 0o777).toBe(0o600); expect(statSync(join(data, "admin-token")).mode & 0o777).toBe(0o600); expect(readFileSync(join(data, "signing-key.pub"), "utf8").trim()).toMatch(/^[A-Za-z0-9+/]{43}=$/); }); it("no log line holds a token", async () => { const lines: string[] = []; await start(); await new Promise<void>((r) => { server.closeAllConnections(); server.close(() => r()); }); store.close(); const data = join(dir, "data"); store = new CentralStore(join(data, "central.db")); central = new Central({ store, dataDir: data, now: Date.now, ...devKeys(data).deps, leaseMs: 60_000, maxAttempts: 3, maxArchiveBytes: 1000 }); const hosts: string[] = []; server = createCentralServer({ central, adminToken: admin, hosts, maxArchiveBytes: 1000, log: (l) => void lines.push(l) }); await new Promise<void>((r) => server.listen(0, "127.0.0.1", r)); const port = (server.address() as AddressInfo).port; hosts.push(`127.0.0.1:${port}`); base = `http://127.0.0.1:${port}`; const { token } = await enrol(); await call("/api/jobs", { token }); await call("/api/jobs", { token: "wrong-secret-value" }); expect(lines.length).toBeGreaterThan(2); for (const l of lines) { expect(l).not.toContain(token); expect(l).not.toContain(admin); expect(l).not.toContain("wrong-secret-value"); } });});
describe("signed jobs, the release key kept off the host, and the revocation list", () => { const FILES = { "scripts/send.aite": SCRIPT }; /** What `npm run release` leaves for the operator to bring in. */ const signedBundle = async (notAfter = Date.now() + 86_400_000) => { const release = devKeys(join(dir, "data")).release; const hash = await bundleHash({ version: 1, files: FILES }); return { hash, body: { files: FILES, commit: "c1", notAfter, signature: release.sign(signedText(hash, notAfter)) } }; }; const signedList = (seq: number, hashes: string[]) => ({ seq, hashes, signature: devKeys(join(dir, "data")).release.sign(denylistSignedText({ seq, hashes })) }); beforeEach(() => start());
it("publishes two different public keys: the release key and the dispatch key", async () => { const r = await adminJson("/admin/key"); expect(r.body.publicKey).toMatch(/^[A-Za-z0-9+/]{43}=$/); expect(r.body.dispatchKey).toMatch(/^[A-Za-z0-9+/]{43}=$/); expect(r.body.dispatchKey).not.toBe(r.body.publicKey); }); it("a device that lists its jobs gets each one signed by the dispatch key, with an expiry", async () => { const { device, token } = await enrol(); const made = await adminJson("/admin/jobs", { device: device.id, ref: "main", file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); expect(made.status).toBe(201); const jobs = ((await (await call("/api/jobs?state=offered", { token })).json()) as { jobs: Array<Record<string, unknown>> }).jobs; expect(jobs).toHaveLength(1); const key = (await adminJson("/admin/key")).body.dispatchKey as string; expect(await verifyJob(jobs[0], key, Date.now())).toEqual({ ok: true }); expect(await verifyJob({ ...jobs[0], budget: { totalMs: 3_600_000, postsAllowed: 99 } }, key, Date.now())).toMatchObject({ ok: false, reason: "signature" }); });
it("a device gets no revocation list until one is imported (a 404 it treats as a refusal), then exactly the signed list that was imported", async () => { const { token } = await enrol(); expect((await call("/api/denylist", { token })).status).toBe(404); expect((await call("/api/denylist")).status).toBe(401); const doc = signedList(1, []); expect(await adminJson("/admin/denylist", doc)).toMatchObject({ status: 200, body: { ok: true, seq: 1 } }); const served = await (await call("/api/denylist", { token })).json(); expect(served).toEqual(doc); const key = (await adminJson("/admin/key")).body.publicKey as string; expect(await verifyDenylist(served, key)).toMatchObject({ ok: true, seq: 1 }); }); it("importing a revocation list needs the admin token, and a list that is forged, altered or not newer is refused and changes nothing", async () => { expect((await call("/admin/denylist", { method: "POST", body: JSON.stringify(signedList(1, [])) })).status).toBe(401); expect(await adminJson("/admin/denylist", { ...signedList(1, []), signature: "AAAA" })).toMatchObject({ status: 400 }); expect(await adminJson("/admin/denylist", signedList(3, []))).toMatchObject({ status: 200 }); expect(await adminJson("/admin/denylist", signedList(3, ["a".repeat(64)]))).toMatchObject({ status: 400 }); expect(await adminJson("/admin/denylist", signedList(2, ["a".repeat(64)]))).toMatchObject({ status: 400 }); expect(await adminJson("/admin/denylist", "nope")).toMatchObject({ status: 400 }); expect(central.denylist()!.seq).toBe(3); }); it("revoking by hash works in dev mode, takes only 64 character hashes, and the next number is higher", async () => { expect(await adminJson("/admin/bundles/revoke", { hashes: ["a".repeat(64)] })).toMatchObject({ status: 200, body: { ok: true, seq: 1 } }); expect(await adminJson("/admin/bundles/revoke", { hashes: ["b".repeat(64)] })).toMatchObject({ status: 200, body: { seq: 2 } }); expect((await adminJson("/admin/bundles/revoke", { hashes: ["xyz"] })).status).toBe(400); expect((await adminJson("/admin/bundles/revoke", {})).status).toBe(400); expect((await call("/admin/bundles/revoke", { method: "POST", body: "{}" })).status).toBe(401); }); it("a signed bundle can be imported, and is then served to a device with a job that names it, with its expiry", async () => { const { hash, body } = await signedBundle(); expect((await call("/admin/bundles/import", { method: "POST", body: JSON.stringify(body) })).status).toBe(401); const r = await adminJson("/admin/bundles/import", body); expect(r).toMatchObject({ status: 201, body: { ok: true, hash, notAfter: body.notAfter } }); const { device, token } = await enrol(); await adminJson("/admin/jobs", { device: device.id, bundle: hash, file: "scripts/send.aite", start: `${ORIGIN}/form.html`, allow: [ORIGIN] }); const got = (await (await call(`/api/bundles/${hash}`, { token })).json()) as { signature: string; notAfter: number }; expect(got).toMatchObject({ signature: body.signature, notAfter: body.notAfter }); }); it("a bundle import that is forged, expired, malformed or oversized is refused and nothing is kept", async () => { const { hash, body } = await signedBundle(); expect(await adminJson("/admin/bundles/import", { ...body, signature: "AAAA" })).toMatchObject({ status: 400 }); expect(await adminJson("/admin/bundles/import", { ...body, notAfter: body.notAfter + 1000 })).toMatchObject({ status: 400 }); expect(await adminJson("/admin/bundles/import", (await signedBundle(Date.now() - 1000)).body)).toMatchObject({ status: 400 }); expect(await adminJson("/admin/bundles/import", { ...body, files: ["x"] })).toMatchObject({ status: 400 }); expect(await adminJson("/admin/bundles/import", { signature: "x" })).toMatchObject({ status: 400 }); expect(await adminJson("/admin/bundles/import", { files: FILES })).toMatchObject({ status: 400 }); const huge = await call("/admin/bundles/import", { method: "POST", adminAuth: true, body: JSON.stringify({ files: { "scripts/a.aite": "x".repeat(9 * 1024 * 1024) } }), headers: { "content-type": "application/json" } }); expect(huge.status).toBe(413); expect(store.bundleByHash(hash)).toBeUndefined(); });});