mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
fix(server): expose build version in health endpoint (#3404)
* fix(server): expose build version in health endpoint * fix(server): redact public health failure details
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { execute, healthcheck } = vi.hoisted(() => ({ execute: vi.fn(), healthcheck: vi.fn() }));
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: { execute } }));
|
||||
vi.mock("@reactive-resume/api/features/storage", () => ({ getStorageService: () => ({ healthcheck }) }));
|
||||
vi.mock("../app-version", () => ({ appVersion: "9.8.7" }));
|
||||
|
||||
import { handleHealth } from "./health";
|
||||
|
||||
describe("health version reporting", () => {
|
||||
beforeEach(() => {
|
||||
execute.mockResolvedValue([]);
|
||||
healthcheck.mockResolvedValue({ status: "healthy" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports the built application version when launched directly by Node", async () => {
|
||||
vi.stubEnv("npm_package_version", undefined);
|
||||
|
||||
const response = await handleHealth();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ service: "reactive-resume", version: "9.8.7", status: "healthy" });
|
||||
});
|
||||
|
||||
it("ignores a package manager's workspace package version", async () => {
|
||||
vi.stubEnv("npm_package_version", "0.0.0");
|
||||
|
||||
expect(await (await handleHealth()).json()).toMatchObject({ version: "9.8.7" });
|
||||
});
|
||||
|
||||
it("keeps the version available when a dependency is unhealthy", async () => {
|
||||
vi.stubEnv("npm_package_version", undefined);
|
||||
execute.mockRejectedValueOnce(new Error("Database unavailable"));
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const response = await handleHealth();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toMatchObject({ version: "9.8.7", status: "unhealthy" });
|
||||
});
|
||||
it.each(["database", "storage"])("keeps thrown %s error details in server logs only", async (dependency) => {
|
||||
const detail = "Connection failed for private-user at internal.example:5432";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
(dependency === "database" ? execute : healthcheck).mockRejectedValueOnce(new Error(detail));
|
||||
|
||||
const response = await handleHealth();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(JSON.stringify(body)).not.toContain(detail);
|
||||
expect(body[dependency]).toMatchObject({
|
||||
status: "unhealthy",
|
||||
error: expect.stringContaining("health check failed"),
|
||||
});
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[Healthcheck]",
|
||||
expect.objectContaining({
|
||||
[dependency]: expect.objectContaining({ error: detail }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts returned storage failures while preserving diagnostics in server logs", async () => {
|
||||
const detail = "Access denied to bucket private-bucket on internal.example";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
healthcheck.mockResolvedValueOnce({
|
||||
status: "unhealthy",
|
||||
type: "s3",
|
||||
message: detail,
|
||||
error: detail,
|
||||
internalDetail: detail,
|
||||
});
|
||||
|
||||
const response = await handleHealth();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(body.storage).toEqual({
|
||||
status: "unhealthy",
|
||||
type: "s3",
|
||||
latencyMs: expect.any(Number),
|
||||
error: "Storage health check failed.",
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain(detail);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[Healthcheck]",
|
||||
expect.objectContaining({ storage: expect.objectContaining({ error: detail, message: detail }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
|
||||
import { withTimeout } from "es-toolkit";
|
||||
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
||||
|
||||
@@ -31,6 +32,16 @@ async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
function publicCheck(check: CheckResult, name: "Database" | "Storage"): CheckResult {
|
||||
if (check.status === "healthy") return check;
|
||||
return {
|
||||
status: check.status,
|
||||
latencyMs: check.latencyMs,
|
||||
error: `${name} health check failed.`,
|
||||
...(check.type === "local" || check.type === "s3" ? { type: check.type } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ponytail: inner try/catches removed; runCheck's outer catch handles all errors
|
||||
async function checkDatabase() {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
@@ -45,12 +56,12 @@ export async function handleHealth() {
|
||||
|
||||
const checks = {
|
||||
service: "reactive-resume",
|
||||
version: process.env.npm_package_version,
|
||||
version: appVersion,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database,
|
||||
storage,
|
||||
database: publicCheck(database, "Database"),
|
||||
storage: publicCheck(storage, "Storage"),
|
||||
};
|
||||
|
||||
if (status === "unhealthy") {
|
||||
|
||||
@@ -80,6 +80,29 @@ describe("generateOpenApiSpec", () => {
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("documents the public health endpoint at its actual URL", async () => {
|
||||
const spec = await generateSpec();
|
||||
const health = spec.paths?.["/api/health"]?.get;
|
||||
|
||||
expect(health).toMatchObject({
|
||||
operationId: "getHealth",
|
||||
security: [],
|
||||
servers: [{ url: "https://rxresu.me" }],
|
||||
});
|
||||
for (const status of ["200", "503"]) {
|
||||
expect(health?.responses?.[status]).toMatchObject({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
required: expect.arrayContaining(["service", "version", "status"]),
|
||||
properties: { version: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the canonical input-side ResumeData schema in update requests", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OpenAPI } from "@orpc/openapi";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
@@ -50,6 +51,31 @@ type GenerateOpenApiSpecOptions = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
const healthDependencySchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["healthy", "unhealthy"] },
|
||||
latencyMs: { type: "number" },
|
||||
error: { type: "string", description: "Generic failure message. Detailed diagnostics are logged on the server." },
|
||||
},
|
||||
required: ["status", "latencyMs"],
|
||||
additionalProperties: true,
|
||||
} satisfies OpenAPI.SchemaObject;
|
||||
|
||||
const healthResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
service: { type: "string", enum: ["reactive-resume"] },
|
||||
version: { type: "string", description: "The running application's build version." },
|
||||
status: { type: "string", enum: ["healthy", "unhealthy"] },
|
||||
timestamp: { type: "string", format: "date-time" },
|
||||
uptime: { type: "string" },
|
||||
database: healthDependencySchema,
|
||||
storage: healthDependencySchema,
|
||||
},
|
||||
required: ["service", "version", "status", "timestamp", "uptime", "database", "storage"],
|
||||
} satisfies OpenAPI.SchemaObject;
|
||||
|
||||
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
|
||||
return await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
@@ -60,6 +86,28 @@ export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSp
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
servers: [{ url: `${appUrl}/api/openapi` }],
|
||||
paths: {
|
||||
"/api/health": {
|
||||
get: {
|
||||
operationId: "getHealth",
|
||||
tags: ["System"],
|
||||
summary: "Get application health and version",
|
||||
description: "Checks database and storage availability. Does not require authentication.",
|
||||
servers: [{ url: appUrl }],
|
||||
security: [],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The application and its dependencies are healthy.",
|
||||
content: { "application/json": { schema: healthResponseSchema } },
|
||||
},
|
||||
"503": {
|
||||
description: "One or more application dependencies are unhealthy.",
|
||||
content: { "application/json": { schema: healthResponseSchema } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema, strategy: "input" },
|
||||
|
||||
+204
@@ -28878,6 +28878,210 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"operationId": "getHealth",
|
||||
"tags": [
|
||||
"System"
|
||||
],
|
||||
"summary": "Get application health and version",
|
||||
"description": "Checks database and storage availability. Does not require authentication.",
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://rxresu.me"
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The application and its dependencies are healthy.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reactive-resume"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The running application's build version."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"healthy",
|
||||
"unhealthy"
|
||||
]
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"uptime": {
|
||||
"type": "string"
|
||||
},
|
||||
"database": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"healthy",
|
||||
"unhealthy"
|
||||
]
|
||||
},
|
||||
"latencyMs": {
|
||||
"type": "number"
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Generic failure message. Detailed diagnostics are logged on the server."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"latencyMs"
|
||||
],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"storage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"healthy",
|
||||
"unhealthy"
|
||||
]
|
||||
},
|
||||
"latencyMs": {
|
||||
"type": "number"
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Generic failure message. Detailed diagnostics are logged on the server."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"latencyMs"
|
||||
],
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"service",
|
||||
"version",
|
||||
"status",
|
||||
"timestamp",
|
||||
"uptime",
|
||||
"database",
|
||||
"storage"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "One or more application dependencies are unhealthy.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reactive-resume"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The running application's build version."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"healthy",
|
||||
"unhealthy"
|
||||
]
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"uptime": {
|
||||
"type": "string"
|
||||
},
|
||||
"database": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"healthy",
|
||||
"unhealthy"
|
||||
]
|
||||
},
|
||||
"latencyMs": {
|
||||
"type": "number"
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Generic failure message. Detailed diagnostics are logged on the server."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"latencyMs"
|
||||
],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"storage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"healthy",
|
||||
"unhealthy"
|
||||
]
|
||||
},
|
||||
"latencyMs": {
|
||||
"type": "number"
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Generic failure message. Detailed diagnostics are logged on the server."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"latencyMs"
|
||||
],
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"service",
|
||||
"version",
|
||||
"status",
|
||||
"timestamp",
|
||||
"uptime",
|
||||
"database",
|
||||
"storage"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user