Merge pull request #1589 from balena-io/v1-unit-tests

Complete V1 unit tests, modify PATCH /v1/device/host-config
This commit is contained in:
bulldozer-balena[bot] 2021-02-18 04:19:21 +00:00 committed by GitHub
commit 2ff94e52ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 776 additions and 18 deletions

View File

@ -41,6 +41,8 @@ import {
import * as dbFormat from './device-state/db-format';
import * as apiKeys from './lib/api-keys';
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
function validateLocalState(state: any): asserts state is TargetState['local'] {
if (state.name != null) {
if (!validation.isValidShortText(state.name)) {
@ -128,15 +130,63 @@ function createDeviceStateRouter() {
),
);
router.patch('/v1/device/host-config', (req, res) =>
hostConfig
.patch(req.body)
.then(() => res.status(200).send('OK'))
.catch((err) =>
res.status(503).send(err?.message ?? err ?? 'Unknown error'),
),
router.patch('/v1/device/host-config', async (req, res) => {
// Because v1 endpoints are legacy, and this endpoint might already be used
// by multiple users, adding too many throws might have unintended side effects.
// Thus we're simply logging invalid fields and allowing the request to continue.
try {
if (!req.body.network) {
log.warn("Key 'network' must exist in PATCH body");
// If network does not exist, skip all field validation checks below
throw new Error();
}
const { proxy } = req.body.network;
// Validate proxy fields, if they exist
if (proxy && Object.keys(proxy).length) {
const blacklistedFields = Object.keys(proxy).filter((key) =>
disallowedHostConfigPatchFields.includes(key),
);
if (blacklistedFields.length > 0) {
log.warn(`Invalid proxy field(s): ${blacklistedFields.join(', ')}`);
}
if (
proxy.type &&
!constants.validRedsocksProxyTypes.includes(proxy.type)
) {
log.warn(
`Invalid redsocks proxy type, must be one of ${constants.validRedsocksProxyTypes.join(
', ',
)}`,
);
}
if (proxy.noProxy && !Array.isArray(proxy.noProxy)) {
log.warn('noProxy field must be an array of addresses');
}
}
} catch (e) {
/* noop */
}
try {
// If hostname is an empty string, return first 7 digits of device uuid
if (req.body.network?.hostname === '') {
const uuid = await config.get('uuid');
req.body.network.hostname = uuid?.slice(0, 7);
}
await hostConfig.patch(req.body);
res.status(200).send('OK');
} catch (err) {
res.status(503).send(err?.message ?? err ?? 'Unknown error');
}
});
router.get('/v1/device', async (_req, res) => {
try {
const state = await getStatus();

View File

@ -43,7 +43,7 @@ const redsocksConfPath = path.join(proxyBasePath, 'redsocks.conf');
const noProxyPath = path.join(proxyBasePath, 'no_proxy');
interface ProxyConfig {
[key: string]: string | string[];
[key: string]: string | string[] | number;
}
interface HostConfig {
@ -61,7 +61,8 @@ const memoizedAuthRegex = _.memoize(
);
const memoizedRegex = _.memoize(
(proxyField) => new RegExp(proxyField + '\\s*=\\s*([^;\\s]*)\\s*;'),
// 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<ProxyConfig | undefined> {
@ -93,13 +94,24 @@ async function readProxy(): Promise<ProxyConfig | undefined> {
}
try {
const noProxy = await fs.readFile(noProxyPath, 'utf-8');
conf.noProxy = noProxy.split('\n');
const noProxy = await fs
.readFile(noProxyPath, 'utf-8')
// Prevent empty newline from being reported as a noProxy address
.then((addrs) => addrs.split('\n').filter((addr) => addr !== ''));
if (noProxy.length) {
conf.noProxy = noProxy;
}
} catch (e) {
if (!ENOENT(e)) {
throw e;
}
}
// Convert port to number per API doc spec
if (conf.port) {
conf.port = parseInt(conf.port as string, 10);
}
return conf;
}
@ -134,9 +146,21 @@ async function setProxy(maybeConf: ProxyConfig | null): Promise<void> {
if (_.isArray(conf.noProxy)) {
await writeFileAtomic(noProxyPath, conf.noProxy.join('\n'));
}
const redsocksConf = `${redsocksHeader}${generateRedsocksConfEntries(
conf,
)}${redsocksFooter}`;
let currentConf: ProxyConfig | undefined;
try {
currentConf = await readProxy();
} catch (err) {
// 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 writeFileAtomic(redsocksConfPath, redsocksConf);
}

View File

@ -69,6 +69,7 @@ const constants = {
// (this number is used as an upper bound when generating
// a random jitter)
maxApiJitterDelay: 60 * 1000,
validRedsocksProxyTypes: ['socks4', 'socks5', 'http-connect', 'http-relay'],
};
if (process.env.DOCKER_HOST == null) {

View File

@ -1,8 +1,17 @@
import * as _ from 'lodash';
import * as Bluebird from 'bluebird';
import { expect } from 'chai';
import { stub, spy, SinonStub, SinonSpy } from 'sinon';
import {
stub,
spy,
useFakeTimers,
SinonStub,
SinonSpy,
SinonFakeTimers,
} from 'sinon';
import * as supertest from 'supertest';
import * as path from 'path';
import { promises as fs } from 'fs';
import * as appMock from './lib/application-state-mock';
import * as mockedDockerode from './lib/mocked-dockerode';
@ -18,8 +27,13 @@ import * as dbus from '../src//lib/dbus';
import * as updateLock from '../src/lib/update-lock';
import * as TargetState from '../src/device-state/target-state';
import * as targetStateCache from '../src/device-state/target-state-cache';
import blink = require('../src/lib/blink');
import constants = require('../src/lib/constants');
import * as deviceAPI from '../src/device-api/common';
import { UpdatesLockedError } from '../src/lib/errors';
import { SchemaTypeKey } from '../src/config/schema-type';
import log from '../src/lib/supervisor-console';
describe('SupervisorAPI [V1 Endpoints]', () => {
let api: SupervisorAPI;
@ -615,5 +629,581 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
});
});
// TODO: add tests for V1 endpoints
describe('POST /v1/blink', () => {
// Further blink function-specific testing located in 07-blink.spec.ts
it('responds with code 200 and empty body', async () => {
await request
.post('/v1/blink')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.POST['/blink'].statusCode)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V1.POST['/blink'].body,
);
expect(response.text).to.deep.equal(
sampleResponses.V1.POST['/blink'].text,
);
});
});
it('directs device to blink for 15000ms (hardcoded length)', async () => {
const blinkStartSpy: SinonSpy = spy(blink.pattern, 'start');
const blinkStopSpy: SinonSpy = spy(blink.pattern, 'stop');
const clock: SinonFakeTimers = useFakeTimers();
await request
.post('/v1/blink')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.then(() => {
expect(blinkStartSpy.callCount).to.equal(1);
clock.tick(15000);
expect(blinkStopSpy.callCount).to.equal(1);
});
blinkStartSpy.restore();
blinkStopSpy.restore();
clock.restore();
});
});
describe('POST /v1/regenerate-api-key', () => {
it('returns a valid new API key', async () => {
const refreshKeySpy: SinonSpy = spy(apiKeys, 'refreshKey');
let newKey: string = '';
await request
.post('/v1/regenerate-api-key')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.POST['/regenerate-api-key'].statusCode)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V1.POST['/regenerate-api-key'].body,
);
expect(response.text).to.equal(apiKeys.cloudApiKey);
newKey = response.text;
expect(refreshKeySpy.callCount).to.equal(1);
});
// Ensure persistence with future calls
await request
.post('/v1/blink')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${newKey}`)
.expect(sampleResponses.V1.POST['/blink'].statusCode);
refreshKeySpy.restore();
});
it('expires old API key after generating new key', async () => {
const oldKey: string = apiKeys.cloudApiKey;
await request
.post('/v1/regenerate-api-key')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${oldKey}`)
.expect(sampleResponses.V1.POST['/regenerate-api-key'].statusCode);
await request
.post('/v1/restart')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${oldKey}`)
.expect(401);
});
it('communicates the new API key to balena API', async () => {
const reportStateSpy: SinonSpy = spy(deviceState, 'reportCurrentState');
await request
.post('/v1/regenerate-api-key')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.then(() => {
expect(reportStateSpy.callCount).to.equal(1);
// Further reportCurrentState tests should be in 05-device-state.spec.ts,
// but its test case seems to currently be skipped until interface redesign
});
reportStateSpy.restore();
});
});
describe('/v1/device/host-config', () => {
// Wrap GET and PATCH /v1/device/host-config tests in the same block to share
// common scoped variables, namely file paths and file content
const hostnamePath: string = path.join(
process.env.ROOT_MOUNTPOINT!,
'/etc/hostname',
);
const proxyBasePath: string = path.join(
process.env.ROOT_MOUNTPOINT!,
process.env.BOOT_MOUNTPOINT!,
'system-proxy',
);
const redsocksPath: string = path.join(proxyBasePath, 'redsocks.conf');
const noProxyPath: string = path.join(proxyBasePath, 'no_proxy');
/**
* Copies contents of hostname, redsocks.conf, and no_proxy test files with `.template`
* endings to test files without `.template` endings to ensure the same data always
* exists for /v1/device/host-config test suites
*/
const restoreConfFileTemplates = async (): Promise<void[]> => {
return Promise.all([
fs.writeFile(
hostnamePath,
await fs.readFile(`${hostnamePath}.template`),
),
fs.writeFile(
redsocksPath,
await fs.readFile(`${redsocksPath}.template`),
),
fs.writeFile(noProxyPath, await fs.readFile(`${noProxyPath}.template`)),
]);
};
// Set hostname & proxy file content to expected defaults
before(async () => await restoreConfFileTemplates());
afterEach(async () => await restoreConfFileTemplates());
// Store GET responses for endpoint in variables so we can be less verbose in tests
const hostnameOnlyRes =
sampleResponses.V1.GET['/device/host-config [Hostname only]'];
const hostnameProxyRes =
sampleResponses.V1.GET['/device/host-config [Hostname and proxy]'];
describe('GET /v1/device/host-config', () => {
it('returns current host config (hostname and proxy)', async () => {
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal(hostnameProxyRes.body);
});
});
it('returns current host config (hostname only)', async () => {
await Promise.all([fs.unlink(redsocksPath), fs.unlink(noProxyPath)]);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(hostnameOnlyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal(hostnameOnlyRes.body);
});
});
it('errors if no hostname file exists', async () => {
await fs.unlink(hostnamePath);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(503);
});
});
describe('PATCH /v1/device/host-config', () => {
let configSetStub: SinonStub;
let logWarnStub: SinonStub;
let restartServiceSpy: SinonSpy;
const validProxyReqs: { [key: string]: number[] | string[] } = {
ip: ['proxy.example.org', 'proxy.foo.org'],
port: [5128, 1080],
type: constants.validRedsocksProxyTypes,
login: ['user', 'user2'],
password: ['foo', 'bar'],
};
// Mock to short-circuit config.set, allowing writing hostname directly to test file
const configSetFakeFn = async <T extends SchemaTypeKey>(
keyValues: config.ConfigMap<T>,
): Promise<void> =>
await fs.writeFile(hostnamePath, (keyValues as any).hostname);
const validatePatchResponse = (res: supertest.Response): void => {
expect(res.text).to.equal(
sampleResponses.V1.PATCH['/host/device-config'].text,
);
expect(res.body).to.deep.equal(
sampleResponses.V1.PATCH['/host/device-config'].body,
);
};
before(() => {
configSetStub = stub(config, 'set').callsFake(configSetFakeFn);
logWarnStub = stub(log, 'warn');
});
after(() => {
configSetStub.restore();
logWarnStub.restore();
});
beforeEach(() => {
restartServiceSpy = spy(dbus, 'restartService');
});
afterEach(() => {
restartServiceSpy.restore();
});
it('updates the hostname with provided string if string is not empty', async () => {
await Promise.all([fs.unlink(redsocksPath), fs.unlink(noProxyPath)]);
const patchBody = { network: { hostname: 'newdevice' } };
await request
.patch('/v1/device/host-config')
.send(patchBody)
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
// Should restart hostname service on successful change
expect(restartServiceSpy.callCount).to.equal(1);
expect(restartServiceSpy).to.have.been.calledWith('resin-hostname');
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.then((response) => {
expect(response.body).to.deep.equal(patchBody);
});
});
it('updates hostname to first 7 digits of device uuid when sent invalid hostname', async () => {
await Promise.all([fs.unlink(redsocksPath), fs.unlink(noProxyPath)]);
await request
.patch('/v1/device/host-config')
.send({ network: { hostname: '' } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
// Should restart hostname service on successful change
expect(restartServiceSpy.callCount).to.equal(1);
expect(restartServiceSpy).to.have.been.calledWith('resin-hostname');
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.then(async (response) => {
const uuidHostname = await config
.get('uuid')
.then((uuid) => uuid?.slice(0, 7));
expect(response.body).to.deep.equal({
network: { hostname: uuidHostname },
});
});
});
it('removes proxy when sent empty proxy object', async () => {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: {} } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then(async (response) => {
validatePatchResponse(response);
expect(fs.stat(redsocksPath)).to.be.rejected;
expect(fs.stat(noProxyPath)).to.be.rejected;
});
expect(restartServiceSpy.callCount).to.equal(2);
expect(restartServiceSpy.args).to.deep.equal([
['resin-proxy-config'],
['redsocks'],
]);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(hostnameOnlyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal(hostnameOnlyRes.body);
});
});
it('updates proxy type when provided valid values', async () => {
// Test each proxy patch sequentially to prevent conflicts when writing to fs
let restartCallCount = 0;
for (const key of Object.keys(validProxyReqs)) {
const patchBodyValuesforKey: string[] | number[] =
validProxyReqs[key];
for (const value of patchBodyValuesforKey) {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: { [key]: value } } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(
sampleResponses.V1.PATCH['/host/device-config'].statusCode,
)
.then((response) => {
validatePatchResponse(response);
});
expect(restartServiceSpy.callCount).to.equal(
++restartCallCount * 2,
);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal({
network: {
hostname: hostnameProxyRes.body.network.hostname,
// All other proxy configs should be unchanged except for any values sent in patch
proxy: {
...hostnameProxyRes.body.network.proxy,
[key]: value,
},
},
});
});
} // end for (const value of patchBodyValuesforKey)
await restoreConfFileTemplates();
} // end for (const key in validProxyReqs)
});
it('warns on the supervisor console when provided disallowed proxy fields', async () => {
const invalidProxyReqs: { [key: string]: string | number } = {
// At this time, don't support changing local_ip or local_port
local_ip: '0.0.0.0',
local_port: 12345,
type: 'invalidType',
noProxy: 'not a list of addresses',
};
for (const key of Object.keys(invalidProxyReqs)) {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: { [key]: invalidProxyReqs[key] } } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(200)
.then(() => {
if (key === 'type') {
expect(logWarnStub).to.have.been.calledWith(
`Invalid redsocks proxy type, must be one of ${validProxyReqs.type.join(
', ',
)}`,
);
} else if (key === 'noProxy') {
expect(logWarnStub).to.have.been.calledWith(
'noProxy field must be an array of addresses',
);
} else {
expect(logWarnStub).to.have.been.calledWith(
`Invalid proxy field(s): ${key}`,
);
}
});
}
});
it('replaces no_proxy file with noProxy array from PATCH body', async () => {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: { noProxy: ['1.2.3.4/5'] } } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
expect(restartServiceSpy.callCount).to.equal(2);
expect(restartServiceSpy.args).to.deep.equal([
['resin-proxy-config'],
['redsocks'],
]);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal({
network: {
hostname: hostnameProxyRes.body.network.hostname,
// New noProxy should be only value in no_proxy file
proxy: {
...hostnameProxyRes.body.network.proxy,
noProxy: ['1.2.3.4/5'],
},
},
});
});
});
it('removes no_proxy file when sent an empty array', async () => {
await request
.patch('/v1/device/host-config')
.send({ network: { proxy: { noProxy: [] } } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
expect(restartServiceSpy.callCount).to.equal(2);
expect(restartServiceSpy.args).to.deep.equal([
['resin-proxy-config'],
['redsocks'],
]);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal({
network: {
hostname: hostnameProxyRes.body.network.hostname,
// Reference all properties in proxy object EXCEPT noProxy
proxy: {
ip: hostnameProxyRes.body.network.proxy.ip,
login: hostnameProxyRes.body.network.proxy.login,
password: hostnameProxyRes.body.network.proxy.password,
port: hostnameProxyRes.body.network.proxy.port,
type: hostnameProxyRes.body.network.proxy.type,
},
},
});
});
});
it('does not update hostname or proxy when hostname or proxy are undefined', async () => {
await request
.patch('/v1/device/host-config')
.send({ network: {} })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.PATCH['/host/device-config'].statusCode)
.then((response) => {
validatePatchResponse(response);
});
// As no host configs were patched, no services should be restarted
expect(restartServiceSpy.callCount).to.equal(0);
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(hostnameProxyRes.statusCode)
.then((response) => {
expect(response.body).to.deep.equal(hostnameProxyRes.body);
});
});
it('warns on console when sent a malformed patch body', async () => {
await request
.patch('/v1/device/host-config')
.send({})
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(200)
.then(() => {
expect(logWarnStub).to.have.been.calledWith(
"Key 'network' must exist in PATCH body",
);
});
expect(restartServiceSpy.callCount).to.equal(0);
});
});
});
describe('POST /v1/purge', () => {
it('errors if no appId found in request body', async () => {
await request
.post('/v1/purge')
.send({})
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(
sampleResponses.V1.POST['/purge [400 Invalid/missing appId]']
.statusCode,
)
.then((response) => {
expect(response.text).to.equal(
sampleResponses.V1.POST['/purge [400 Invalid/missing appId]'].text,
);
});
});
it('purges the /data directory with valid appId', async () => {
const doPurgeStub: SinonStub = stub(deviceAPI, 'doPurge').resolves();
await mockedDockerode.testWithData({ containers, images }, async () => {
await request
.post('/v1/purge')
.send({ appId: 2 })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V1.POST['/purge [200]'].statusCode)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V1.POST['/purge [200]'].body,
);
});
});
expect(doPurgeStub.callCount).to.equal(1);
doPurgeStub.restore();
});
it('errors if appId is out of scope (application not available)', async () => {
// Generate a new scoped key to call the endpoint, as mocked
// appId = 2 services are all in the global scope and thus
// resolve to true for any isScoped check
const scopedKey = await apiKeys.generateScopedKey(
2,
containers[0].serviceId,
);
await request
.post('/v1/purge')
.send({ appId: 3 })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${scopedKey}`)
.expect(sampleResponses.V1.POST['/purge [401 Out of scope]'].statusCode)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V1.POST['/purge [401 Out of scope]'].body,
);
});
});
});
});

View File

@ -42,6 +42,26 @@
"body": {
"containerId": "abc123"
}
},
"/device/host-config [Hostname only]": {
"statusCode": 200,
"body": { "network": { "hostname": "foobardevice" } }
},
"/device/host-config [Hostname and proxy]": {
"statusCode": 200,
"body": {
"network": {
"hostname": "foobardevice",
"proxy": {
"ip": "example.org",
"noProxy": ["152.10.30.4", "253.1.1.0/16"],
"port": 1080,
"type": "socks5",
"login": "foo",
"password": "bar"
}
}
}
}
},
"POST": {
@ -64,6 +84,37 @@
"statusCode": 202,
"body": {},
"text": "OK"
},
"/blink": {
"statusCode": 200,
"body": {},
"text": "OK"
},
"/regenerate-api-key": {
"statusCode": 200,
"body": {}
},
"/purge [200]": {
"statusCode": 200,
"body": { "Data": "OK", "Error": "" }
},
"/purge [400 Invalid/missing appId]": {
"statusCode": 400,
"text": "Invalid or missing appId"
},
"/purge [401 Out of scope]": {
"statusCode": 401,
"body": {
"status": "failed",
"message": "Application is not available"
}
}
},
"PATCH": {
"/host/device-config": {
"statusCode": 200,
"body": {},
"text": "OK"
}
}
},

View File

@ -0,0 +1 @@
foobardevice

View File

@ -0,0 +1,2 @@
152.10.30.4
253.1.1.0/16

View File

@ -0,0 +1,2 @@
152.10.30.4
253.1.1.0/16

View File

@ -0,0 +1,17 @@
base {
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";
}

View File

@ -0,0 +1,17 @@
base {
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";
}

View File

@ -26,6 +26,9 @@ stub(dbus, 'getBus').returns({
StartUnit: (_unitName: string) => {
// noop
},
RestartUnit: (_unitName: string, _mode: string) => {
// noop
},
} as any);
},
} as any);