224 lines
7.9 KiB
TypeScript
224 lines
7.9 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
MaintenanceService,
|
|
maintenanceMiddleware,
|
|
maintenanceRoute,
|
|
normalizeMaintenanceHost,
|
|
type MaintenancePersistence,
|
|
} from "../../server/maintenance";
|
|
import { createApp } from "../../server/app";
|
|
import { hashToken, MemoryAuthStore } from "../../server/auth";
|
|
|
|
class MemoryPersistence implements MaintenancePersistence {
|
|
state: { hosts: string[] } | undefined;
|
|
resources: Record<string, unknown>[] = [];
|
|
deleted = 0;
|
|
routePresent = false;
|
|
failApply = false;
|
|
failWrite = false;
|
|
async readState() {
|
|
return this.state;
|
|
}
|
|
async writeState(value: { hosts: string[] }) {
|
|
if (this.failWrite) throw new Error("state write failed");
|
|
this.state = value;
|
|
}
|
|
async routeExists() {
|
|
return this.routePresent;
|
|
}
|
|
async apply(resource: Record<string, unknown>) {
|
|
if (this.failApply) throw new Error("route apply failed");
|
|
this.resources.push(resource);
|
|
if (resource.kind === "IngressRoute") this.routePresent = true;
|
|
}
|
|
async deleteRoute() {
|
|
this.deleted += 1;
|
|
this.routePresent = false;
|
|
}
|
|
}
|
|
|
|
const lease = { acquire: async () => ({ release: async () => {} }) };
|
|
|
|
describe("maintenance override", () => {
|
|
test("normalizes only DNS hostnames", () => {
|
|
expect(normalizeMaintenanceHost(" Sub.Domain.COM. ")).toBe(
|
|
"sub.domain.com",
|
|
);
|
|
for (const host of [
|
|
"http://example.com",
|
|
"example.com:443",
|
|
"127.0.0.1",
|
|
"[::1]",
|
|
"*.example.com",
|
|
"example",
|
|
"a..com",
|
|
])
|
|
expect(() => normalizeMaintenanceHost(host)).toThrow();
|
|
});
|
|
|
|
test("enables, deduplicates, and disables the last host", async () => {
|
|
const persistence = new MemoryPersistence();
|
|
const service = new MaintenanceService(persistence, lease);
|
|
expect((await service.status("a.example.com")).enabled).toBe(false);
|
|
expect((await service.toggle("A.example.com")).hosts).toEqual([
|
|
"a.example.com",
|
|
]);
|
|
persistence.state = {
|
|
hosts: ["a.example.com", "b.example.com", "A.example.com"],
|
|
};
|
|
expect((await service.toggle("a.example.com")).hosts).toEqual([
|
|
"b.example.com",
|
|
]);
|
|
expect((await service.toggle("b.example.com")).hosts).toEqual([]);
|
|
expect(persistence.deleted).toBe(2);
|
|
expect(persistence.state).toEqual({ hosts: [] });
|
|
});
|
|
|
|
test("renders the single shared error route and rewrite middleware", () => {
|
|
expect(maintenanceMiddleware()).toMatchObject({
|
|
metadata: { name: "maintenance-override", namespace: "routing" },
|
|
spec: {
|
|
replacePathRegex: { regex: "^/.*", replacement: "/__error/1001" },
|
|
},
|
|
});
|
|
expect(maintenanceRoute(["a.example.com", "b.example.com"])).toMatchObject({
|
|
metadata: { name: "maintenance-override", namespace: "routing" },
|
|
spec: {
|
|
routes: [
|
|
{
|
|
match: "Host(`a.example.com`) || Host(`b.example.com`)",
|
|
priority: 1_000_000,
|
|
services: [
|
|
{
|
|
name: "error-page",
|
|
namespace: "routing",
|
|
port: 3000,
|
|
scheme: "http",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
test("maps an unavailable global lease to a retryable API conflict", async () => {
|
|
const persistence = new MemoryPersistence();
|
|
const store = new MemoryAuthStore();
|
|
await store.putUser({ username: "operator", passwordHash: "hash", roles: ["operator"] });
|
|
await store.putSession({ tokenHash: hashToken("operator-token"), username: "operator", roles: ["operator"], expiresAt: "2099-01-01T00:00:00.000Z" });
|
|
const app = createApp({
|
|
store,
|
|
maintenance: new MaintenanceService(persistence, { acquire: async () => undefined }),
|
|
});
|
|
const result = await app(new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
|
method: "POST",
|
|
headers: { authorization: "Bearer operator-token", "content-type": "application/json" },
|
|
body: JSON.stringify({ enabled: true }),
|
|
}));
|
|
expect(result.status).toBe(409);
|
|
expect(result.headers.get("retry-after")).toBe("1");
|
|
expect(await result.json()).toMatchObject({ code: "MAINTENANCE_BUSY" });
|
|
expect(persistence.state).toBeUndefined();
|
|
});
|
|
|
|
test("recovers the persisted desired state after route or state failures", async () => {
|
|
const persistence = new MemoryPersistence();
|
|
const service = new MaintenanceService(persistence, lease);
|
|
persistence.failApply = true;
|
|
await expect(service.set("a.example.com", true)).rejects.toThrow("route apply failed");
|
|
expect(persistence.state).toBeUndefined();
|
|
persistence.failApply = false;
|
|
expect(await service.status("a.example.com")).toMatchObject({ enabled: false });
|
|
|
|
persistence.failWrite = true;
|
|
await expect(service.set("a.example.com", true)).rejects.toThrow("state write failed");
|
|
expect(persistence.routePresent).toBe(true);
|
|
persistence.failWrite = false;
|
|
expect(await service.status("a.example.com")).toMatchObject({ enabled: false });
|
|
expect(persistence.routePresent).toBe(false);
|
|
|
|
expect(await service.set("a.example.com", true)).toMatchObject({ enabled: true });
|
|
persistence.routePresent = false;
|
|
expect(await service.status("a.example.com")).toMatchObject({ enabled: true });
|
|
expect(persistence.routePresent).toBe(true);
|
|
});
|
|
|
|
test("requires kubernetes write without workspace or trust context", async () => {
|
|
const store = new MemoryAuthStore();
|
|
await store.putUser({
|
|
username: "viewer",
|
|
passwordHash: "hash",
|
|
roles: ["viewer"],
|
|
});
|
|
await store.putUser({
|
|
username: "operator",
|
|
passwordHash: "hash",
|
|
roles: ["operator"],
|
|
});
|
|
for (const token of ["viewer-token", "operator-token"])
|
|
await store.putSession({
|
|
tokenHash: hashToken(token),
|
|
username: token.startsWith("viewer") ? "viewer" : "operator",
|
|
roles: token.startsWith("viewer") ? ["viewer"] : ["operator"],
|
|
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
});
|
|
await store.createApiKey({
|
|
id: "maintenance-scoped-key",
|
|
tokenHash: hashToken("scoped-key"),
|
|
username: "operator",
|
|
capabilities: ["kubernetes:write"],
|
|
workspace: "shop",
|
|
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
});
|
|
await store.createApiKey({
|
|
id: "maintenance-global-key",
|
|
tokenHash: hashToken("global-key"),
|
|
username: "operator",
|
|
capabilities: ["kubernetes:write"],
|
|
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
});
|
|
const persistence = new MemoryPersistence();
|
|
const app = createApp({
|
|
store,
|
|
maintenance: new MaintenanceService(persistence, lease),
|
|
});
|
|
const viewer = await app(
|
|
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
|
headers: { authorization: "Bearer viewer-token" },
|
|
}),
|
|
);
|
|
expect(viewer.status).toBe(403);
|
|
const scoped = await app(
|
|
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
|
headers: { authorization: "Bearer scoped-key" },
|
|
}),
|
|
);
|
|
expect(scoped.status).toBe(403);
|
|
expect(await scoped.json()).toMatchObject({ code: "MAINTENANCE_GLOBAL_SCOPE_REQUIRED" });
|
|
const global = await app(
|
|
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
|
headers: { authorization: "Bearer global-key" },
|
|
}),
|
|
);
|
|
expect(global.status).toBe(200);
|
|
const status = await app(
|
|
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
|
headers: { authorization: "Bearer operator-token" },
|
|
}),
|
|
);
|
|
expect(await status.json()).toMatchObject({ enabled: false });
|
|
const toggle = await app(
|
|
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: "Bearer operator-token",
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ enabled: true }),
|
|
}),
|
|
);
|
|
expect(await toggle.json()).toMatchObject({ enabled: true });
|
|
});
|
|
});
|