From 53f5641ef1cd2e5374f55a50a601fb80d4872511 Mon Sep 17 00:00:00 2001 From: Christina Ying Wang Date: Wed, 1 May 2024 16:55:54 -0700 Subject: [PATCH] Refactor host-config to be its own module The host-config module exposes the following interfaces: get, patch, and parse. `get` gets host configuration such as redsocks proxy configuration and hostname and returns it in an object of type HostConfiguration. `patch` takes an object of type HostConfiguration or LegacyHostConfiguration and updates the hostname and redsocks proxy configuration, optionally forcing the patch through update locks. `parse` takes a user input of unknown type and parses it into type HostConfiguration or LegacyHostConfiguration for patching, erroring if parse was unsuccessful. LegacyHostConfiguration is a looser typing of the user input which does not validate values of the five known proxy fields of type, ip, port, username, and password. We should stop supporting it in the next major Supervisor API release. Change-type: minor Signed-off-by: Christina Ying Wang --- src/device-api/actions.ts | 14 +- src/device-api/v1.ts | 6 + src/host-config.ts | 201 ------------------ src/host-config/index.ts | 54 ++++- src/host-config/proxy.ts | 98 ++++++++- test/data/mnt/boot/system-proxy/redsocks.conf | 14 +- test/integration/device-api/v1.spec.ts | 36 ++++ test/integration/host-config.spec.ts | 86 +++++++- test/unit/host-config.spec.ts | 24 ++- 9 files changed, 296 insertions(+), 237 deletions(-) delete mode 100644 src/host-config.ts diff --git a/src/device-api/actions.ts b/src/device-api/actions.ts index 74274c41..036eda40 100644 --- a/src/device-api/actions.ts +++ b/src/device-api/actions.ts @@ -7,7 +7,10 @@ import * as deviceState from '../device-state'; import * as logger from '../logger'; import * as config from '../config'; import * as hostConfig from '../host-config'; -import { parse } from '../host-config/index'; +import type { + HostConfiguration, + LegacyHostConfiguration, +} from '../host-config'; import * as applicationManager from '../compose/application-manager'; import type { CompositionStepAction } from '../compose/composition-steps'; import { generateStep } from '../compose/composition-steps'; @@ -447,8 +450,11 @@ export const getHostConfig = async () => { * - PATCH /v1/device/host-config */ export const patchHostConfig = async (conf: unknown, force: boolean) => { - const parsedConf = parse(conf); - if (parsedConf) { - await hostConfig.patch(parsedConf, force); + let parsedConf: HostConfiguration | LegacyHostConfiguration; + try { + parsedConf = hostConfig.parse(conf); + } catch (e: unknown) { + throw new BadRequestError((e as Error).message); } + await hostConfig.patch(parsedConf, force); }; diff --git a/src/device-api/v1.ts b/src/device-api/v1.ts index 7d18dc43..d9c69727 100644 --- a/src/device-api/v1.ts +++ b/src/device-api/v1.ts @@ -187,6 +187,12 @@ router.patch('/v1/device/host-config', async (req, res) => { if (e instanceof UpdatesLockedError) { return res.status(423).send(e?.message ?? e); } + + // User input cannot be parsed to type HostConfiguration or LegacyHostConfiguration + if (isBadRequestError(e)) { + return res.status(e.statusCode).send(e.statusMessage); + } + return res.status(503).send((e as Error)?.message ?? e ?? 'Unknown error'); } }); diff --git a/src/host-config.ts b/src/host-config.ts deleted file mode 100644 index b003d64c..00000000 --- a/src/host-config.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { stripIndent } from 'common-tags'; -import _ from 'lodash'; -import path from 'path'; - -import * as applicationManager from './compose/application-manager'; -import { - readHostname, - setHostname, - readNoProxy, - setNoProxy, -} from './host-config/index'; -import * as dbus from './lib/dbus'; -import { isENOENT } from './lib/errors'; -import { mkdirp, unlinkAll } from './lib/fs-utils'; -import { writeToBoot, readFromBoot, pathOnBoot } from './lib/host-utils'; -import * as updateLock from './lib/update-lock'; - -const redsocksHeader = stripIndent` - base { - log_debug = off; - log_info = on; - log = stderr; - daemon = off; - redirector = iptables; - } - - redsocks { - local_ip = 127.0.0.1; - local_port = 12345; -`; - -const redsocksFooter = '}\n'; - -const proxyFields = ['type', 'ip', 'port', 'login', 'password']; - -const proxyBasePath = pathOnBoot('system-proxy'); -const redsocksConfPath = path.join(proxyBasePath, 'redsocks.conf'); - -interface ProxyConfig { - [key: string]: string | string[] | number; -} - -interface HostConfig { - network: { - proxy?: ProxyConfig; - hostname?: string; - }; -} - -const isAuthField = (field: string): boolean => - ['login', 'password'].includes(field); - -const memoizedAuthRegex = _.memoize( - (proxyField: string) => new RegExp(proxyField + '\\s*=\\s*"(.*)"\\s*;'), -); - -const memoizedRegex = _.memoize( - // Add beginning-of-line RegExp to prevent local_ip and local_port static fields from being memoized - (proxyField) => new RegExp('^\\s*' + proxyField + '\\s*=\\s*([^;\\s]*)\\s*;'), -); - -async function readProxy(): Promise { - const conf: ProxyConfig = {}; - let redsocksConf: string; - try { - redsocksConf = await readFromBoot(redsocksConfPath, 'utf-8'); - } catch (e: unknown) { - if (!isENOENT(e)) { - throw e; - } - return; - } - const lines = redsocksConf.split('\n'); - - for (const line of lines) { - for (const proxyField of proxyFields) { - let match: string[] | null = null; - if (isAuthField(proxyField)) { - match = line.match(memoizedAuthRegex(proxyField)); - } else { - match = line.match(memoizedRegex(proxyField)); - } - - if (match != null) { - conf[proxyField] = match[1]; - } - } - } - - const noProxy = await readNoProxy(); - if (noProxy.length) { - conf.noProxy = noProxy; - } - - // Convert port to number per API doc spec - if (conf.port) { - conf.port = parseInt(conf.port as string, 10); - } - return conf; -} - -function generateRedsocksConfEntries(conf: ProxyConfig): string { - let val = ''; - for (const field of proxyFields) { - let v = conf[field]; - if (v != null) { - if (isAuthField(field)) { - // Escape any quotes in the field value - v = `"${v.toString().replace(/"/g, '\\"')}"`; - } - val += `\t${field} = ${v};\n`; - } - } - return val; -} - -async function setProxy(maybeConf: ProxyConfig | null): Promise { - if (_.isEmpty(maybeConf)) { - await unlinkAll(redsocksConfPath); - await setNoProxy([]); - } else { - // We know that maybeConf is not null due to the _.isEmpty check above, - // but the compiler doesn't - const conf = maybeConf as ProxyConfig; - await mkdirp(proxyBasePath); - if (Array.isArray(conf.noProxy)) { - await setNoProxy(conf.noProxy); - } - - let currentConf: ProxyConfig | undefined; - try { - currentConf = await readProxy(); - } catch { - // Noop - current redsocks.conf does not exist - } - - // If currentConf is undefined, the currentConf spread will be skipped. - // See: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-1.html#conditional-spreads-create-optional-properties - const redsocksConf = ` - ${redsocksHeader}\n - ${generateRedsocksConfEntries({ ...currentConf, ...conf })} - ${redsocksFooter} - `; - await writeToBoot(redsocksConfPath, redsocksConf); - } - - // restart balena-proxy-config if it is loaded and NOT PartOf redsocks-conf.target - if ( - ( - await Promise.any([ - dbus.servicePartOf('balena-proxy-config'), - dbus.servicePartOf('resin-proxy-config'), - ]) - ).includes('redsocks-conf.target') === false - ) { - await Promise.any([ - dbus.restartService('balena-proxy-config'), - dbus.restartService('resin-proxy-config'), - ]); - } - - // restart redsocks if it is loaded and NOT PartOf redsocks-conf.target - if ( - (await dbus.servicePartOf('redsocks')).includes('redsocks-conf.target') === - false - ) { - await dbus.restartService('redsocks'); - } -} - -export async function get(): Promise { - return { - network: { - proxy: await readProxy(), - hostname: await readHostname(), - }, - }; -} - -export async function patch( - conf: HostConfig, - force: boolean = false, -): Promise { - const apps = await applicationManager.getCurrentApps(); - const appIds = Object.keys(apps).map((strId) => parseInt(strId, 10)); - - // It's possible for appIds to be an empty array, but patch shouldn't fail - // as it's not dependent on there being any running user applications. - return updateLock.lock(appIds, { force }, async () => { - const promises: Array> = []; - if (conf != null && conf.network != null) { - if (conf.network.proxy != null) { - promises.push(setProxy(conf.network.proxy)); - } - if (conf.network.hostname != null) { - promises.push(setHostname(conf.network.hostname)); - } - } - await Promise.all(promises); - }); -} diff --git a/src/host-config/index.ts b/src/host-config/index.ts index c6beb160..19259f55 100644 --- a/src/host-config/index.ts +++ b/src/host-config/index.ts @@ -4,9 +4,13 @@ import Reporter from 'io-ts-reporters'; import type { RedsocksConfig, ProxyConfig } from './types'; import { HostConfiguration, LegacyHostConfiguration } from './types'; +import { readProxy, setProxy } from './proxy'; import * as config from '../config'; +// FIXME: The host-config module shouldn't be importing from compose +import * as applicationManager from '../compose/application-manager'; import { pathOnRoot } from '../lib/host-utils'; import log from '../lib/supervisor-console'; +import * as updateLock from '../lib/update-lock'; export * from './proxy'; export * from './types'; @@ -33,7 +37,7 @@ export async function setHostname(val: string) { export function parse( conf: unknown, -): HostConfiguration | LegacyHostConfiguration | null { +): HostConfiguration | LegacyHostConfiguration { const decoded = HostConfiguration.decode(conf); if (isRight(decoded)) { @@ -52,7 +56,7 @@ export function parse( return legacyDecoded.right; } } - return null; + throw new Error('Could not parse host config input to a valid format'); } export function patchProxy( @@ -80,3 +84,49 @@ export function patchProxy( } return patchedConf; } + +export async function patch( + conf: HostConfiguration | LegacyHostConfiguration, + force: boolean = false, +): Promise { + const apps = await applicationManager.getCurrentApps(); + const appIds = Object.keys(apps).map((strId) => parseInt(strId, 10)); + + if (conf.network.hostname != null) { + await setHostname(conf.network.hostname); + } + + if (conf.network.proxy != null) { + const targetConf = conf.network.proxy; + // It's possible for appIds to be an empty array, but patch shouldn't fail + // as it's not dependent on there being any running user applications. + return updateLock.lock(appIds, { force }, async () => { + const proxyConf = await readProxy(); + let currentConf: ProxyConfig | undefined = undefined; + if (proxyConf) { + delete proxyConf.noProxy; + currentConf = proxyConf; + } + + // Merge current & target redsocks.conf + const patchedConf = patchProxy( + { + redsocks: currentConf, + }, + { + redsocks: targetConf, + }, + ); + await setProxy(patchedConf, targetConf.noProxy); + }); + } +} + +export async function get(): Promise { + return { + network: { + hostname: await readHostname(), + proxy: await readProxy(), + }, + }; +} diff --git a/src/host-config/proxy.ts b/src/host-config/proxy.ts index c92ef4d8..7b846485 100644 --- a/src/host-config/proxy.ts +++ b/src/host-config/proxy.ts @@ -3,15 +3,17 @@ import path from 'path'; import { isRight } from 'fp-ts/lib/Either'; import Reporter from 'io-ts-reporters'; +import type { RedsocksConfig, HostProxyConfig } from './types'; import { ProxyConfig } from './types'; -import type { RedsocksConfig } from './types'; -import { pathOnBoot, readFromBoot } from '../lib/host-utils'; -import { unlinkAll } from '../lib/fs-utils'; +import { pathOnBoot, readFromBoot, writeToBoot } from '../lib/host-utils'; +import { unlinkAll, mkdirp } from '../lib/fs-utils'; import { isENOENT } from '../lib/errors'; import log from '../lib/supervisor-console'; +import * as dbus from '../lib/dbus'; const proxyBasePath = pathOnBoot('system-proxy'); const noProxyPath = path.join(proxyBasePath, 'no_proxy'); +const redsocksConfPath = path.join(proxyBasePath, 'redsocks.conf'); const disallowedProxyFields = ['local_ip', 'local_port']; @@ -136,6 +138,87 @@ export class RedsocksConf { } } +export async function readProxy(): Promise { + // Get and parse redsocks.conf + let rawConf: string | undefined; + try { + rawConf = await readFromBoot(redsocksConfPath, 'utf-8'); + } catch (e: unknown) { + if (!isENOENT(e)) { + throw e; + } + return undefined; + } + const redsocksConf = RedsocksConf.parse(rawConf); + + // Get and parse no_proxy + const noProxy = await readNoProxy(); + + // Build proxy object + const proxy = { + ...redsocksConf.redsocks, + ...(noProxy.length && { noProxy }), + }; + + // Assumes mandatory proxy config fields (type, ip, port) are present, + // even if they very well may not be. It is up to the user to ensure + // that all the necessary fields are present in the redsocks.conf file. + return proxy as HostProxyConfig; +} + +export async function setProxy( + conf: RedsocksConfig, + noProxy: Nullable, +) { + // Ensure proxy directory exists + await mkdirp(proxyBasePath); + + // Set no_proxy + let noProxyChanged = false; + if (noProxy != null) { + noProxyChanged = await setNoProxy(noProxy); + } + + // Write to redsocks.conf + const toWrite = RedsocksConf.stringify(conf); + if (toWrite) { + await writeToBoot(redsocksConfPath, toWrite); + } + // If target is empty aside from noProxy and noProxy got patched, + // do not change redsocks.conf to remain backwards compatible + else if (!noProxyChanged) { + await unlinkAll(redsocksConfPath); + } + + // Restart services using dbus + await restartProxyServices(); +} + +async function restartProxyServices() { + // restart balena-proxy-config if it is loaded and NOT PartOf redsocks-conf.target + if ( + ( + await Promise.any([ + dbus.servicePartOf('balena-proxy-config'), + dbus.servicePartOf('resin-proxy-config'), + ]) + ).includes('redsocks-conf.target') === false + ) { + await Promise.any([ + dbus.restartService('balena-proxy-config'), + dbus.restartService('resin-proxy-config'), + ]); + } + + // restart redsocks if it is loaded and NOT PartOf redsocks-conf.target + if ( + (await dbus.servicePartOf('redsocks')).includes('redsocks-conf.target') === + false + ) { + await dbus.restartService('redsocks'); + } +} + export async function readNoProxy(): Promise { try { const noProxy = await readFromBoot(noProxyPath, 'utf-8') @@ -155,10 +238,17 @@ export async function readNoProxy(): Promise { } } -export async function setNoProxy(list: string[]) { +export async function setNoProxy(list: Nullable) { + const current = await readNoProxy(); if (!list || !Array.isArray(list) || !list.length) { await unlinkAll(noProxyPath); } else { await fs.writeFile(noProxyPath, list.join('\n')); } + // If noProxy has changed, return true + return ( + Array.isArray(list) && + (current.length !== list.length || + !current.every((addr) => list.includes(addr))) + ); } diff --git a/test/data/mnt/boot/system-proxy/redsocks.conf b/test/data/mnt/boot/system-proxy/redsocks.conf index 1e805255..08c8efca 100644 --- a/test/data/mnt/boot/system-proxy/redsocks.conf +++ b/test/data/mnt/boot/system-proxy/redsocks.conf @@ -1,17 +1,17 @@ base { - log_debug = off; - log_info = on; - log = stderr; - daemon = off; - redirector = iptables; + log_debug = off; + log_info = on; + log = stderr; + daemon = off; + redirector = iptables; } redsocks { - local_ip = 127.0.0.1; - local_port = 12345; ip = example.org; port = 1080; type = socks5; login = "foo"; password = "bar"; + local_ip = 127.0.0.1; + local_port = 12345; } diff --git a/test/integration/device-api/v1.spec.ts b/test/integration/device-api/v1.spec.ts index f85b8cd7..a5a3eeb8 100644 --- a/test/integration/device-api/v1.spec.ts +++ b/test/integration/device-api/v1.spec.ts @@ -816,5 +816,41 @@ describe('device-api/v1', () => { ); }); }); + + it('responds with 200 if patch successful', async () => { + (actions.patchHostConfig as SinonStub).resolves(); + await request(api) + .patch('/v1/device/host-config') + .send({ network: { hostname: 'deadbeef' } }) + .set('Authorization', `Bearer ${await apiKeys.getGlobalApiKey()}`) + .expect(200); + }); + + it('responds with 423 for update lock errors', async () => { + (actions.patchHostConfig as SinonStub).throws(new UpdatesLockedError()); + await request(api) + .patch('/v1/device/host-config') + .send({ network: { hostname: 'deadbeef' } }) + .set('Authorization', `Bearer ${await apiKeys.getGlobalApiKey()}`) + .expect(423); + }); + + it('responds with 400 for BadRequestErrors', async () => { + (actions.patchHostConfig as SinonStub).throws(new BadRequestError()); + await request(api) + .patch('/v1/device/host-config') + .send({ network: { hostname: 'deadbeef' } }) + .set('Authorization', `Bearer ${await apiKeys.getGlobalApiKey()}`) + .expect(400); + }); + + it('responds with 503 for other errors that occur during patch', async () => { + (actions.patchHostConfig as SinonStub).throws(new Error()); + await request(api) + .patch('/v1/device/host-config') + .send({ network: { hostname: 'deadbeef' } }) + .set('Authorization', `Bearer ${await apiKeys.getGlobalApiKey()}`) + .expect(503); + }); }); }); diff --git a/test/integration/host-config.spec.ts b/test/integration/host-config.spec.ts index 19437d4e..a3ca19a1 100644 --- a/test/integration/host-config.spec.ts +++ b/test/integration/host-config.spec.ts @@ -7,7 +7,7 @@ import { stub } from 'sinon'; import * as fs from 'fs/promises'; import { get, patch } from '~/src/host-config'; -import * as hostConfig from '~/src/host-config/index'; +import * as hostConfig from '~/src/host-config'; import * as config from '~/src/config'; import * as applicationManager from '~/src/compose/application-manager'; import type { InstancedAppState } from '~/src/compose/types'; @@ -73,12 +73,14 @@ describe('host-config', () => { // Stub external dependencies stub(dbus, 'servicePartOf').resolves([]); stub(dbus, 'restartService').resolves(); + stub(applicationManager, 'getCurrentApps').resolves({}); }); afterEach(async () => { await tFs.restore(); (dbus.servicePartOf as SinonStub).restore(); (dbus.restartService as SinonStub).restore(); + (applicationManager.getCurrentApps as SinonStub).restore(); }); describe('hostname', () => { @@ -111,6 +113,43 @@ describe('host-config', () => { expect(await fs.readFile(noProxy, 'utf-8')).to.equal( 'balena.io\n1.1.1.1', ); + await hostConfig.setNoProxy(['balena.io', '2.2.2.2']); + expect(await fs.readFile(noProxy, 'utf-8')).to.equal( + 'balena.io\n2.2.2.2', + ); + }); + + it('returns whether noProxy was changed', async () => { + // Set initial no_proxy as empty + await hostConfig.setNoProxy([]); + + // Change no_proxy + expect(await hostConfig.setNoProxy(['balena.io', '1.1.1.1'])).to.be.true; + expect(await hostConfig.readNoProxy()) + .to.deep.include.members(['balena.io', '1.1.1.1']) + .and.have.lengthOf(2); + + // Change no_proxy to same value + expect(await hostConfig.setNoProxy(['1.1.1.1', 'balena.io'])).to.be.false; + expect(await hostConfig.readNoProxy()) + .to.deep.include.members(['balena.io', '1.1.1.1']) + .and.have.lengthOf(2); + + // Remove a value + expect(await hostConfig.setNoProxy(['1.1.1.1'])).to.be.true; + expect(await hostConfig.readNoProxy()) + .to.deep.include.members(['1.1.1.1']) + .and.have.lengthOf(1); + + // Add a value + expect(await hostConfig.setNoProxy(['2.2.2.2', '1.1.1.1'])).to.be.true; + expect(await hostConfig.readNoProxy()) + .to.deep.include.members(['2.2.2.2', '1.1.1.1']) + .and.have.lengthOf(2); + + // Remove no_proxy + expect(await hostConfig.setNoProxy([])).to.be.true; + expect(await hostConfig.readNoProxy()).to.deep.equal([]); }); it('removes no_proxy file if empty or invalid', async () => { @@ -150,29 +189,36 @@ describe('host-config', () => { }); it('prevents patch if update locks are present', async () => { - stub(applicationManager, 'getCurrentApps').resolves(currentApps); + (applicationManager.getCurrentApps as SinonStub).resolves(currentApps); try { - await patch({ network: { hostname: 'test' } }); + await patch({ network: { proxy: {} } }); expect.fail('Expected hostConfig.patch to throw UpdatesLockedError'); } catch (e: unknown) { expect(e).to.be.instanceOf(UpdatesLockedError); } - - (applicationManager.getCurrentApps as SinonStub).restore(); }); it('patches if update locks are present but force is specified', async () => { - stub(applicationManager, 'getCurrentApps').resolves(currentApps); + (applicationManager.getCurrentApps as SinonStub).resolves(currentApps); try { - await patch({ network: { hostname: 'deadreef' } }, true); - expect(await config.get('hostname')).to.equal('deadreef'); + await patch({ network: { proxy: {} } }, true); + expect(await hostConfig.readProxy()).to.be.undefined; } catch (e: unknown) { expect.fail(`Expected hostConfig.patch to not throw, but got ${e}`); } + }); - (applicationManager.getCurrentApps as SinonStub).restore(); + it('patches hostname regardless of update locks', async () => { + (applicationManager.getCurrentApps as SinonStub).resolves(currentApps); + + try { + await patch({ network: { hostname: 'test' } }); + expect(await config.get('hostname')).to.equal('test'); + } catch (e: unknown) { + expect.fail(`Expected hostConfig.patch to not throw, but got ${e}`); + } }); it('patches hostname', async () => { @@ -251,6 +297,19 @@ describe('host-config', () => { ]); }); + it('ignores unsupported fields when patching proxy', async () => { + const rawConf = await fs.readFile(redsocksConf, 'utf-8'); + await patch({ + network: { + proxy: { + local_ip: '127.0.0.2', + local_port: 12346, + } as any, + }, + }); + expect(await fs.readFile(redsocksConf, 'utf-8')).to.equal(rawConf); + }); + it('skips restarting proxy services when part of redsocks-conf.target', async () => { (dbus.servicePartOf as SinonStub).resolves(['redsocks-conf.target']); await patch({ @@ -298,7 +357,14 @@ describe('host-config', () => { }, }); const { network } = await get(); - expect(network).to.have.property('proxy'); + // If only noProxy is patched, redsocks.conf should remain unchanged + expect(network).to.have.property('proxy').that.deep.includes({ + ip: 'example.org', + port: 1080, + type: 'socks5', + login: 'foo', + password: 'bar', + }); expect(network.proxy).to.not.have.property('noProxy'); expect(await fs.readdir(proxyBase)).to.not.have.members(['no_proxy']); }); diff --git a/test/unit/host-config.spec.ts b/test/unit/host-config.spec.ts index ab54f5b6..8d4f76de 100644 --- a/test/unit/host-config.spec.ts +++ b/test/unit/host-config.spec.ts @@ -2,9 +2,9 @@ import { expect } from 'chai'; import { stripIndent } from 'common-tags'; import type { SinonStub } from 'sinon'; -import * as hostConfig from '~/src/host-config/index'; -import { RedsocksConf } from '~/src/host-config/index'; -import type { RedsocksConfig, ProxyConfig } from '~/src/host-config/types'; +import * as hostConfig from '~/src/host-config'; +import { RedsocksConf } from '~/src/host-config'; +import type { RedsocksConfig, ProxyConfig } from '~/src/host-config'; import log from '~/lib/supervisor-console'; describe('RedsocksConf', () => { @@ -619,24 +619,30 @@ describe('src/host-config', () => { (log.warn as SinonStub).resetHistory(); }); - it('parses to null for HostConfiguration without network key', () => { - expect(hostConfig.parse({})).to.be.null; + it('throws error for HostConfiguration without network key', () => { + expect(() => hostConfig.parse({})).to.throw( + 'Could not parse host config input to a valid format', + ); }); - it('parses to null for HostConfiguration with invalid network key', () => { + it('throws error for HostConfiguration with invalid network key', () => { const conf = { network: 123, }; - expect(hostConfig.parse(conf)).to.be.null; + expect(() => hostConfig.parse(conf)).to.throw( + 'Could not parse host config input to a valid format', + ); }); - it('parses to null for HostConfiguration with invalid hostname', () => { + it('throws error for HostConfiguration with invalid hostname', () => { const conf = { network: { hostname: 123, }, }; - expect(hostConfig.parse(conf)).to.be.null; + expect(() => hostConfig.parse(conf)).to.throw( + 'Could not parse host config input to a valid format', + ); }); }); });