import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { Deployer, shellQuote } from '../lib/deployer.js'
import { generateConfig } from '../lib/config-generator.js'
import { Registry } from '../lib/registry.js'
function fakeRegistryData () {
return {
server: { user: 'fleeky', host: '10.22.0.3' },
nodes: [
{ petname: 'tabertron', ip: '10.22.0.1' },
{ petname: 'stortron', ip: '10.22.0.2' }
],
services: [
{
node: 'stortron',
name: 'pear-git',
port: 5005,
rootPath: '/git',
backends: [
{ node: 'stortron', port: 5005, mode: 'active' },
{ node: 'tabertron', port: 5005, mode: 'standby' }
]
}
]
}
}
function createTestRegistry () {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), '7rnx-test-'))
const reg = new Registry(tmpDir)
reg.load()
return reg
}
describe('shellQuote', () => {
it('neutralises shell metacharacters', () => {
assert.equal(shellQuote('/etc/apache2/x.conf'), "'/etc/apache2/x.conf'")
assert.equal(shellQuote("a'; rm -rf /"), "'a'\\''; rm -rf /'")
})
})
describe('Deployer.deploy() — validate-then-apply with rollback', () => {
it('gates reload on configtest and confirms via marker (success path)', () => {
const d = new Deployer(fakeRegistryData())
let remoteCmd = null
d._scp = () => ''
d._ssh = (cmd) => { remoteCmd = cmd; return 'Syntax OK\n__DEPLOY_OK__\n' }
assert.doesNotThrow(() => d.deploy())
// The remote script must back up, install, THEN test, and only reload on pass.
assert.match(remoteCmd, /7rnx-proxy\.conf\.bak/, 'takes a backup')
const iTest = remoteCmd.indexOf('apachectl configtest')
const iReload = remoteCmd.indexOf('systemctl reload apache2')
const iInstall = remoteCmd.indexOf('cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf')
assert.ok(iInstall !== -1 && iTest !== -1 && iReload !== -1, 'all steps present')
assert.ok(iInstall < iTest, 'installs the new config BEFORE testing it')
assert.ok(iTest < iReload, 'tests BEFORE reloading')
assert.match(remoteCmd, /cp \/etc\/apache2\/7rnx-proxy\.conf\.bak \/etc\/apache2\/7rnx-proxy\.conf/, 'restores backup on failure')
})
it('throws (does not silently succeed) when configtest fails', () => {
const d = new Deployer(fakeRegistryData())
d._scp = () => ''
d._ssh = () => {
const err = new Error('Command failed')
err.stdout = 'AH00526: Syntax error\n__DEPLOY_FAILED_ROLLED_BACK__\n'
err.stderr = ''
throw err
}
assert.throws(() => d.deploy(), /rolled back|config test failed/i)
})
it('throws when the success marker is absent', () => {
const d = new Deployer(fakeRegistryData())
d._scp = () => ''
d._ssh = () => 'Syntax OK\n' // reload ran but no marker
assert.throws(() => d.deploy(), /confirm success/i)
})
})
describe('Deployer.fetchRemoteConfig — path is quoted', () => {
it('quotes a malicious path so it cannot break out of cat', () => {
const d = new Deployer(fakeRegistryData())
let seen = null
d._ssh = (cmd) => { seen = cmd; return '' }
d.fetchRemoteConfig('/etc/x; rm -rf /')
assert.equal(seen, "cat '/etc/x; rm -rf /'")
})
})
describe('config-generator — balancer timeout + safety', () => {
it('uses a generous member timeout by default (not the 5s that broke #101)', () => {
const out = generateConfig(fakeRegistryData())
assert.match(out, /ProxySet timeout=300/)
assert.doesNotMatch(out, /timeout=5\b/)
assert.doesNotMatch(out, /failontimeout=On/)
})
it('honours a per-service timeout override', () => {
const data = fakeRegistryData()
data.services[0].timeout = 600
assert.match(generateConfig(data), /ProxySet timeout=600/)
})
it('rejects a service name that would break the Apache config', () => {
const data = fakeRegistryData()
data.services[0].name = 'pear git' // space
assert.throws(() => generateConfig(data), /unsafe service name/)
})
})
describe('registry — input validation', () => {
it('rejects a malformed IP', () => {
const reg = createTestRegistry()
assert.throws(() => reg.addNode('n1', '999.1.1.1'), /invalid IPv4/)
assert.throws(() => reg.addNode('n2', 'not-an-ip'), /invalid IPv4/)
})
it('rejects a petname with shell/apache metacharacters', () => {
const reg = createTestRegistry()
assert.throws(() => reg.addNode('bad name', '10.0.0.1'), /invalid petname/)
assert.throws(() => reg.addNode('a;b', '10.0.0.1'), /invalid petname/)
})
it('rejects a bad port and a bad root path on addService', () => {
const reg = createTestRegistry()
reg.addNode('n1', '10.0.0.1')
assert.throws(() => reg.addService('n1', 'web', 70000), /invalid port/)
assert.throws(() => reg.addService('n1', 'web', 8080, '/', '/git; rm'), /invalid root path/)
})
it('accepts a valid node + service', () => {
const reg = createTestRegistry()
assert.doesNotThrow(() => reg.addNode('n1', '10.22.0.9'))
assert.doesNotThrow(() => reg.addService('n1', 'web', 8080, '/', '/web'))
})
})