Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/backend/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {});
Expand Down Expand Up @@ -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', {});
Expand Down Expand Up @@ -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: '',
});

/**
Expand Down Expand Up @@ -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
*/
Expand Down
52 changes: 52 additions & 0 deletions src/backend/stubs/controllers/health_controller.stub
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
}
10 changes: 10 additions & 0 deletions src/backend/stubs/routes/health.stub
Original file line number Diff line number Diff line change
@@ -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());
};
2 changes: 2 additions & 0 deletions src/backend/stubs/routes/routes.stub
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +31,7 @@ import settings from '#start/routes/settings';

auth();
api();
health();
uiTools();

// Private routes
Expand Down
161 changes: 161 additions & 0 deletions src/backend/stubs/tests/functional/health.stub
Original file line number Diff line number Diff line change
@@ -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<typeof db.rawQuery>) => 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);
});
});
11 changes: 10 additions & 1 deletion src/backend/stubs/tests/rest.stub
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,13 @@ Accept: application/json

###
GET \{\{ authority \}\}/api/v1/locale HTTP/1.1
Accept: application/json
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 \}\}
4 changes: 2 additions & 2 deletions src/backend/stubs/tests/unit/resource_service.stub
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]);
});

Expand Down
Loading