Skip to content

Commit

Permalink
test: Set up integration testing (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
meyfa authored Jul 20, 2024
1 parent a257d4b commit 5168852
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 2 deletions.
15 changes: 15 additions & 0 deletions integration/backend/trigger.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
})
})
73 changes: 73 additions & 0 deletions integration/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<void>> = []

/**
* 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<void> {
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<TestServerResult> {
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
}
112 changes: 112 additions & 0 deletions integration/frontend.test.ts
Original file line number Diff line number Diff line change
@@ -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('<title>Foreman</title>'))

// 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[^>]*>[^<]+<\/script>/i)
// inline event handlers
assert.doesNotMatch(text, /\bon[a-z]+=/i)
// style tags
assert.doesNotMatch(text, /<style/i)
// style attributes
assert.doesNotMatch(text, /style=/i)
})

it('references external scripts and styles', async () => {
const { origin } = await startTestServer()

const response = await fetch(`${origin}/`)
assert.strictEqual(response.status, 200)

const text = await response.text()

const stylesheet = text.match(/<link[^>]*\shref="([^"]+)"/i)
assert.ok(stylesheet)
assert.match(stylesheet[1], /^\/assets\/index-.+\.css$/)

const script = text.match(/<script[^>]*\ssrc="([^"]+)"/i)
assert.ok(script)
assert.match(script[1], /^\/assets\/index-.+\.js$/)
})

it('serves static files', async () => {
const { origin } = await startTestServer()

const favicon = await fetch(`${origin}/assets/favicon.ico`)
assert.strictEqual(favicon.status, 200)
assert.strictEqual(favicon.headers.get('Content-Type'), 'image/vnd.microsoft.icon')

const robots = await fetch(`${origin}/robots.txt`)
assert.strictEqual(robots.status, 200)
assert.strictEqual(robots.headers.get('Content-Type'), 'text/plain; charset=UTF-8')
assert.strictEqual(await robots.text(), 'User-agent: *\nDisallow: /\n')

const webmanifest = await fetch(`${origin}/assets/manifest.webmanifest`)
assert.strictEqual(webmanifest.status, 200)
assert.strictEqual(webmanifest.headers.get('Content-Type'), 'application/manifest+json')
})

it('responds with 404 for unexpected request methods', async () => {
const { origin } = await startTestServer()

for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
for (const path of ['/', '/index.html', '/foo', '/foo/bar', '/assets/favicon.ico']) {
const response = await fetch(`${origin}${path}`, { method })
assert.strictEqual(response.status, 404)
assert.strictEqual(response.headers.get('Content-Type'), 'application/json; charset=utf-8')
assert.deepStrictEqual(await response.json(), { error: 'Not Found' })
}
}
})

it('responds with 400 for unknown request methods', async () => {
const { origin } = await startTestServer()

const response = await fetch(`${origin}/`, { method: 'MYRANDOMMETHOD' })
assert.strictEqual(response.status, 400)
assert.deepStrictEqual(await response.json(), {
error: 'Bad Request',
message: 'Client Error',
statusCode: 400
})
})
})
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"scripts": {
"build": "npm run build --workspaces && node -e \"fs.rmSync('./dist',{force:true,recursive:true})\" && tsc",
"lint": "npm run lint --workspaces && tsc --noEmit -p tsconfig.lint.json && eslint --ignore-path .gitignore src",
"test": "npm run test --workspaces --if-present && mocha --require tsx --recursive \"test/**/*.ts\"",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "npm run test --workspaces --if-present && mocha --require tsx --recursive \"test/**/*.ts\"",
"test:integration": "mocha --require tsx --recursive \"integration/**/*.ts\"",
"start": "node --disable-proto=delete dist/main.js"
},
"repository": {
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.lint.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"extends": "./tsconfig.json",
"include": [
"src",
"test"
"test",
"integration"
]
}

0 comments on commit 5168852

Please sign in to comment.