From 69df7b0ab727bfbe00783f7bf9897f2450db7269 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sun, 19 Apr 2026 10:10:05 +0200 Subject: [PATCH] feat: add health check endpoint Add Heroku-idiomatic /health endpoint that verifies: - App status (200 response) - Database connectivity (SELECT 1 query) - ?type=startup param for fast Heroku startup checks - Latency tracking in all responses Detects database type by checking object capabilities (.query vs .prepare) to support both SQLite and PostgreSQL, as well as test injection. Error responses follow RFC 9457 (Problem Details): - Content-Type: application/problem+json - Fields: type, title, status, detail, timestamp, checks, latency Middleware registrations reordered for health (no auth required). --- package-lock.json | 69 +++++++++++ package.json | 1 + src/app/app.js | 28 ++++- src/app/routes/health.js | 105 ++++++++++++++++ test/features/health.test.js | 224 +++++++++++++++++++++++++++++++++++ 5 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 src/app/routes/health.js create mode 100644 test/features/health.test.js diff --git a/package-lock.json b/package-lock.json index 8837437..0dbff98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "fallow": "^2.40.3", "globals": "^17.4.0", "prettier": "^3.8.1", + "sinon": "^21.1.2", "tap": "^21.6.3" } }, @@ -1341,6 +1342,47 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", + "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5725,6 +5767,23 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sinon": { + "version": "21.1.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.1.2.tgz", + "integrity": "sha512-FS6mN+/bx7e2ajpXkEmOcWB6xBzWiuNoAQT18/+a20SS4U7FSYl8Ms7N6VTUxN/1JAjkx7aXp+THMC8xdpp0gA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.3.2", + "@sinonjs/samsam": "^10.0.2", + "diff": "^8.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -6497,6 +6556,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", diff --git a/package.json b/package.json index d47fde5..9cb593d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "fallow": "^2.40.3", "globals": "^17.4.0", "prettier": "^3.8.1", + "sinon": "^21.1.2", "tap": "^21.6.3" } } diff --git a/src/app/app.js b/src/app/app.js index ff481be..9c18ce4 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -2,7 +2,9 @@ import { Hono } from "hono"; import { pinoLogger } from "hono-pino"; import { serveStatic } from "@hono/node-server/serve-static"; +import { db } from "../auth.js"; import authHandler from "./routes/auth.js"; +import healthHandler from "./routes/health.js"; import homeHandler from "./routes/home.js"; import profileHandler from "./routes/profile.js"; import whoamiHandler from "./routes/whoami.js"; @@ -11,7 +13,15 @@ import adminHandler from "./routes/admin.js"; // demo import demoHandler from "./demo/routes.js"; -export function createApp(auth) { +/** + * Creates the Hono application instance + * @param {Object} auth - Better Auth instance + * @param {Object|null} [injectedDb] - Optional database to inject for testing. + * When null, health endpoint returns 503 (database unavailable). + * When undefined (not provided), uses the default db from auth.js. + * @returns {Hono} Configured Hono application + */ +export function createApp(auth, injectedDb) { // Validate auth instance if (!auth?.api?.getSession) { throw new Error("Invalid auth instance passed to createApp"); @@ -35,12 +45,21 @@ export function createApp(auth) { 'req.headers["user-agent"]', ], }, + // Skip logging for health endpoints to reduce noise + // Returns true to skip logging, false to log + onRequest: (c) => { + return c.req.path === "/health"; + }, }), ); - // Auth context middleware - makes auth available to all routes + // Auth and DB context middleware + // injectedDb === undefined: use default db (production) + // injectedDb === null: health endpoint sees null (test null scenario) + // injectedDb is object: use injected db (test fake scenarios) app.use("*", (c, next) => { c.set("auth", auth); + c.set("db", injectedDb !== undefined ? injectedDb : db); return next(); }); @@ -49,6 +68,9 @@ export function createApp(auth) { return auth.handler(c.req.raw); }); + // Health check routes (no session required) + app.route("/", healthHandler); + // Session middleware - adds user and session to context app.use("*", async (c, next) => { const authInstance = c.get("auth"); @@ -57,8 +79,6 @@ export function createApp(auth) { headers: c.req.raw.headers, }); - console.log(session); - c.set("user", session?.user || null); c.set("session", session?.session || null); } catch (error) { diff --git a/src/app/routes/health.js b/src/app/routes/health.js new file mode 100644 index 0000000..6d98214 --- /dev/null +++ b/src/app/routes/health.js @@ -0,0 +1,105 @@ +import { Hono } from "hono"; + +const healthHandler = new Hono(); + +healthHandler.get("/health", async (c) => { + const timestamp = new Date().toISOString(); + const startTime = Date.now(); + + // Get database from context (allows injection for testing) + const db = c.get("db"); + + // Determine database type by checking object capabilities + // PostgreSQL/Kysely has .query(), better-sqlite3 has .prepare() + const isPostgres = typeof db?.query === "function"; + + // Check for Heroku startup health check (skip DB check for fast startup) + const isStartupCheck = c.req.query("type") === "startup"; + + try { + if (isStartupCheck) { + return c.json( + { + status: 200, + timestamp, + latency: Date.now() - startTime, + checks: { + app: { status: "up" }, + }, + }, + 200, + { "Content-Type": "application/json" }, + ); + } + + if (!db) { + return c.json( + { + type: "/problems/database-unavailable", + title: "Database Unavailable", + status: 503, + detail: "Database connection is not available", + timestamp, + latency: Date.now() - startTime, + checks: { + app: { status: "up" }, + database: { + status: "disconnected", + message: "Database not available", + }, + }, + }, + 503, + { "Content-Type": "application/problem+json" }, + ); + } + + // Check database connectivity + if (isPostgres) { + // PostgreSQL: use pool query + await db.query("SELECT 1"); + } else { + // SQLite: use prepare/get + db.prepare("SELECT 1").get(); + } + + return c.json( + { + status: 200, + timestamp, + latency: Date.now() - startTime, + checks: { + app: { status: "up" }, + database: { + status: "connected", + type: isPostgres ? "postgresql" : "sqlite", + }, + }, + }, + 200, + { "Content-Type": "application/json" }, + ); + } catch (error) { + return c.json( + { + type: "/problems/database-unavailable", + title: "Database Unavailable", + status: 503, + detail: error.message, + timestamp, + latency: Date.now() - startTime, + checks: { + app: { status: "up" }, + database: { + status: "disconnected", + message: error.message, + }, + }, + }, + 503, + { "Content-Type": "application/problem+json" }, + ); + } +}); + +export default healthHandler; diff --git a/test/features/health.test.js b/test/features/health.test.js new file mode 100644 index 0000000..36c7818 --- /dev/null +++ b/test/features/health.test.js @@ -0,0 +1,224 @@ +import { test } from "tap"; +import sinon from "sinon"; +import { getTestInstance } from "../helpers/test-instance.js"; +import { createApp } from "../../src/app/app.js"; + +test("health endpoint feature tests", async (t) => { + t.test("GET /health returns 200 with healthy status", async (t) => { + const testInstance = await getTestInstance(); + const app = createApp(testInstance.auth, testInstance.db); + + const res = await app.request("/health"); + + t.equal(res.status, 200, "returns 200 OK"); + + const body = await res.json(); + t.equal(body.status, 200, "status is 200"); + t.ok(body.timestamp, "has timestamp"); + t.ok(body.checks, "has checks"); + t.equal(body.checks.app.status, "up", "app status is up"); + t.equal(body.checks.database.status, "connected", "database is connected"); + t.ok( + ["sqlite", "postgresql"].includes(body.checks.database.type), + "database type is valid", + ); + }); + + t.test("GET /health returns JSON with correct structure", async (t) => { + const testInstance = await getTestInstance(); + const app = createApp(testInstance.auth, testInstance.db); + + const res = await app.request("/health"); + const body = await res.json(); + + t.ok(body.status, "has status field"); + t.ok(body.timestamp, "has timestamp field"); + t.ok("latency" in body, "has latency field"); + t.ok(body.checks, "has checks field"); + t.ok(body.checks.app, "has checks.app field"); + t.equal(body.checks.app.status, "up", "app status is up"); + t.ok(body.checks.database, "has checks.database field"); + t.equal(body.checks.database.status, "connected", "database is connected"); + t.ok(body.checks.database.type, "has database type"); + }); + + t.test("timestamp is valid ISO 8601 format", async (t) => { + const testInstance = await getTestInstance(); + const app = createApp(testInstance.auth, testInstance.db); + + const res = await app.request("/health"); + const body = await res.json(); + + const date = new Date(body.timestamp); + t.ok(!isNaN(date.getTime()), "timestamp is valid ISO 8601"); + }); + + t.test( + "GET /health?type=startup returns 200 with minimal checks", + async (t) => { + const testInstance = await getTestInstance(); + const app = createApp(testInstance.auth, testInstance.db); + + const res = await app.request("/health?type=startup"); + + t.equal(res.status, 200, "returns 200 OK"); + const body = await res.json(); + t.equal(body.status, 200, "status is 200"); + t.ok("latency" in body, "has latency field"); + t.equal(body.checks.app.status, "up", "app status is up"); + t.notOk(body.checks.database, "no database check on startup"); + }, + ); +}); + +test("health endpoint failure states", async (t) => { + t.test( + "returns 503 with RFC 9457 format when database query fails", + async (t) => { + // Create fake db - test instance uses SQLite (has .prepare(), no .query()) + const fakeGet = sinon.fake.throws(new Error("database is locked")); + const fakePrepare = sinon.fake.returns({ + get: fakeGet, + }); + const fakeDb = { + prepare: fakePrepare, + }; + + // Create app with injected fake database + const testInstance = await getTestInstance(); + const app = createApp(testInstance.auth, fakeDb); + + const res = await app.request("/health"); + + t.equal(res.status, 503, "returns 503 Service Unavailable"); + t.match( + res.headers.get("content-type"), + /application\/problem\+json/, + "content-type is application/problem+json", + ); + + const body = await res.json(); + t.equal(body.type, "/problems/database-unavailable", "has RFC 9457 type"); + t.equal(body.title, "Database Unavailable", "has RFC 9457 title"); + t.equal(body.status, 503, "has RFC 9457 status"); + t.ok(body.detail, "has RFC 9457 detail"); + t.ok(body.timestamp, "has timestamp"); + t.ok(body.checks, "has checks extension"); + t.equal(body.checks.app.status, "up", "app status is up"); + t.equal( + body.checks.database.status, + "disconnected", + "database is disconnected", + ); + + // Verify specific error message for SQLite + t.match( + body.checks.database.message, + /database is locked/, + "error message matches expected", + ); + + // Verify appropriate fake was called + t.ok(fakePrepare.calledOnce, "prepare was called"); + t.ok(fakeGet.calledOnce, "get was called"); + }, + ); + + t.test( + "returns 503 with RFC 9457 format on generic database error", + async (t) => { + // Create fake db that throws error (SQLite pattern) + const fakeDb = { + prepare: () => { + throw new Error("database connection failed"); + }, + }; + + // Create app with injected fake database + const testInstance = await getTestInstance(); + const app = createApp(testInstance.auth, fakeDb); + + const res = await app.request("/health"); + + t.equal(res.status, 503, "returns 503 Service Unavailable"); + t.match( + res.headers.get("content-type"), + /application\/problem\+json/, + "content-type is application/problem+json", + ); + + const body = await res.json(); + t.equal(body.type, "/problems/database-unavailable", "has RFC 9457 type"); + t.equal(body.title, "Database Unavailable", "has RFC 9457 title"); + t.equal(body.status, 503, "has RFC 9457 status"); + t.ok(body.timestamp, "has timestamp"); + t.ok(body.checks, "has checks extension"); + t.equal(body.checks.app.status, "up", "app status is up"); + + // Verify specific error message for SQLite + t.match( + body.detail, + /database connection failed/, + "error detail matches expected for sqlite", + ); + }, + ); + + t.test("returns 503 when database is null", async (t) => { + // Create app with null database + const testInstance = await getTestInstance(); + const app = createApp(testInstance.auth, null); + + const res = await app.request("/health"); + + t.equal(res.status, 503, "returns 503 Service Unavailable"); + + const body = await res.json(); + t.equal(body.type, "/problems/database-unavailable", "has RFC 9457 type"); + t.equal(body.title, "Database Unavailable", "has RFC 9457 title"); + t.equal(body.status, 503, "has RFC 9457 status"); + t.equal( + body.detail, + "Database connection is not available", + "detail says database not available", + ); + t.equal(body.checks.app.status, "up", "app status is up"); + t.equal( + body.checks.database.status, + "disconnected", + "database is disconnected", + ); + t.equal( + body.checks.database.message, + "Database not available", + "error message is correct", + ); + }); + + t.test("returns 503 when PostgreSQL-style db query fails", async (t) => { + // Create fake db that has .query() method (PostgreSQL-style) + const fakeQuery = sinon.fake.rejects(new Error("connection refused")); + const fakeDb = { + query: fakeQuery, + }; + + const testInstance = await getTestInstance(); + const app = createApp(testInstance.auth, fakeDb); + + const res = await app.request("/health"); + + t.equal(res.status, 503, "returns 503 Service Unavailable"); + const body = await res.json(); + t.equal( + body.checks.database.status, + "disconnected", + "database is disconnected", + ); + t.match( + body.checks.database.message, + /connection refused/, + "error message matches", + ); + t.ok(fakeQuery.calledOnce, "query was called"); + }); +});