diff --git a/src/backend/configure.ts b/src/backend/configure.ts index 88107a7f..28b2500a 100644 --- a/src/backend/configure.ts +++ b/src/backend/configure.ts @@ -120,12 +120,14 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'controllers/users_controller.stub', {}); await codemods.makeUsingStub(stubsRoot, 'controllers/settings_controller.stub', {}); await codemods.makeUsingStub(stubsRoot, 'controllers/locale_controller.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'controllers/health_controller.stub', {}); await codemods.makeUsingStub(stubsRoot, 'inertia/middleware.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/users.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/settings.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/auth.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'routes/health.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/routes.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/invitations.stub', {}); await codemods.makeUsingStub(stubsRoot, 'routes/dashboard.stub', {}); @@ -161,6 +163,7 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'tests/rest.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/ui.rest.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/functional/draft.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'tests/functional/health.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/unit/ui_service.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/unit/invitation_service.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/unit/page_service.stub', {}); @@ -197,6 +200,7 @@ export async function configure(command: Configure) { OPENAI_API_KEY: 'redacted', GOOGLE_APPLICATION_CREDENTIALS_JSON: 'redacted', FIREBASE_SERVICE_ACCOUNT_KEY_JSON: 'redacted', + HEALTH_CHECK_TOKEN: '', }); /** @@ -254,6 +258,13 @@ export async function configure(command: Configure) { leadingComment: 'Configuration for the Firebase service account key', }); + await codemods.defineEnvValidations({ + variables: { + HEALTH_CHECK_TOKEN: `Env.schema.string.optional(),`, + }, + leadingComment: 'Variables for configuring the Health check token', + }); + /** * Register providers */ diff --git a/src/backend/stubs/controllers/health_controller.stub b/src/backend/stubs/controllers/health_controller.stub new file mode 100644 index 00000000..71d27a70 --- /dev/null +++ b/src/backend/stubs/controllers/health_controller.stub @@ -0,0 +1,52 @@ +{{{ + exports({ to: app.makePath('app/controllers/health_controller.ts') }) +}}} +import type { HttpContext } from '@adonisjs/core/http'; +import db from '@adonisjs/lucid/services/db'; +import { DateTime } from 'luxon'; +import env from '#start/env'; + +export default class HealthController { + public async index({ request, response, logger }: HttpContext) { + if (request.input('extend') !== 'db') { + return response.ok({ status: 'ok' }); + } + + const healthCheckToken = env.get('HEALTH_CHECK_TOKEN'); + if ( + !healthCheckToken || + request.header('x-health-check-token') !== healthCheckToken + ) { + return response.notFound({ status: 'not_found' }); + } + + try { + await db.rawQuery('select 1'); + const databaseCheckedAt = DateTime.utc().toISO() as string; + + return response.ok({ + status: 'ok', + database: 'ok', + databaseCheckedAt, + }); + } catch (error) { + const databaseCheckedAt = DateTime.utc().toISO() as string; + + logger.error( + { + err: error, + component: 'database', + healthCheck: 'extended', + databaseCheckedAt, + }, + 'Database health check failed', + ); + + return response.serviceUnavailable({ + status: 'error', + database: 'error', + databaseCheckedAt, + }); + } + } +} diff --git a/src/backend/stubs/routes/health.stub b/src/backend/stubs/routes/health.stub new file mode 100644 index 00000000..b79c4074 --- /dev/null +++ b/src/backend/stubs/routes/health.stub @@ -0,0 +1,10 @@ +{{{ + exports({ to: app.makePath('start/routes/health.ts') }) +}}} +import router from '@adonisjs/core/services/router'; +import { middleware } from '#start/kernel'; +const HealthController = () => import('#controllers/health_controller'); + +export default () => { + router.get('/health', [HealthController, 'index']).use(middleware.noIndex()); +}; diff --git a/src/backend/stubs/routes/routes.stub b/src/backend/stubs/routes/routes.stub index 5feb9e47..19f48fb0 100644 --- a/src/backend/stubs/routes/routes.stub +++ b/src/backend/stubs/routes/routes.stub @@ -15,6 +15,7 @@ import { middleware } from '#start/kernel'; import api from '#start/routes/api'; import audience from '#start/routes/audience'; import auth from '#start/routes/auth'; +import health from '#start/routes/health'; import invitations from '#start/routes/invitations'; import dashboard from '#start/routes/dashboard'; import pages from '#start/routes/pages'; @@ -30,6 +31,7 @@ import settings from '#start/routes/settings'; auth(); api(); +health(); uiTools(); // Private routes diff --git a/src/backend/stubs/tests/functional/health.stub b/src/backend/stubs/tests/functional/health.stub new file mode 100644 index 00000000..0b544e6c --- /dev/null +++ b/src/backend/stubs/tests/functional/health.stub @@ -0,0 +1,161 @@ +{{{ + exports({ to: app.makePath('tests/functional/health.spec.ts') }) +}}} +import { test } from '@japa/runner'; +import db from '@adonisjs/lucid/services/db'; +import env from '#start/env'; + +const HEALTH_CHECK_TOKEN = 'test-health-check-token'; + +type RawQueryStub = (...args: Parameters) => unknown; + +function swapRawQuery(replacement: RawQueryStub) { + const original = db.rawQuery.bind(db); + db.rawQuery = replacement as typeof db.rawQuery; + return () => { + db.rawQuery = original; + }; +} + +test.group('GET /health', (group) => { + let previousHealthCheckToken: string | undefined; + let restoreRawQuery: (() => void) | undefined; + + group.each.setup(() => { + previousHealthCheckToken = env.get('HEALTH_CHECK_TOKEN'); + env.set('HEALTH_CHECK_TOKEN', HEALTH_CHECK_TOKEN); + }); + + group.each.teardown(() => { + restoreRawQuery?.(); + restoreRawQuery = undefined; + env.set('HEALTH_CHECK_TOKEN', previousHealthCheckToken ?? ''); + }); + + test('returns application liveness without checking the database', async ({ + assert, + client, + }) => { + let databaseChecks = 0; + restoreRawQuery = swapRawQuery(async () => { + databaseChecks++; + throw new Error('The basic health check must not query the database'); + }); + + const response = await client.get('/health'); + + response.assertStatus(200); + assert.deepEqual(response.body(), { status: 'ok' }); + assert.equal(databaseChecks, 0); + }); + + test('rejects an unauthenticated database check without querying the database', async ({ + assert, + client, + }) => { + let databaseChecks = 0; + restoreRawQuery = swapRawQuery(async () => { + databaseChecks++; + throw new Error('An unauthenticated check must not query the database'); + }); + + const response = await client.get('/health?extend=db'); + + response.assertStatus(404); + assert.deepEqual(response.body(), { status: 'not_found' }); + assert.equal(databaseChecks, 0); + }); + + test('rejects a wrong token without querying the database', async ({ + assert, + client, + }) => { + let databaseChecks = 0; + restoreRawQuery = swapRawQuery(async () => { + databaseChecks++; + throw new Error('A wrong token must not query the database'); + }); + + const response = await client + .get('/health?extend=db') + .header('x-health-check-token', 'wrong-token'); + + response.assertStatus(404); + assert.deepEqual(response.body(), { status: 'not_found' }); + assert.equal(databaseChecks, 0); + }); + + test('rejects extend=db when HEALTH_CHECK_TOKEN is not configured', async ({ + assert, + client, + }) => { + env.set('HEALTH_CHECK_TOKEN', ''); + + let databaseChecks = 0; + restoreRawQuery = swapRawQuery(async () => { + databaseChecks++; + throw new Error('Must not query when token env is missing'); + }); + + const response = await client.get('/health?extend=db'); + + response.assertStatus(404); + assert.deepEqual(response.body(), { status: 'not_found' }); + assert.equal(databaseChecks, 0); + }); + + test('runs an authenticated database check', async ({ assert, client }) => { + let databaseChecks = 0; + restoreRawQuery = swapRawQuery(async () => { + databaseChecks++; + return []; + }); + + const response = await client + .get('/health?extend=db') + .header('x-health-check-token', HEALTH_CHECK_TOKEN); + + response.assertStatus(200); + assert.equal(response.body().status, 'ok'); + assert.equal(response.body().database, 'ok'); + assert.isString(response.body().databaseCheckedAt); + assert.equal(databaseChecks, 1); + }); + + test('returns 503 when the authenticated database check fails', async ({ + assert, + client, + }) => { + restoreRawQuery = swapRawQuery(async () => { + throw new Error('connection refused'); + }); + + const response = await client + .get('/health?extend=db') + .header('x-health-check-token', HEALTH_CHECK_TOKEN); + + response.assertStatus(503); + assert.equal(response.body().status, 'error'); + assert.equal(response.body().database, 'error'); + assert.isString(response.body().databaseCheckedAt); + }); + + test('does not cache authenticated database checks', async ({ assert, client }) => { + let databaseChecks = 0; + restoreRawQuery = swapRawQuery(async () => { + databaseChecks++; + return []; + }); + + const first = await client + .get('/health?extend=db') + .header('x-health-check-token', HEALTH_CHECK_TOKEN); + const second = await client + .get('/health?extend=db') + .header('x-health-check-token', HEALTH_CHECK_TOKEN); + + first.assertStatus(200); + second.assertStatus(200); + assert.equal(databaseChecks, 2); + }); +}); diff --git a/src/backend/stubs/tests/rest.stub b/src/backend/stubs/tests/rest.stub index bd285fec..afa9df55 100644 --- a/src/backend/stubs/tests/rest.stub +++ b/src/backend/stubs/tests/rest.stub @@ -35,4 +35,13 @@ Accept: application/json ### GET \{\{ authority \}\}/api/v1/locale HTTP/1.1 -Accept: application/json \ No newline at end of file +Accept: application/json + +### +GET \{\{ authority \}\}/health HTTP/1.1 +Accept: application/json + +### +GET \{\{ authority \}\}/health?extend=db HTTP/1.1 +Accept: application/json +X-Health-Check-Token: \{\{ healthCheckToken \}\} \ No newline at end of file diff --git a/src/backend/stubs/tests/unit/resource_service.stub b/src/backend/stubs/tests/unit/resource_service.stub index 9b68e8c5..77494207 100644 --- a/src/backend/stubs/tests/unit/resource_service.stub +++ b/src/backend/stubs/tests/unit/resource_service.stub @@ -148,8 +148,8 @@ test.group('Resource service', (group) => { assert.lengthOf(usages, 2); assert.deepEqual(usages, [ - { storyId: alphaStory.id, title: 'Alpha Story' }, - { storyId: zebraStory.id, title: 'Zebra Story' }, + { id: alphaStory.id, title: 'Alpha Story' }, + { id: zebraStory.id, title: 'Zebra Story' }, ]); });