diff --git a/package.json b/package.json index 3fd2eeb..2b34c48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paperclip-adapter-opencode-k8s", - "version": "0.1.27", + "version": "0.1.28", "description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs", "license": "MIT", "type": "module", diff --git a/src/server/k8s-client.test.ts b/src/server/k8s-client.test.ts new file mode 100644 index 0000000..5ec0042 --- /dev/null +++ b/src/server/k8s-client.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Regression coverage for FAR-85: the @kubernetes/client-node v1.x ApiException + * exposes the HTTP status as `code`, not `statusCode`. The previous `isNotFound` + * predicate only checked `statusCode`/`response.statusCode`, so real 404s were + * never recognized — `getPvc` re-threw the 404 instead of returning null, and + * `ensureAgentDbPvc`'s existence check died before the create path ran. + * + * These tests mock the underlying k8s SDK and feed `getPvc`/`deletePvc` errors + * shaped exactly like the real ApiException so the predicate is exercised + * end-to-end, not in isolation. + */ + +vi.mock("@kubernetes/client-node", () => { + // Reproduces the real @kubernetes/client-node v1.x ApiException shape: + // HTTP status under `code`, plus `body` and `headers`. Defined inside the + // factory because vi.mock() is hoisted above any module-level declarations. + class ApiException extends Error { + code: number; + body: T; + headers: Record; + constructor(code: number, message: string, body: T, headers: Record = {}) { + super(`HTTP-Code: ${code}\nMessage: ${message}\nBody: ${JSON.stringify(body)}`); + this.code = code; + this.body = body; + this.headers = headers; + } + } + class KubeConfig { + loadFromCluster() {} + loadFromFile() {} + makeApiClient() { + return { + readNamespacedPersistentVolumeClaim: mockReadNamespacedPVC, + deleteNamespacedPersistentVolumeClaim: mockDeleteNamespacedPVC, + createNamespacedPersistentVolumeClaim: mockCreateNamespacedPVC, + }; + } + } + return { + KubeConfig, + CoreV1Api: class {}, + BatchV1Api: class {}, + AuthorizationV1Api: class {}, + Log: class {}, + ApiException, + }; +}); + +const mockReadNamespacedPVC = vi.fn(); +const mockDeleteNamespacedPVC = vi.fn(); +const mockCreateNamespacedPVC = vi.fn(); + +import * as k8s from "@kubernetes/client-node"; +import { getPvc, createPvc, deletePvc, resetCache } from "./k8s-client.js"; + +const ApiException = (k8s as unknown as { ApiException: new (code: number, message: string, body: T, headers?: Record) => Error & { code: number; body: T } }).ApiException; + +beforeEach(() => { + resetCache(); + vi.resetAllMocks(); +}); + +describe("getPvc — 404 detection (FAR-85 regression)", () => { + const NAMESPACE = "paperclip"; + const NAME = "opencode-db-test"; + + it("returns the PVC on success", async () => { + const pvc = { metadata: { name: NAME, namespace: NAMESPACE } }; + mockReadNamespacedPVC.mockResolvedValue(pvc); + const result = await getPvc(NAMESPACE, NAME); + expect(result).toEqual(pvc); + expect(mockReadNamespacedPVC).toHaveBeenCalledWith({ name: NAME, namespace: NAMESPACE }); + }); + + it("returns null when the SDK throws ApiException with code=404 (v1.x shape)", async () => { + mockReadNamespacedPVC.mockRejectedValue( + new ApiException(404, "Unknown API Status Code!", { + kind: "Status", + status: "Failure", + message: `persistentvolumeclaims "${NAME}" not found`, + reason: "NotFound", + code: 404, + }), + ); + const result = await getPvc(NAMESPACE, NAME); + expect(result).toBeNull(); + }); + + it("returns null for legacy errors with statusCode=404", async () => { + mockReadNamespacedPVC.mockRejectedValue(Object.assign(new Error("not found"), { statusCode: 404 })); + expect(await getPvc(NAMESPACE, NAME)).toBeNull(); + }); + + it("returns null for legacy errors with response.statusCode=404", async () => { + mockReadNamespacedPVC.mockRejectedValue(Object.assign(new Error("not found"), { response: { statusCode: 404 } })); + expect(await getPvc(NAMESPACE, NAME)).toBeNull(); + }); + + it("re-throws non-404 ApiException (e.g. 500)", async () => { + const err = new ApiException(500, "Internal Error", { message: "boom" }); + mockReadNamespacedPVC.mockRejectedValue(err); + await expect(getPvc(NAMESPACE, NAME)).rejects.toBe(err); + }); + + it("re-throws 403 (Forbidden) — must not be silently masked as missing", async () => { + const err = new ApiException(403, "Forbidden", { message: "rbac denied" }); + mockReadNamespacedPVC.mockRejectedValue(err); + await expect(getPvc(NAMESPACE, NAME)).rejects.toBe(err); + }); +}); + +describe("deletePvc — 404 detection", () => { + const NAMESPACE = "paperclip"; + const NAME = "opencode-db-test"; + + it("swallows ApiException with code=404 (already gone)", async () => { + mockDeleteNamespacedPVC.mockRejectedValue( + new ApiException(404, "Unknown API Status Code!", { reason: "NotFound" }), + ); + await expect(deletePvc(NAMESPACE, NAME)).resolves.toBeUndefined(); + }); + + it("re-throws non-404 errors", async () => { + const err = new ApiException(409, "Conflict", { reason: "Conflict" }); + mockDeleteNamespacedPVC.mockRejectedValue(err); + await expect(deletePvc(NAMESPACE, NAME)).rejects.toBe(err); + }); +}); + +describe("createPvc — passes through to SDK", () => { + it("forwards the spec to createNamespacedPersistentVolumeClaim", async () => { + const spec = { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { name: "opencode-db-x", namespace: "paperclip" }, + spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "1Gi" } } }, + }; + mockCreateNamespacedPVC.mockResolvedValue(spec); + const result = await createPvc("paperclip", spec as never); + expect(result).toEqual(spec); + expect(mockCreateNamespacedPVC).toHaveBeenCalledWith({ namespace: "paperclip", body: spec }); + }); +});