import assert from "node:assert/strict"; import test from "node:test"; import { createServer } from "node:http"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import Fastify from "fastify"; import routes from "../src/routes/selfhostUpdates"; import { createUpdaterClient } from "../src/modules/selfhost-updater"; import authPlugin from "../src/plugins/auth"; import { secrets } from "../src/utils/secrets"; import jwt from "jsonwebtoken"; const id = "12345678-1234-4123-a123-123456789abc"; const headers = { authorization: "Bearer verified-jwt-token" }; async function fixture(t: any, admin = true, authenticated = true) { const app = Fastify(); const calls: unknown[] = []; let currentAdmin = admin; // Mirror the parent auth plugin's preHandler order, not an earlier onRequest hook. app.addHook("preHandler", async req => { if (authenticated) req.user = { user_id: id, is_admin: true } as any; }); app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: currentAdmin }] }) }) }) }); await app.register(routes, { client: async (...args) => { calls.push(args); return { status: 202, body: { status: "queued", requestId: id } }; } }); t.after(() => app.close()); return { app, calls, revoke: () => { currentAdmin = false; } }; } test("all update endpoints require a current global admin, including after revocation", async t => { const f = await fixture(t); assert.equal((await f.app.inject({ url: "/admin/updates/status" })).statusCode, 202); f.revoke(); for (const [method, url] of [["GET", "/admin/updates/status"], ["GET", `/admin/updates/requests/${id}`], ["GET", `/admin/updates/requests/${id}/logs`], ["POST", "/admin/updates/check"], ["POST", "/admin/updates/apply"]] as const) { assert.equal((await f.app.inject({ method, url, headers, payload: method === "POST" ? { requestId: id } : undefined })).statusCode, 403); } assert.equal(f.calls.length, 1); }); test("admin log access uses the fixed request route and does not cache responses", async t => { const f = await fixture(t); const response = await f.app.inject({ url: `/admin/updates/requests/${id}/logs` }); assert.equal(response.statusCode, 202); assert.equal(response.headers["cache-control"], "no-store"); assert.deepEqual(f.calls[0], ["GET", `/v1/requests/${id}/logs`, undefined]); assert.equal((await f.app.inject({ url: "/admin/updates/requests/bad.id/logs" })).statusCode, 400); const anonymous = await fixture(t, true, false); assert.equal((await anonymous.app.inject({ url: `/admin/updates/requests/${id}/logs` })).statusCode, 401); }); test("unauthenticated and cookie-only requests cannot start jobs", async t => { const anonymous = await fixture(t, true, false); assert.equal((await anonymous.app.inject({ url: "/admin/updates/status" })).statusCode, 401); const f = await fixture(t); for (const authorization of [undefined, "Bearer short", "bearer long-but-not-used-by-auth"]) { assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check", headers: authorization ? { authorization } : {}, payload: { requestId: id } })).statusCode, 403); } assert.equal(f.calls.length, 0); }); test("fixed routes reject injected options, malformed IDs and unreviewed apply requests", async t => { const f = await fixture(t); for (const payload of [{ requestId: id, directory: "/root" }, { requestId: id, version: "latest" }, { requestId: "../../etc/passwd" }, { requestId: id, acceptComposeReplacement: true }]) { assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check", headers, payload })).statusCode, 400); } assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers, payload: { requestId: id, version: "3.2.0" } })).statusCode, 400); assert.equal(f.calls.length, 0); const response = await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers, payload: { requestId: id, version: "3.2.0", checkRequestId: "checked-request" } }); assert.equal(response.statusCode, 202); assert.equal(response.headers["cache-control"], "no-store"); assert.deepEqual(f.calls[0], ["POST", "/v1/apply", { requestId: id, version: "3.2.0", checkRequestId: "checked-request" }]); }); test("real authentication requires a valid header JWT even when a valid cookie exists", async t => { const app = Fastify(); t.after(() => app.close()); const previous = secrets.JWT_SECRET; secrets.JWT_SECRET = "update-route-test-secret"; t.after(() => { secrets.JWT_SECRET = previous; }); const token = jwt.sign({ user_id: id, tenant_id: null, is_admin: true }, secrets.JWT_SECRET, { expiresIn: "5m" }); app.addHook("onRequest", async req => { req.cookies = { token }; }); app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: true }] }) }) }) }); await app.register(authPlugin); let forwarded = 0; await app.register(routes, { client: async () => { forwarded++; return { status: 202, body: { status: "queued" } }; } }); for (const authorization of ["Bearer x", "bearer ignored-token-with-valid-cookie", "Bearer invalid-long-jwt-token"]) { const result = await app.inject({ method: "POST", url: "/admin/updates/check", headers: { authorization }, payload: { requestId: id } }); assert.ok([401, 403].includes(result.statusCode)); } assert.equal(forwarded, 0); assert.equal((await app.inject({ method: "POST", url: "/admin/updates/check", headers: { authorization: `Bearer ${token}` }, payload: { requestId: id } })).statusCode, 202); assert.equal(forwarded, 1); }); async function socketFixture(t: any, handler: Parameters[0]) { const directory = await mkdtemp(`${tmpdir()}/fedeo-api-`); const socket = `${directory}/control.sock`; const server = createServer(handler); await new Promise(resolve => server.listen(socket, resolve)); t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); await rm(directory, { recursive: true, force: true }); }); return createUpdaterClient(socket, 150); } test("Unix client strips private fields and never forwards credentials or raw error messages", async t => { const client = await socketFixture(t, (req, res) => { assert.equal(req.headers.authorization, undefined); assert.equal(req.headers.cookie, undefined); if (req.url === "/v1/status") res.end(JSON.stringify({ apiVersion: 1, acceptingRequests: true, engine: { status: "idle", job: "/secret/path", error: "password" }, installed: {}, secret: "token" })); else { res.writeHead(409); res.end(JSON.stringify({ error: { code: "PLAN_CHANGED", message: "password" } })); } }); const response = await client("GET", "/v1/status"); assert.deepEqual(response.body, { apiVersion: 1, acceptingRequests: true, engine: { status: "idle" }, installed: {} }); await assert.rejects(client("POST", "/v1/apply", {}), { code: "PLAN_CHANGED", status: 409, message: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen." }); await assert.rejects(client("GET", "http://example.com"), { code: "INVALID_REQUEST" }); }); test("Unix client bounds response size, deadline, and protocol shape", async t => { for (const scenario of ["oversize", "timeout", "invalid"] as const) { await t.test(scenario, async t => { const client = await socketFixture(t, (_req, res) => { if (scenario === "oversize") res.end("x".repeat(70000)); else if (scenario === "invalid") res.end('{"apiVersion":2}'); }); await assert.rejects(client("GET", "/v1/status"), (error: any) => ["UPDATER_UNAVAILABLE", "UPDATER_PROTOCOL"].includes(error.code) && !error.message.includes("ENOENT")); }); } }); test("provider status and plan cross the socket boundary without private storage configuration", async t => { const client = await socketFixture(t, (_req, res) => res.end(JSON.stringify({ apiVersion: 1, acceptingRequests: true, engine: { status: "recovered", phase: "resolved" }, installed: {}, storage: { provider: "seaweedfs", migrationPending: false, service: { secret: "hidden" } }, request: { status: "checked", plan: { updateAvailable: true, requiresComposeAdoption: false, retiredServices: [], storageProvider: "seaweedfs", storageImageRetained: true, storageBackupPaths: ["/private/path"] } }, }))); const { body } = await client("GET", "/v1/status") as { body: any }; assert.deepEqual(body.storage, { provider: "seaweedfs", migrationPending: false }); assert.equal(body.request.plan.storageProvider, "seaweedfs"); assert.equal(body.request.plan.storageImageRetained, true); assert.ok(!JSON.stringify(body).includes("hidden")); assert.ok(!JSON.stringify(body).includes("private/path")); }); test("Unix client validates bounded log responses and their request association", async t => { let payload: any = { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true, path: "/private/commands.log" }], private: "omitted" }; const client = await socketFixture(t, (_req, res) => res.end(JSON.stringify(payload))); const response = await client("GET", `/v1/requests/${id}/logs`); assert.deepEqual(response.body, { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true }] }); for (const invalid of [ { ...payload, requestId: "different-request" }, { ...payload, logs: [{ source: "env", content: "secret", truncated: false }] }, { ...payload, logs: [{ source: "commands", content: "x".repeat(6001), truncated: false }] }, ]) { payload = invalid; await assert.rejects(client("GET", `/v1/requests/${id}/logs`), { code: "UPDATER_PROTOCOL" }); } await assert.rejects(client("GET", `/v1/requests/${id}/logs?path=/etc/passwd`), { code: "INVALID_REQUEST" }); });