From 453c7e2dc445448609390856691871061ea73d1d Mon Sep 17 00:00:00 2001 From: Fabian Meyer <3982806+meyfa@users.noreply.github.com> Date: Sat, 20 Jul 2024 00:30:53 +0200 Subject: [PATCH] test: Set up integration testing This patch adds the capability to perform integration testing of the entire server code, including frontend/backend, by making requests against instances started for each test case. Some initial tests are added to check static file serving and authentication of a backend route. --- integration/backend/trigger.test.ts | 15 ++++ integration/fixtures.ts | 73 ++++++++++++++++++ integration/frontend.test.ts | 112 ++++++++++++++++++++++++++++ package.json | 4 +- tsconfig.lint.json | 3 +- 5 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 integration/backend/trigger.test.ts create mode 100644 integration/fixtures.ts create mode 100644 integration/frontend.test.ts diff --git a/integration/backend/trigger.test.ts b/integration/backend/trigger.test.ts new file mode 100644 index 0000000..b4c2a61 --- /dev/null +++ b/integration/backend/trigger.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert' +import { cleanup, startTestServer } from '../fixtures.js' + +describe('/api/trigger', () => { + afterEach(async () => await cleanup()) + + it('requires authentication', async () => { + const { origin } = await startTestServer() + + const response = await fetch(`${origin}/api/trigger`, { method: 'POST' }) + assert.strictEqual(response.status, 403) + assert.strictEqual(response.headers.get('Content-Type'), 'application/json; charset=utf-8') + assert.deepStrictEqual(await response.json(), { error: 'Forbidden' }) + }) +}) diff --git a/integration/fixtures.ts b/integration/fixtures.ts new file mode 100644 index 0000000..3af0b3f --- /dev/null +++ b/integration/fixtures.ts @@ -0,0 +1,73 @@ +import pino, { BaseLogger } from 'pino' +import { KubeConfig } from '@kubernetes/client-node' +import { startServer } from '../src/server.js' +import { createConfig } from '../src/config.js' + +const testServerPort = 3333 +const testClusterPort = 56443 + +const cleanupFunctions: Array<() => Promise> = [] + +/** + * Clean up any resources created during tests. This should be called after each test, + * i.e., in an `afterEach` hook. + */ +export async function cleanup (): Promise { + for (const cleanupFunction of cleanupFunctions) { + await cleanupFunction() + } + cleanupFunctions.splice(0, cleanupFunctions.length) +} + +interface TestServerResult { + origin: string +} + +/** + * Start a server for integration tests. The server will be closed when `cleanup` is called, + * which should be done after each test. + * + * @returns Information about the server. + */ +export async function startTestServer (): Promise { + const closeFn = await startServer({ + log: getTestLogger(), + config: createConfig(), + port: testServerPort, + kubeConfig: getTestKubeConfig() + }) + cleanupFunctions.push(closeFn) + return { + origin: `http://127.0.0.1:${testServerPort}` + } +} + +function getTestLogger (): BaseLogger { + return pino({ level: 'silent' }) +} + +function getTestKubeConfig (): KubeConfig { + const kubeConfig = new KubeConfig() + kubeConfig.loadFromOptions({ + clusters: [ + { + name: 'test-cluster', + server: `http://127.0.0.1:${testClusterPort}` + } + ], + contexts: [ + { + name: 'test-context', + cluster: 'test-cluster', + user: 'test-user' + } + ], + users: [ + { + name: 'test-user' + } + ], + currentContext: 'test-context' + }) + return kubeConfig +} diff --git a/integration/frontend.test.ts b/integration/frontend.test.ts new file mode 100644 index 0000000..3b1a017 --- /dev/null +++ b/integration/frontend.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert' +import { cleanup, startTestServer } from './fixtures.js' + +describe('frontend', () => { + afterEach(async () => await cleanup()) + + it('serves index.html by default', async () => { + const { origin } = await startTestServer() + + for (const path of ['/', '/index.html', '/foo', '/foo/bar']) { + const getResponse = await fetch(`${origin}${path}`) + assert.strictEqual(getResponse.status, 200) + + const text = await getResponse.text() + assert.ok(text.includes('Foreman')) + + // should set proper headers + const { headers } = getResponse + assert.strictEqual(headers.get('Content-Security-Policy'), "default-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'") + assert.strictEqual(headers.get('X-Frame-Options'), 'DENY') + assert.strictEqual(headers.get('X-Content-Type-Options'), 'nosniff') + assert.strictEqual(headers.get('Referrer-Policy'), 'no-referrer') + assert.strictEqual(headers.get('Cache-Control'), 'public, max-age=0') + assert.strictEqual(headers.get('Content-Type'), 'text/html; charset=UTF-8') + + // should also respond to HEAD requests + const headResponse = await fetch(`${origin}${path}`, { method: 'HEAD' }) + assert.strictEqual(headResponse.status, 200) + assert.strictEqual(headResponse.headers.get('Content-Type'), headers.get('Content-Type')) + assert.strictEqual(headResponse.headers.get('Content-Length'), headers.get('Content-Length')) + } + }) + + it('has no inline scripts or styles', async () => { + // Inline CSS/JS is a security risk, and is disallowed by the Content Security Policy. + + const { origin } = await startTestServer() + + const response = await fetch(`${origin}/`) + assert.strictEqual(response.status, 200) + + const text = await response.text() + + // script tags with content (vs. references to external scripts) + assert.doesNotMatch(text, /]*>[^<]+<\/script>/i) + // inline event handlers + assert.doesNotMatch(text, /\bon[a-z]+=/i) + // style tags + assert.doesNotMatch(text, /