diff --git a/auth/package.json b/auth/package.json index 9eef257..6f7d303 100644 --- a/auth/package.json +++ b/auth/package.json @@ -7,7 +7,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "generate": "npx @better-auth/cli generate" + "generate": "npx @better-auth/cli generate", + "test": "node --test src/__tests__/*.test.ts" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/auth/src/__tests__/health.test.ts b/auth/src/__tests__/health.test.ts new file mode 100644 index 0000000..efb66fd --- /dev/null +++ b/auth/src/__tests__/health.test.ts @@ -0,0 +1,117 @@ +import { describe, it } from 'node:test'; +import { equal } from 'node:assert'; +import http from 'node:http'; + +describe('Auth health endpoint', () => { + const startHealthServer = (poolMock) => { + return new Promise((resolve) => { + const server = http.createServer(async (req, res) => { + if (req.url === '/health' && req.method === 'GET') { + try { + const client = await poolMock.connect(); + try { + await Promise.race([ + client.query('SELECT 1'), + new Promise((_, reject) => setTimeout(() => reject(new Error('DB timeout')), 2000)), + ]); + } finally { + client.release(); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', db: 'reachable' })); + } catch { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'error', db: 'unreachable' })); + } + return; + } + res.writeHead(404); + res.end(); + }); + server.listen(0, '0.0.0.0', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve({ port, close: () => server.close() }); + }); + }); + }; + + const makeRequest = (port) => { + return new Promise((resolve) => { + const req = http.get(`http://localhost:${port}/health`, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + resolve({ status: res.statusCode, body }); + }); + }); + req.on('error', () => resolve({ status: 0, body: '' })); + }); + }; + + it('returns 200 with db=reachable when pool.connect succeeds', async () => { + const mockClient = { + query: async () => ({ rows: [{ 1: 1 }] }), + release: () => {}, + }; + const poolMock = { + connect: async () => mockClient, + }; + + const { port, close } = await startHealthServer(poolMock); + const { status, body } = await makeRequest(port); + close(); + + equal(status, 200); + equal(body, '{"status":"ok","db":"reachable"}'); + }); + + it('returns 503 with db=unreachable when pool.connect throws', async () => { + const poolMock = { + connect: async () => { throw new Error('connection refused'); }, + }; + + const { port, close } = await startHealthServer(poolMock); + const { status, body } = await makeRequest(port); + close(); + + equal(status, 503); + equal(body, '{"status":"error","db":"unreachable"}'); + }); + + it('returns 503 with db=unreachable when query times out', async () => { + const mockClient = { + query: async () => { + await new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)); + }, + release: () => {}, + }; + const poolMock = { + connect: async () => mockClient, + }; + + const { port, close } = await startHealthServer(poolMock); + const { status, body } = await makeRequest(port); + close(); + + equal(status, 503); + equal(body, '{"status":"error","db":"unreachable"}'); + }); + + it('returns a terminal response for unknown paths (no hang)', async () => { + const poolMock = { connect: async () => ({ query: async () => {}, release: () => {} }) }; + const { port, close } = await startHealthServer(poolMock); + + const result = await new Promise<{ status: number }>((resolve) => { + const req = http.get(`http://localhost:${port}/`, (res) => { + res.resume(); + res.on('end', () => resolve({ status: res.statusCode ?? 0 })); + }); + req.on('error', () => resolve({ status: 0 })); + setTimeout(() => resolve({ status: -1 }), 1000); + }); + close(); + + equal(result.status !== -1, true, 'Unknown path must return a terminal response within 1s'); + }); +}); \ No newline at end of file diff --git a/auth/src/index.ts b/auth/src/index.ts index 708d91d..71448e1 100644 --- a/auth/src/index.ts +++ b/auth/src/index.ts @@ -8,7 +8,7 @@ const handler = toNodeHandler(auth); const server = createServer(async (req, res) => { // Health check - if (req.url === "/health" && req.method === "GET") { + if ((req.url === "/health" || req.url === "/auth/health") && req.method === "GET") { try { const client = await pool.connect(); try { @@ -20,7 +20,7 @@ const server = createServer(async (req, res) => { client.release(); } res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "ok", db: "connected" })); + res.end(JSON.stringify({ status: "ok", db: "reachable" })); } catch { res.writeHead(503, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "error", db: "unreachable" })); @@ -28,7 +28,7 @@ const server = createServer(async (req, res) => { return; } - // All /auth/* routes handled by Better-Auth + // All other routes handled by Better-Auth (returns 404 for unknown paths) await handler(req, res); }); diff --git a/vitest.config.ts b/vitest.config.ts index 30c96d0..d2f29f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,6 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - exclude: ['e2e/**', 'node_modules/**'], + exclude: ['e2e/**', 'auth/**', 'node_modules/**'], }, })