mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 10:06:42 +02:00
Expose process liveness without database or credential queries while preserving hostname and runtime identity guards. Deep health and sleep/wake readiness checks retain their existing behavior. Co-Authored-By: Paperclip <noreply@paperclip.ing>
110 lines
5.4 KiB
TypeScript
110 lines
5.4 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { livenessRoutes } from "../routes/liveness.js";
|
|
import { healthRoutes } from "../routes/health.js";
|
|
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
|
import { cloudRuntimeIdentityMiddleware } from "../middleware/cloud-runtime-identity.js";
|
|
import { cloudWarmStandbyMiddleware } from "../middleware/cloud-warm-standby.js";
|
|
import { idleAdmissionMiddleware, trackIdleRequestHandlers } from "../middleware/idle-admission.js";
|
|
import { idleWorkSnapshot, startTaskDrain, stopTaskDrain } from "../services/task-admission.js";
|
|
import { resetStartupRecoveryStateForTests, setStartupRecoveryPhase } from "../startup-recovery-state.js";
|
|
import { getServerInfoSnapshot } from "../server-info.js";
|
|
|
|
afterEach(() => { stopTaskDrain(); resetStartupRecoveryStateForTests(); });
|
|
|
|
function fixture(standby = false) {
|
|
const db = { execute: vi.fn().mockRejectedValue(new Error("database offline")) } as unknown as Db;
|
|
const auth = vi.fn((_req, _res, next) => next());
|
|
const health = healthRoutes(db, {
|
|
deploymentMode: "authenticated", deploymentExposure: "public", authReady: true,
|
|
companyDeletionEnabled: false, runtimeEnv: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "fixture-token" },
|
|
isWarmStandby: () => standby,
|
|
});
|
|
const app = express();
|
|
app.use(idleAdmissionMiddleware);
|
|
app.use(privateHostnameGuard({ enabled: true, allowedHostnames: ["allowed.example"], bindHost: "0.0.0.0" }));
|
|
app.use(cloudRuntimeIdentityMiddleware(db));
|
|
app.use("/api/liveness", livenessRoutes());
|
|
app.use(cloudWarmStandbyMiddleware(() => standby, health));
|
|
app.use(auth);
|
|
app.use("/api/health", health);
|
|
app.get("/api/private", (_req, res) => res.sendStatus(401));
|
|
trackIdleRequestHandlers(app);
|
|
const http = request.agent(app).set("host", "allowed.example");
|
|
return { app, http, db, auth };
|
|
}
|
|
|
|
describe("database-free process liveness", () => {
|
|
it.each([false, true])("does not resolve auth or query SQL with an unavailable database (standby: %s)", async standby => {
|
|
const f = fixture(standby);
|
|
for (let i = 0; i < 3; i++) {
|
|
const response = await f.http.get("/api/liveness")
|
|
.set("authorization", "Bearer invalid-fixture")
|
|
.set("cookie", "session_token=invalid-fixture")
|
|
.set("x-paperclip-cloud-tenant-token", "invalid-fixture");
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers["cache-control"]).toBe("no-store");
|
|
const serverInfo = getServerInfoSnapshot();
|
|
expect(response.body).toEqual({ status: "ok", commit: serverInfo.git.available ? serverInfo.git.fullSha : null });
|
|
}
|
|
const head = await f.http.head("/api/liveness").expect(200);
|
|
expect(head.text).toBeUndefined();
|
|
expect(f.auth).not.toHaveBeenCalled();
|
|
expect(f.db.execute).not.toHaveBeenCalled();
|
|
expect(idleWorkSnapshot().active).toBe(0);
|
|
if (!standby) {
|
|
await f.http.get("/api/health").expect(503);
|
|
expect(f.db.execute).toHaveBeenCalledOnce();
|
|
await f.http.get("/api/private").expect(401);
|
|
expect(f.auth).toHaveBeenCalledTimes(2);
|
|
}
|
|
});
|
|
|
|
it.each(["starting", "recovering"] as const)("reports %s until startup recovery is complete", async phase => {
|
|
const f = fixture();
|
|
setStartupRecoveryPhase(phase);
|
|
await f.http.get("/api/liveness").expect(503).expect(response => expect(response.body.status).toBe("starting"));
|
|
setStartupRecoveryPhase("ready");
|
|
await f.http.get("/api/liveness").expect(200);
|
|
expect(f.auth).not.toHaveBeenCalled();
|
|
expect(f.db.execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retains hostname and signed bootstrap endpoint validation", async () => {
|
|
const f = fixture();
|
|
await f.http.get("/api/liveness").set("host", "denied.example").expect(403);
|
|
await f.http.get("/api/liveness").set("x-paperclip-cloud-runtime-identity", "invalid-fixture")
|
|
.expect(400).expect({ error: "cloud_runtime_identity_wrong_endpoint" });
|
|
expect(f.auth).not.toHaveBeenCalled();
|
|
expect(f.db.execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stays reachable during drain without exempting writes or other paths", async () => {
|
|
const f = fixture();
|
|
const before = idleWorkSnapshot();
|
|
startTaskDrain({ purpose: "idle", ttlMs: 60_000 });
|
|
for (const path of ["/api/liveness", "/api/liveness/"]) {
|
|
await f.http.get(path).expect(200);
|
|
await f.http.head(path).expect(200);
|
|
await f.http.post(path).expect(503);
|
|
}
|
|
await f.http.get("/api/liveness/details").expect(503);
|
|
await f.http.get("/api/private").expect(503);
|
|
expect(idleWorkSnapshot()).toEqual(before);
|
|
expect(f.auth).not.toHaveBeenCalled();
|
|
expect(f.db.execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("mounts the production route after host and bootstrap validation and before standby/auth", () => {
|
|
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
|
|
const route = source.indexOf('app.use("/api/liveness", livenessRoutes())');
|
|
expect(route).toBeGreaterThan(source.indexOf("privateHostnameGuard({"));
|
|
expect(route).toBeGreaterThan(source.indexOf("app.use(cloudRuntimeIdentityMiddleware(db))"));
|
|
expect(route).toBeLessThan(source.indexOf("app.use(cloudWarmStandbyMiddleware("));
|
|
expect(route).toBeLessThan(source.indexOf("actorMiddleware(db,"));
|
|
});
|
|
});
|