Add allowlisted diagnostic jobs and MG4 ADB plan
This commit is contained in:
@@ -175,4 +175,51 @@ describe("authentication boundary", () => {
|
||||
const response = await app.inject({ method: "GET", url: "/api/system/update" });
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("exposes only allowlisted jobs and authenticated run history", async () => {
|
||||
const app = await createApp();
|
||||
const token = await csrf(app);
|
||||
await setup(app, token);
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
headers: mutationHeaders(token),
|
||||
payload: { username: "owner", password: "Correct-horse1" }
|
||||
});
|
||||
const session = login.cookies.find((item) => item.name === SESSION_COOKIE)?.value ?? "";
|
||||
const cookie = `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${session}`;
|
||||
|
||||
expect((await app.inject({ method: "GET", url: "/api/jobs" })).statusCode).toBe(401);
|
||||
const jobs = await app.inject({ method: "GET", url: "/api/jobs", headers: { cookie } });
|
||||
expect(jobs.statusCode).toBe(200);
|
||||
expect(jobs.json<{ jobs: { id: string }[] }>().jobs.map((job) => job.id)).toEqual([
|
||||
"refresh-health",
|
||||
"network-diagnostics",
|
||||
"support-bundle"
|
||||
]);
|
||||
|
||||
const unknown = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/jobs/arbitrary-shell/runs",
|
||||
headers: { ...mutationHeaders(token), cookie }
|
||||
});
|
||||
expect(unknown.statusCode).toBe(404);
|
||||
expect(unknown.json()).toMatchObject({ error: { code: "JOB_NOT_FOUND" } });
|
||||
|
||||
const queued = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/jobs/refresh-health/runs",
|
||||
headers: { ...mutationHeaders(token), cookie }
|
||||
});
|
||||
expect(queued.statusCode).toBe(202);
|
||||
const runId = queued.json<{ id: string }>().id;
|
||||
let run: { status: string } = { status: "queued" };
|
||||
for (let attempt = 0; attempt < 50 && ["queued", "running"].includes(run.status); attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
const response = await app.inject({ method: "GET", url: `/api/job-runs/${runId}`, headers: { cookie } });
|
||||
expect(response.statusCode).toBe(200);
|
||||
run = response.json<{ status: string }>();
|
||||
}
|
||||
expect(run.status).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { openDatabase, type CompanionDatabase } from "../src/database.js";
|
||||
import { JobRunner, type JobDefinition, type JobRun } from "../src/jobs.js";
|
||||
|
||||
const databases: CompanionDatabase[] = [];
|
||||
const directories: string[] = [];
|
||||
|
||||
function database(path = ":memory:"): CompanionDatabase {
|
||||
const instance = openDatabase(path);
|
||||
databases.push(instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
function runnerWith(run: JobDefinition["run"], timeoutMs = 100): JobRunner {
|
||||
const instance = database();
|
||||
instance
|
||||
.prepare("INSERT INTO users (id, username, password_hash, role, created_at) VALUES (1, 'tester', 'unused', 'admin', ?)")
|
||||
.run(new Date().toISOString());
|
||||
return new JobRunner(instance, [
|
||||
{
|
||||
id: "test-job",
|
||||
title: "Test job",
|
||||
description: "Test only",
|
||||
timeoutMs,
|
||||
riskLevel: "low",
|
||||
confirmationRequired: false,
|
||||
run
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
async function settled(runner: JobRunner, id: string): Promise<JobRun> {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
const run = runner.getRun(id);
|
||||
if (run && !["queued", "running"].includes(run.status)) return run;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
throw new Error("Job did not settle during the test");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const instance of databases.splice(0)) {
|
||||
if (instance.open) instance.close();
|
||||
}
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("job runner", () => {
|
||||
it("runs only registered jobs and persists successful output", async () => {
|
||||
const runner = runnerWith(async () => ({ ok: true }));
|
||||
const statuses: string[] = [];
|
||||
const unsubscribe = runner.onRun((run) => statuses.push(run.status));
|
||||
|
||||
expect(runner.has("test-job")).toBe(true);
|
||||
expect(runner.has("shell-command")).toBe(false);
|
||||
expect(() => runner.enqueue("shell-command", 1)).toThrow("UNKNOWN_JOB");
|
||||
|
||||
const queued = runner.enqueue("test-job", 1);
|
||||
const run = await settled(runner, queued.id);
|
||||
expect(run).toMatchObject({ jobId: "test-job", status: "succeeded", output: { ok: true } });
|
||||
expect(statuses).toEqual(["queued", "running", "succeeded"]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("redacts likely secrets and bounds stored output", async () => {
|
||||
const secretRunner = runnerWith(async () => ({
|
||||
password: "do-not-store",
|
||||
message: "authorization=abc123",
|
||||
nested: { accessToken: "also-secret" }
|
||||
}));
|
||||
const secretRun = await settled(secretRunner, secretRunner.enqueue("test-job", 1).id);
|
||||
expect(secretRun.output).toEqual({
|
||||
password: "[REDACTED]",
|
||||
message: "[REDACTED]",
|
||||
nested: { accessToken: "[REDACTED]" }
|
||||
});
|
||||
|
||||
const largeRunner = runnerWith(async () => ({ value: "x".repeat(40_000) }));
|
||||
const largeRun = await settled(largeRunner, largeRunner.enqueue("test-job", 1).id);
|
||||
expect(largeRun.output).toMatchObject({ truncated: true });
|
||||
});
|
||||
|
||||
it("marks jobs that exceed their timeout", async () => {
|
||||
const runner = runnerWith(() => new Promise(() => undefined), 5);
|
||||
const run = await settled(runner, runner.enqueue("test-job", 1).id);
|
||||
expect(run).toMatchObject({ status: "timed_out", errorCode: "JOB_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("marks incomplete persisted runs as interrupted on restart", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "pi-car-jobs-"));
|
||||
directories.push(directory);
|
||||
const path = join(directory, "companion.db");
|
||||
const first = database(path);
|
||||
first
|
||||
.prepare("INSERT INTO job_runs (id, job_id, status, created_at) VALUES ('run-1', 'test-job', 'running', ?)")
|
||||
.run(new Date().toISOString());
|
||||
first.close();
|
||||
|
||||
const reopened = database(path);
|
||||
const row = reopened.prepare("SELECT status, error_code FROM job_runs WHERE id = 'run-1'").get();
|
||||
expect(row).toEqual({ status: "interrupted", error_code: "PROCESS_RESTARTED" });
|
||||
});
|
||||
|
||||
it("generates a manifest-based support bundle without account or session values", async () => {
|
||||
const instance = database();
|
||||
instance
|
||||
.prepare("INSERT INTO users (id, username, password_hash, role, created_at) VALUES (1, 'private-owner', 'private-hash', 'admin', ?)")
|
||||
.run(new Date().toISOString());
|
||||
instance
|
||||
.prepare("INSERT INTO sessions (id_hash, user_id, expires_at, created_at) VALUES ('private-session', 1, ?, ?)")
|
||||
.run(new Date(Date.now() + 60_000).toISOString(), new Date().toISOString());
|
||||
const runner = new JobRunner(instance);
|
||||
|
||||
const run = await settled(runner, runner.enqueue("support-bundle", 1).id);
|
||||
expect(run.status).toBe("succeeded");
|
||||
expect(run.output).toMatchObject({
|
||||
manifest: { format: "pi-car-companion-support-bundle", version: 1 },
|
||||
diagnostics: { database: { tableCounts: { users: 1, sessions: 1 } } }
|
||||
});
|
||||
const serialized = JSON.stringify(run.output);
|
||||
expect(serialized).not.toContain("private-owner");
|
||||
expect(serialized).not.toContain("private-hash");
|
||||
expect(serialized).not.toContain("private-session");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user