120 lines
7.2 KiB
TypeScript
120 lines
7.2 KiB
TypeScript
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}`],
|
|
["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("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<typeof createServer>[0]) {
|
|
const directory = await mkdtemp(`${tmpdir()}/fedeo-api-`);
|
|
const socket = `${directory}/control.sock`;
|
|
const server = createServer(handler);
|
|
await new Promise<void>(resolve => server.listen(socket, resolve));
|
|
t.after(async () => { server.closeAllConnections(); await new Promise<void>(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"));
|
|
});
|
|
}
|
|
});
|