nginx-testing
Version:
Support for integration/acceptance testing of nginx configuration.
388 lines (322 loc) • 12.4 kB
text/typescript
import * as OS from 'node:os'
import * as path from 'node:path'
import { AssertionError } from 'assert'
import * as dedent from 'dedent'
import { afterEach, describe, test } from 'mocha'
import { NginxBinary } from 'nginx-binaries'
import fetch from 'node-fetch'
import { anything, spy, reset, when } from 'ts-mockito'
import { sync as which } from 'which'
import '../test/helper'
import { isDirectory, isFile, processExists, retryUntilTimeout } from '../test/utils'
import { configPatch, startNginx, NginxServer, __testing } from './nginxRunner'
import { nginxVersionInfo, NginxVersionInfo } from './nginxVersionInfo'
const { adjustConfig } = __testing
const NginxBinarySpy = spy(NginxBinary)
// TODO: Add more test cases.
describe('startNginx', function () {
this.slow(250)
const testingConfig = dedent`
events {
}
http {
server {
listen __ADDRESS__:__PORT__;
root __WORKDIR__;
location /test {
return 418;
}
}
}
`
let config: string = testingConfig
let nginx: NginxServer
const testNginxResponse = async ({ expectedStatus = 418, msg = '' } = {}) => {
const url = `http://127.0.0.1:${nginx.port}/test`
msg ||= `Expected nginx to respond to GET ${url}.`
const resp = await fetch(url)
assert(resp.status === expectedStatus, msg)
}
afterEach(async () => {
nginx && await nginx.stop()
})
;['system', '1.22.x', '1.24.x'].forEach(version => {
describe(`with nginx ${version}`, () => {
let binPath: string
before(async function () {
// We have to download nginx binary which may take some time on slow connection.
this.timeout(120_000)
if (version === 'system') {
binPath = which(process.env.NGINX_BIN || 'nginx', { nothrow: true }) as string
if (!binPath) {
console.warn('nginx not found, skipping tests with system-provided nginx')
this.skip()
}
} else {
binPath = await NginxBinary.download({ version })
when(NginxBinarySpy.download(anything(), anything())).thenResolve(binPath)
}
})
after(() => {
reset(NginxBinarySpy)
})
test('starts nginx with the given config', async () => {
nginx = await startNginx({ binPath, config })
assert(processExists(nginx.pid))
assert(nginx.ports.length === 1)
await testNginxResponse()
})
describe('resolved value', () => {
beforeEach(async () => {
nginx = await startNginx({ binPath, config })
})
test('.config', async () => {
const versionInfo = await nginxVersionInfo(binPath)
const expected = adjustConfig(config, {
...versionInfo,
bindAddress: '127.0.0.1',
configPath: nginx.workDir,
ports: nginx.ports,
workDir: nginx.workDir,
})
assert.equal(nginx.config, expected,
'Expected the .config to be the same as the input config transformed by adjustConfig().')
})
test('.workDir', () => {
assert(isDirectory(nginx.workDir))
assert(isFile(`${nginx.workDir}/nginx.conf`))
})
test('.readAccessLog', async () => {
await fetch(`http://127.0.0.1:${nginx.port}/test`)
await retryUntilTimeout(200, async () => {
assert((await nginx.readAccessLog()).includes('GET /test'),
'Expected to return nginx access log messages.')
})
})
test('.readErrorLog', async () => {
assert((await nginx.readErrorLog()).match(/using the "\w+" event method/),
'Expected to return nginx error log messages.')
})
describe('.reload', () => {
before(function () {
// Windows doesn't support signals, so we cannot send SIGHUP.
if (OS.platform() === 'win32') {
this.skip()
}
})
test('rejects if master_process is off', async () => {
try {
await nginx.reload()
} catch (err: any) {
return assert(err.message.includes('Nginx cannot be reloaded when master_process is off'))
}
assert.fail('The function should throw, rather than completing.')
})
describe('when master_process is on', () => {
before(() => { config = `${testingConfig}\nmaster_process on;\n` })
after(() => { config = testingConfig })
test('reloads nginx with the given config', async () => {
const oldPid = nginx.pid
await testNginxResponse({ expectedStatus: 418 })
await nginx.reload({ config: config.replace('return 418', 'return 428') })
assert(nginx.pid === oldPid)
assert(processExists(nginx.pid))
await retryUntilTimeout(200, async () => testNginxResponse({
expectedStatus: 428,
msg: 'Expected to respond based on the new config.',
}))
})
})
})
describe('.restart', () => {
const testPid = async (oldPid: number) => {
assert(nginx.pid !== oldPid, 'Expected the pid to change.')
await retryUntilTimeout(100, () => assert(
!processExists(oldPid),
'Expected the old process to be dead.',
))
assert(processExists(nginx.pid))
}
beforeEach(async () => {
await testNginxResponse({ expectedStatus: 418 })
})
test('restarts nginx without changing config', async () => {
const { config: oldConfig, pid: oldPid, port: oldPort } = nginx
await nginx.restart()
assert(nginx.config === oldConfig, 'Expected the config not to change.')
assert(nginx.port === oldPort, 'Expected the port number not to change.')
await testPid(oldPid)
await testNginxResponse()
})
test('restarts nginx with the given config', async () => {
const { config: oldConfig, pid: oldPid, port: oldPort } = nginx
await nginx.restart({ config: config.replace('return 418', 'return 428') })
assert(nginx.config !== oldConfig)
assert(nginx.port === oldPort, 'Expected the port number not to change.')
await testPid(oldPid)
await testNginxResponse({
expectedStatus: 428,
msg: 'Expected nginx to respond based on the new config.',
})
})
})
describe('.stop', () => {
test('kills the nginx process', async () => {
await nginx.stop()
await retryUntilTimeout(500, () => assert(
!processExists(nginx.pid),
'Expected the nginx process to be killed within 500 ms.',
))
})
test('removes temporary workDir', async function () {
await nginx.stop()
// This test is very unreliable on Windows and often fails on Windows + Node 15.
await retryUntilTimeout(1_000, () => assert(
!isDirectory(nginx.workDir),
'Expected the temporary workDir to be deleted within 1 sec after stopping.',
), (err: AssertionError) => {
// XXX: If running on Windows and assert didn't pass within 1 sec,
// mark the test as skipped.
if (OS.platform() === 'win32') {
console.warn(`WARN: Ignoring failure of test '${this.test!.title}' on win32.`)
this.skip()
}
throw err
})
})
})
})
})
})
test('rejects if the config does not contain any __PORT__ and ports + preferredPorts are undef/empty', async () => {
const binPath = `${__dirname}/../test/fixtures/nginxV`
try {
nginx = await startNginx({ binPath, config: 'daemon off;', ports: [], preferredPorts: [] })
} catch (err: any) {
return assert(err.message.includes('No __PORT__ placeholder found'))
}
assert.fail('The function should throw, rather than completing.')
})
test('with non-existing binPath', async () => {
const binPath = '/does/not/exist'
try {
await startNginx({ binPath, config })
} catch (err: any) {
return assert(err.message.includes(binPath))
}
assert.fail('Expected the function to throw, rather than completing.')
})
})
describe('adjustConfig', () => {
const minimalConfig = dedent`
events {
}
http {
server {
listen 8080;
}
}
`
const params = {
bindAddress: '127.0.0.2',
configPath: '/home/joe/project/nginx.conf',
modules: {},
ports: [8080],
workDir: '/tmp/nginx-testing',
}
test('adds directives for compatibility with nginx-testing', () => {
const expected = dedent`
events {
}
http {
server {
listen 8080;
}
access_log access.log;
client_body_temp_path client_body_temp;
proxy_temp_path proxy_temp;
fastcgi_temp_path fastcgi_temp;
uwsgi_temp_path uwsgi_temp;
scgi_temp_path scgi_temp;
}
daemon off;
pid nginx.pid;
master_process off;
error_log stderr info;
`
const actual = adjustConfig(minimalConfig, params).trim()
assert.equal(actual, expected)
})
test('does not add directives for unavailable modules', () => {
const patch = configPatch.filter(x => x.ifModule)
const modules = patch.reduce<NginxVersionInfo['modules']>(
(acc, { ifModule }) => (acc[ifModule!] = 'without', acc),
{},
)
const result = adjustConfig(minimalConfig, { ...params, modules })
for (const { path } of patch) {
const directive = path.split('/').pop()
assert(!result.includes(directive!), 'Expected this directive to not be added.')
}
})
test('does not override certain directives', () => {
const input = dedent`
daemon on;
master_process on;
pid /run/nginx.pid;
error_log stderr warn;
http {
access_log misc.log misc;
client_body_temp_path cache/body;
proxy_temp_path cache/proxy;
fastcgi_temp_path cache/fastcgi;
uwsgi_temp_path cache/uwsgi;
scgi_temp_path cache/scgi;
}
`
const expected = dedent`
master_process on;
error_log stderr warn;
http {
access_log misc.log misc;
client_body_temp_path cache/body;
proxy_temp_path cache/proxy;
fastcgi_temp_path cache/fastcgi;
uwsgi_temp_path cache/uwsgi;
scgi_temp_path cache/scgi;
}
daemon off;
pid nginx.pid;
`
const actual = adjustConfig(input, params).trim()
assert.equal(actual, expected,
"Expected 'demon' and 'pid' to be overridden, 'access_log' added and the rest kept as-is.")
})
describe('replaces placeholders with the given params', () => {
const { bindAddress, configPath, workDir } = params
const ports = [8080, 8081, 8090]
;([/* placeholder | expected */
['__ADDRESS__:__PORT__' , `${bindAddress}:${ports[0]}` ],
['__CONFDIR__' , path.dirname(configPath) ],
['__CWD__' , process.cwd().replace(/\\/g, '/')],
['__WORKDIR__' , workDir ],
['__WORKDIR__/__WORKDIR__', `${workDir}/${workDir}` ],
['__WORKDIR__/root' , `${workDir}/root` ],
['__PORT__' , ports[0] ],
['127.0.0.1:__PORT__' , `127.0.0.1:${ports[0]}` ],
['__PORT_0__' , ports[0] ],
['__PORT_1__' , ports[1] ],
['__PORT_2__' , ports[2] ],
] as const).forEach(([placeholder, expected]) => {
test(placeholder, () => {
const input = dedent`
http {
directive ${placeholder};
}
`
const parameters = { ...params, ports }
assert(adjustConfig(input, parameters).includes(`directive ${expected};`))
})
})
})
})