Skip to content

Commit 86bdaec

Browse files
committed
fix(admin): harden maintenance actions
1 parent 3cd80fb commit 86bdaec

2 files changed

Lines changed: 81 additions & 29 deletions

File tree

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
import { renderToStaticMarkup } from "react-dom/server";
2-
import { describe, expect, it, vi } from "vitest";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
const systemStatus = {
5+
deployment: {
6+
dockerAvailable: true,
7+
composeRendered: true,
8+
hostControlConfigured: true,
9+
maintenanceReady: true,
10+
},
11+
images: [
12+
{
13+
id: "app-api",
14+
name: "OpenMapX API",
15+
image: "ghcr.io/openmapx/api:latest",
16+
containerState: "running",
17+
runningImageId: "sha256:old-image",
18+
localImageId: "sha256:new-image",
19+
updateAvailable: true,
20+
status: "update-available" as "update-available" | "up-to-date",
21+
},
22+
],
23+
};
324

425
vi.mock("@/lib/EnvProvider", () => ({ useEnv: () => ({ apiUrl: "http://api.test" }) }));
526
vi.mock("../shared/AdminToast", () => ({ useAdminToast: () => vi.fn() }));
@@ -9,31 +30,19 @@ vi.mock("@tanstack/react-query", () => ({
930
useQuery: (options: { queryKey: unknown[] }) =>
1031
options.queryKey[1] === "system"
1132
? {
12-
data: {
13-
deployment: {
14-
dockerAvailable: true,
15-
composeRendered: true,
16-
hostControlConfigured: true,
17-
maintenanceReady: true,
18-
},
19-
images: [
20-
{
21-
id: "app-api",
22-
name: "OpenMapX API",
23-
image: "ghcr.io/openmapx/api:latest",
24-
containerState: "running",
25-
runningImageId: "sha256:old-image",
26-
localImageId: "sha256:new-image",
27-
updateAvailable: true,
28-
status: "update-available",
29-
},
30-
],
31-
},
33+
data: systemStatus,
3234
isError: false,
3335
}
3436
: { data: undefined, isError: false },
3537
}));
3638

39+
afterEach(() => {
40+
systemStatus.images[0].runningImageId = "sha256:old-image";
41+
systemStatus.images[0].localImageId = "sha256:new-image";
42+
systemStatus.images[0].updateAvailable = true;
43+
systemStatus.images[0].status = "update-available";
44+
});
45+
3746
describe("SystemMaintenance", () => {
3847
it("renders staged image state and safe operator actions", async () => {
3948
const { SystemMaintenance } = await import("./SystemMaintenance");
@@ -45,4 +54,35 @@ describe("SystemMaintenance", () => {
4554
expect(markup).toContain("Deep diagnostics");
4655
expect(markup).toContain("Host-control readiness");
4756
});
57+
58+
it("does not send a JSON content type for bodyless maintenance actions", async () => {
59+
const { buildSystemJobRequestInit } = await import("./SystemMaintenance");
60+
61+
expect(buildSystemJobRequestInit()).toEqual({
62+
method: "POST",
63+
credentials: "include",
64+
});
65+
expect(buildSystemJobRequestInit({ confirmation: "UPDATE OPENMAPX" })).toEqual({
66+
method: "POST",
67+
credentials: "include",
68+
headers: { "Content-Type": "application/json" },
69+
body: JSON.stringify({ confirmation: "UPDATE OPENMAPX" }),
70+
});
71+
});
72+
73+
it("disables application updates when every core image is current", async () => {
74+
systemStatus.images[0].runningImageId = "sha256:current-image";
75+
systemStatus.images[0].localImageId = "sha256:current-image";
76+
systemStatus.images[0].updateAvailable = false;
77+
systemStatus.images[0].status = "up-to-date";
78+
79+
const { SystemMaintenance } = await import("./SystemMaintenance");
80+
const markup = renderToStaticMarkup(<SystemMaintenance />);
81+
82+
expect(
83+
/<button[^>]*disabled=""[^>]*title="All core images are up to date"[^>]*>[\s\S]*?Update OpenMapX/.test(
84+
markup,
85+
),
86+
).toBe(true);
87+
});
4888
});

apps/web/src/components/admin/system/SystemMaintenance.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ interface JobStatus {
7070
error: string | null;
7171
}
7272

73+
export function buildSystemJobRequestInit(body?: Record<string, unknown>): RequestInit {
74+
return {
75+
method: "POST",
76+
credentials: "include",
77+
...(body === undefined
78+
? {}
79+
: {
80+
headers: { "Content-Type": "application/json" },
81+
body: JSON.stringify(body),
82+
}),
83+
};
84+
}
85+
7386
function digest(value: string | null): string {
7487
if (!value) return "—";
7588
return value.replace(/^sha256:/, "").slice(0, 12);
@@ -137,12 +150,10 @@ export function SystemMaintenance() {
137150
body?: Record<string, unknown>;
138151
label: string;
139152
}) => {
140-
const response = await fetch(`${apiUrl}/api/admin/system/${path}`, {
141-
method: "POST",
142-
credentials: "include",
143-
headers: { "Content-Type": "application/json" },
144-
body: body ? JSON.stringify(body) : undefined,
145-
});
153+
const response = await fetch(
154+
`${apiUrl}/api/admin/system/${path}`,
155+
buildSystemJobRequestInit(body),
156+
);
146157
const result = (await response.json().catch(() => ({}))) as {
147158
jobId?: string;
148159
error?: string;
@@ -227,7 +238,8 @@ export function SystemMaintenance() {
227238
<Button
228239
variant="contained"
229240
startIcon={<UpdateIcon />}
230-
disabled={active || !status?.deployment.maintenanceReady}
241+
disabled={active || !status?.deployment.maintenanceReady || updateCount === 0}
242+
title={updateCount === 0 ? "All core images are up to date" : undefined}
231243
onClick={() => setDialogOpen(true)}
232244
>
233245
Update OpenMapX
@@ -365,7 +377,7 @@ export function SystemMaintenance() {
365377
variant="contained"
366378
color="warning"
367379
startIcon={<UpdateIcon />}
368-
disabled={confirmation !== CONFIRMATION || queueJob.isPending}
380+
disabled={confirmation !== CONFIRMATION || queueJob.isPending || updateCount === 0}
369381
onClick={() => {
370382
queueJob.mutate({
371383
path: "updates/apply",

0 commit comments

Comments
 (0)