mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-24 07:46:41 +00:00
669866b4c2
Some recent changes to the OS allowed some services to restart automatically when the associated config files are changed. In these cases we want to avoid restarting the same services manually from the supervisor. Change-type: patch Signed-off-by: Kyle Harding <kyle@balena.io>
1372 lines
42 KiB
TypeScript
1372 lines
42 KiB
TypeScript
import * as _ from 'lodash';
|
|
import * as Bluebird from 'bluebird';
|
|
import { expect } from 'chai';
|
|
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 { exists, unlinkAll } from '../src/lib/fs-utils';
|
|
import * as appMock from './lib/application-state-mock';
|
|
import * as mockedDockerode from './lib/mocked-dockerode';
|
|
import mockedAPI = require('./lib/mocked-device-api');
|
|
import sampleResponses = require('./data/device-api-responses.json');
|
|
import * as config from '../src/config';
|
|
import * as logger from '../src/logger';
|
|
import SupervisorAPI from '../src/supervisor-api';
|
|
import * as apiBinder from '../src/api-binder';
|
|
import * as deviceState from '../src/device-state';
|
|
import * as apiKeys from '../src/lib/api-keys';
|
|
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;
|
|
let healthCheckStubs: SinonStub[];
|
|
let targetStateCacheMock: SinonStub;
|
|
const request = supertest(
|
|
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
|
|
);
|
|
const services = [
|
|
{ appId: 2, serviceId: 640681, serviceName: 'one' },
|
|
{ appId: 2, serviceId: 640682, serviceName: 'two' },
|
|
{ appId: 2, serviceId: 640683, serviceName: 'three' },
|
|
];
|
|
const containers = services.map((service) => mockedAPI.mockService(service));
|
|
const images = services.map((service) => mockedAPI.mockImage(service));
|
|
|
|
let loggerStub: SinonStub;
|
|
|
|
beforeEach(() => {
|
|
// Mock a 3 container release
|
|
appMock.mockManagers(containers, [], []);
|
|
appMock.mockImages([], false, images);
|
|
appMock.mockSupervisorNetwork(true);
|
|
|
|
targetStateCacheMock.resolves({
|
|
appId: 2,
|
|
commit: 'abcdef2',
|
|
name: 'test-app2',
|
|
source: 'https://api.balena-cloud.com',
|
|
releaseId: 1232,
|
|
services: JSON.stringify(services),
|
|
networks: '[]',
|
|
volumes: '[]',
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clear Dockerode actions recorded for each test
|
|
mockedDockerode.resetHistory();
|
|
appMock.unmockAll();
|
|
});
|
|
|
|
before(async () => {
|
|
await apiBinder.initialized;
|
|
await deviceState.initialized;
|
|
await targetStateCache.initialized;
|
|
|
|
// Stub health checks so we can modify them whenever needed
|
|
healthCheckStubs = [
|
|
stub(apiBinder, 'healthcheck'),
|
|
stub(deviceState, 'healthcheck'),
|
|
];
|
|
|
|
// The mockedAPI contains stubs that might create unexpected results
|
|
// See the module to know what has been stubbed
|
|
api = await mockedAPI.create();
|
|
|
|
// Start test API
|
|
await api.listen(
|
|
mockedAPI.mockedOptions.listenPort,
|
|
mockedAPI.mockedOptions.timeout,
|
|
);
|
|
|
|
// Mock target state cache
|
|
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
|
|
|
|
// Create a scoped key
|
|
await apiKeys.initialized;
|
|
await apiKeys.generateCloudKey();
|
|
|
|
// Stub logs for all API methods
|
|
loggerStub = stub(logger, 'attach');
|
|
loggerStub.resolves();
|
|
});
|
|
|
|
after(async () => {
|
|
try {
|
|
await api.stop();
|
|
} catch (e) {
|
|
if (e.message !== 'Server is not running.') {
|
|
throw e;
|
|
}
|
|
}
|
|
// Restore healthcheck stubs
|
|
healthCheckStubs.forEach((hc) => hc.restore());
|
|
// Remove any test data generated
|
|
await mockedAPI.cleanUp();
|
|
targetStateCacheMock.restore();
|
|
loggerStub.restore();
|
|
});
|
|
|
|
describe('POST /v1/restart', () => {
|
|
it('restarts all containers in release', async () => {
|
|
// Perform the test with our mocked release
|
|
await mockedDockerode.testWithData({ containers, images }, async () => {
|
|
// Perform test
|
|
await request
|
|
.post('/v1/restart')
|
|
.send({ appId: 2 })
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(sampleResponses.V1.POST['/restart'].statusCode)
|
|
.then((response) => {
|
|
expect(response.body).to.deep.equal(
|
|
sampleResponses.V1.POST['/restart'].body,
|
|
);
|
|
expect(response.text).to.deep.equal(
|
|
sampleResponses.V1.POST['/restart'].text,
|
|
);
|
|
});
|
|
// Check that mockedDockerode contains 3 stop and start actions
|
|
const removeSteps = _(mockedDockerode.actions)
|
|
.pickBy({ name: 'stop' })
|
|
.map()
|
|
.value();
|
|
expect(removeSteps).to.have.lengthOf(3);
|
|
const startSteps = _(mockedDockerode.actions)
|
|
.pickBy({ name: 'start' })
|
|
.map()
|
|
.value();
|
|
expect(startSteps).to.have.lengthOf(3);
|
|
});
|
|
});
|
|
|
|
it('validates request body parameters', async () => {
|
|
await request
|
|
.post('/v1/restart')
|
|
.send({ thing: '' })
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(sampleResponses.V1.POST['/restart [Invalid Body]'].statusCode)
|
|
.then((response) => {
|
|
expect(response.body).to.deep.equal(
|
|
sampleResponses.V1.POST['/restart [Invalid Body]'].body,
|
|
);
|
|
expect(response.text).to.deep.equal(
|
|
sampleResponses.V1.POST['/restart [Invalid Body]'].text,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /v1/healthy', () => {
|
|
it('returns OK because all checks pass', async () => {
|
|
// Make all healthChecks pass
|
|
healthCheckStubs.forEach((hc) => hc.resolves(true));
|
|
await request
|
|
.get('/v1/healthy')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(sampleResponses.V1.GET['/healthy'].statusCode)
|
|
.then((response) => {
|
|
expect(response.body).to.deep.equal(
|
|
sampleResponses.V1.GET['/healthy'].body,
|
|
);
|
|
expect(response.text).to.deep.equal(
|
|
sampleResponses.V1.GET['/healthy'].text,
|
|
);
|
|
});
|
|
});
|
|
it('Fails because some checks did not pass', async () => {
|
|
healthCheckStubs.forEach((hc) => hc.resolves(false));
|
|
await request
|
|
.get('/v1/healthy')
|
|
.set('Accept', 'application/json')
|
|
.expect(sampleResponses.V1.GET['/healthy [2]'].statusCode)
|
|
.then((response) => {
|
|
expect(response.body).to.deep.equal(
|
|
sampleResponses.V1.GET['/healthy [2]'].body,
|
|
);
|
|
expect(response.text).to.deep.equal(
|
|
sampleResponses.V1.GET['/healthy [2]'].text,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /v1/apps/:appId', () => {
|
|
it('does not return information for an application when there is more than 1 container', async () => {
|
|
await request
|
|
.get('/v1/apps/2')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(
|
|
sampleResponses.V1.GET['/apps/2 [Multiple containers running]']
|
|
.statusCode,
|
|
);
|
|
});
|
|
|
|
it('returns information about a specific application', async () => {
|
|
// Setup single container application
|
|
const container = mockedAPI.mockService({
|
|
containerId: 'abc123',
|
|
appId: 2,
|
|
releaseId: 77777,
|
|
});
|
|
const image = mockedAPI.mockImage({
|
|
appId: 2,
|
|
});
|
|
appMock.mockManagers([container], [], []);
|
|
appMock.mockImages([], false, [image]);
|
|
await mockedDockerode.testWithData(
|
|
{ containers: [container], images: [image] },
|
|
async () => {
|
|
// Make request
|
|
await request
|
|
.get('/v1/apps/2')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(sampleResponses.V1.GET['/apps/2'].statusCode)
|
|
.expect('Content-Type', /json/)
|
|
.then((response) => {
|
|
expect(response.body).to.deep.equal(
|
|
sampleResponses.V1.GET['/apps/2'].body,
|
|
);
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
|
|
// manager methods. A refactor is needed
|
|
describe.skip('POST /v1/apps/:appId/stop', () => {
|
|
it('does not allow stopping an application when there is more than 1 container', async () => {
|
|
// Every test case in this suite has a 3 service release mocked so just make the request
|
|
await mockedDockerode.testWithData({ containers, images }, async () => {
|
|
await request
|
|
.post('/v1/apps/2/stop')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(
|
|
sampleResponses.V1.GET['/apps/2/stop [Multiple containers running]']
|
|
.statusCode,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('stops a SPECIFIC application and returns a containerId', async () => {
|
|
// Setup single container application
|
|
const container = mockedAPI.mockService({
|
|
containerId: 'abc123',
|
|
appId: 2,
|
|
});
|
|
const image = mockedAPI.mockImage({
|
|
appId: 2,
|
|
});
|
|
appMock.mockManagers([container], [], []);
|
|
appMock.mockImages([], false, [image]);
|
|
// Perform the test with our mocked release
|
|
await mockedDockerode.testWithData(
|
|
{ containers: [container], images: [image] },
|
|
async () => {
|
|
await request
|
|
.post('/v1/apps/2/stop')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode)
|
|
.expect('Content-Type', /json/)
|
|
.then((response) => {
|
|
expect(response.body).to.deep.equal(
|
|
sampleResponses.V1.GET['/apps/2/stop'].body,
|
|
);
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
|
|
// manager methods. A refactor is needed
|
|
describe.skip('POST /v1/apps/:appId/start', () => {
|
|
it('does not allow starting an application when there is more than 1 container', async () => {
|
|
// Every test case in this suite has a 3 service release mocked so just make the request
|
|
await mockedDockerode.testWithData({ containers, images }, async () => {
|
|
await request
|
|
.post('/v1/apps/2/start')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(400);
|
|
});
|
|
});
|
|
|
|
it('starts a SPECIFIC application and returns a containerId', async () => {
|
|
const service = {
|
|
serviceName: 'main',
|
|
containerId: 'abc123',
|
|
appId: 2,
|
|
serviceId: 640681,
|
|
};
|
|
// Setup single container application
|
|
const container = mockedAPI.mockService(service);
|
|
const image = mockedAPI.mockImage(service);
|
|
appMock.mockManagers([container], [], []);
|
|
appMock.mockImages([], false, [image]);
|
|
|
|
// Target state returns single service
|
|
targetStateCacheMock.resolves({
|
|
appId: 2,
|
|
commit: 'abcdef2',
|
|
name: 'test-app2',
|
|
source: 'https://api.balena-cloud.com',
|
|
releaseId: 1232,
|
|
services: JSON.stringify([service]),
|
|
volumes: '[]',
|
|
networks: '[]',
|
|
});
|
|
|
|
// Perform the test with our mocked release
|
|
await mockedDockerode.testWithData(
|
|
{ containers: [container], images: [image] },
|
|
async () => {
|
|
await request
|
|
.post('/v1/apps/2/start')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(200)
|
|
.expect('Content-Type', /json/)
|
|
.then((response) => {
|
|
expect(response.body).to.deep.equal({ containerId: 'abc123' });
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('GET /v1/device', () => {
|
|
it('returns MAC address', async () => {
|
|
const response = await request
|
|
.get('/v1/device')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).to.have.property('mac_address').that.is.not.empty;
|
|
});
|
|
});
|
|
|
|
describe('POST /v1/reboot', () => {
|
|
let rebootMock: SinonStub;
|
|
before(() => {
|
|
rebootMock = stub(dbus, 'reboot').resolves((() => void 0) as any);
|
|
});
|
|
|
|
after(() => {
|
|
rebootMock.restore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rebootMock.resetHistory();
|
|
});
|
|
|
|
it('should return 202 and reboot if no locks are set', async () => {
|
|
// Setup single container application
|
|
const container = mockedAPI.mockService({
|
|
containerId: 'abc123',
|
|
appId: 2,
|
|
releaseId: 77777,
|
|
});
|
|
const image = mockedAPI.mockImage({
|
|
appId: 2,
|
|
});
|
|
appMock.mockManagers([container], [], []);
|
|
appMock.mockImages([], false, [image]);
|
|
|
|
await mockedDockerode.testWithData(
|
|
{ containers: [container], images: [image] },
|
|
async () => {
|
|
const response = await request
|
|
.post('/v1/reboot')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(202);
|
|
|
|
expect(response.body).to.have.property('Data').that.is.not.empty;
|
|
expect(rebootMock).to.have.been.calledOnce;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should return 423 and reject the reboot if no locks are set', async () => {
|
|
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
|
|
if (opts.force) {
|
|
return Bluebird.resolve(fn());
|
|
}
|
|
throw new UpdatesLockedError('Updates locked');
|
|
});
|
|
|
|
// Setup single container application
|
|
const container = mockedAPI.mockService({
|
|
containerId: 'abc123',
|
|
appId: 2,
|
|
releaseId: 77777,
|
|
});
|
|
const image = mockedAPI.mockImage({
|
|
appId: 2,
|
|
});
|
|
appMock.mockManagers([container], [], []);
|
|
appMock.mockImages([], false, [image]);
|
|
|
|
await mockedDockerode.testWithData(
|
|
{ containers: [container], images: [image] },
|
|
async () => {
|
|
const response = await request
|
|
.post('/v1/reboot')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(423);
|
|
|
|
expect(updateLock.lock).to.be.calledOnce;
|
|
expect(response.body).to.have.property('Error').that.is.not.empty;
|
|
expect(rebootMock).to.not.have.been.called;
|
|
},
|
|
);
|
|
|
|
(updateLock.lock as SinonStub).restore();
|
|
});
|
|
|
|
it('should return 202 and reboot if force is set to true', async () => {
|
|
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
|
|
if (opts.force) {
|
|
return Bluebird.resolve(fn());
|
|
}
|
|
throw new UpdatesLockedError('Updates locked');
|
|
});
|
|
|
|
// Setup single container application
|
|
const container = mockedAPI.mockService({
|
|
containerId: 'abc123',
|
|
appId: 2,
|
|
releaseId: 77777,
|
|
});
|
|
const image = mockedAPI.mockImage({
|
|
appId: 2,
|
|
});
|
|
appMock.mockManagers([container], [], []);
|
|
appMock.mockImages([], false, [image]);
|
|
|
|
await mockedDockerode.testWithData(
|
|
{ containers: [container], images: [image] },
|
|
async () => {
|
|
const response = await request
|
|
.post('/v1/reboot')
|
|
.send({ force: true })
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(202);
|
|
|
|
expect(updateLock.lock).to.be.calledOnce;
|
|
expect(response.body).to.have.property('Data').that.is.not.empty;
|
|
expect(rebootMock).to.have.been.calledOnce;
|
|
},
|
|
);
|
|
|
|
(updateLock.lock as SinonStub).restore();
|
|
});
|
|
});
|
|
|
|
describe('POST /v1/shutdown', () => {
|
|
let shutdownMock: SinonStub;
|
|
before(() => {
|
|
shutdownMock = stub(dbus, 'shutdown').resolves((() => void 0) as any);
|
|
});
|
|
|
|
after(async () => {
|
|
shutdownMock.restore();
|
|
});
|
|
|
|
it('should return 202 and shutdown if no locks are set', async () => {
|
|
// Setup single container application
|
|
const container = mockedAPI.mockService({
|
|
containerId: 'abc123',
|
|
appId: 2,
|
|
releaseId: 77777,
|
|
});
|
|
const image = mockedAPI.mockImage({
|
|
appId: 2,
|
|
});
|
|
appMock.mockManagers([container], [], []);
|
|
appMock.mockImages([], false, [image]);
|
|
|
|
await mockedDockerode.testWithData(
|
|
{ containers: [container], images: [image] },
|
|
async () => {
|
|
const response = await request
|
|
.post('/v1/shutdown')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(202);
|
|
|
|
expect(response.body).to.have.property('Data').that.is.not.empty;
|
|
expect(shutdownMock).to.have.been.calledOnce;
|
|
},
|
|
);
|
|
|
|
shutdownMock.resetHistory();
|
|
});
|
|
|
|
it('should return 423 and reject the reboot if no locks are set', async () => {
|
|
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
|
|
if (opts.force) {
|
|
return Bluebird.resolve(fn());
|
|
}
|
|
throw new UpdatesLockedError('Updates locked');
|
|
});
|
|
|
|
// Setup single container application
|
|
const container = mockedAPI.mockService({
|
|
containerId: 'abc123',
|
|
appId: 2,
|
|
releaseId: 77777,
|
|
});
|
|
const image = mockedAPI.mockImage({
|
|
appId: 2,
|
|
});
|
|
appMock.mockManagers([container], [], []);
|
|
appMock.mockImages([], false, [image]);
|
|
|
|
await mockedDockerode.testWithData(
|
|
{ containers: [container], images: [image] },
|
|
async () => {
|
|
const response = await request
|
|
.post('/v1/shutdown')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(423);
|
|
|
|
expect(updateLock.lock).to.be.calledOnce;
|
|
expect(response.body).to.have.property('Error').that.is.not.empty;
|
|
expect(shutdownMock).to.not.have.been.called;
|
|
},
|
|
);
|
|
|
|
(updateLock.lock as SinonStub).restore();
|
|
});
|
|
|
|
it('should return 202 and shutdown if force is set to true', async () => {
|
|
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
|
|
if (opts.force) {
|
|
return Bluebird.resolve(fn());
|
|
}
|
|
throw new UpdatesLockedError('Updates locked');
|
|
});
|
|
|
|
// Setup single container application
|
|
const container = mockedAPI.mockService({
|
|
containerId: 'abc123',
|
|
appId: 2,
|
|
releaseId: 77777,
|
|
});
|
|
const image = mockedAPI.mockImage({
|
|
appId: 2,
|
|
});
|
|
appMock.mockManagers([container], [], []);
|
|
appMock.mockImages([], false, [image]);
|
|
|
|
await mockedDockerode.testWithData(
|
|
{ containers: [container], images: [image] },
|
|
async () => {
|
|
const response = await request
|
|
.post('/v1/shutdown')
|
|
.send({ force: true })
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(202);
|
|
|
|
expect(updateLock.lock).to.be.calledOnce;
|
|
expect(response.body).to.have.property('Data').that.is.not.empty;
|
|
expect(shutdownMock).to.have.been.calledOnce;
|
|
},
|
|
);
|
|
|
|
(updateLock.lock as SinonStub).restore();
|
|
});
|
|
});
|
|
|
|
describe('POST /v1/update', () => {
|
|
let configStub: SinonStub;
|
|
let targetUpdateSpy: SinonSpy;
|
|
|
|
before(() => {
|
|
configStub = stub(config, 'get');
|
|
targetUpdateSpy = spy(TargetState, 'update');
|
|
});
|
|
|
|
afterEach(() => {
|
|
targetUpdateSpy.resetHistory();
|
|
});
|
|
|
|
after(() => {
|
|
configStub.restore();
|
|
targetUpdateSpy.restore();
|
|
});
|
|
|
|
it('returns 204 with no parameters', async () => {
|
|
// Stub response for getting instantUpdates
|
|
configStub.resolves(true);
|
|
// Make request
|
|
await request
|
|
.post('/v1/update')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(sampleResponses.V1.POST['/update [204 Response]'].statusCode);
|
|
// Check that TargetState.update was called
|
|
expect(targetUpdateSpy).to.be.called;
|
|
expect(targetUpdateSpy).to.be.calledWith(undefined, true);
|
|
});
|
|
|
|
it('returns 204 with force: true in body', async () => {
|
|
// Stub response for getting instantUpdates
|
|
configStub.resolves(true);
|
|
// Make request with force: true in the body
|
|
await request
|
|
.post('/v1/update')
|
|
.send({ force: true })
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(sampleResponses.V1.POST['/update [204 Response]'].statusCode);
|
|
// Check that TargetState.update was called
|
|
expect(targetUpdateSpy).to.be.called;
|
|
expect(targetUpdateSpy).to.be.calledWith(true, true);
|
|
});
|
|
|
|
it('returns 202 when instantUpdates are disabled', async () => {
|
|
// Stub response for getting instantUpdates
|
|
configStub.resolves(false);
|
|
// Make request
|
|
await request
|
|
.post('/v1/update')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(sampleResponses.V1.POST['/update [202 Response]'].statusCode);
|
|
// Check that TargetState.update was not called
|
|
expect(targetUpdateSpy).to.not.be.called;
|
|
});
|
|
});
|
|
|
|
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 unlinkAll(redsocksPath, 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 unlinkAll(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 () => {
|
|
// stub servicePartOf to throw exceptions for the new service names
|
|
stub(dbus, 'servicePartOf').callsFake(
|
|
async (serviceName: string): Promise<string> => {
|
|
if (serviceName === 'balena-hostname') {
|
|
throw new Error('Unit not loaded.');
|
|
}
|
|
return '';
|
|
},
|
|
);
|
|
await unlinkAll(redsocksPath, 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 services
|
|
expect(restartServiceSpy.callCount).to.equal(2);
|
|
expect(restartServiceSpy.args).to.deep.equal([
|
|
['balena-hostname'],
|
|
['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);
|
|
});
|
|
|
|
(dbus.servicePartOf as SinonStub).restore();
|
|
});
|
|
|
|
it('skips restarting hostname services if they are part of config-json.target', async () => {
|
|
// stub servicePartOf to return the config-json.target we are looking for
|
|
stub(dbus, 'servicePartOf').callsFake(
|
|
async (): Promise<string> => {
|
|
return 'config-json.target';
|
|
},
|
|
);
|
|
|
|
await unlinkAll(redsocksPath, 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);
|
|
});
|
|
|
|
// skips restarting hostname services if they are part of config-json.target
|
|
expect(restartServiceSpy.callCount).to.equal(0);
|
|
|
|
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);
|
|
});
|
|
|
|
(dbus.servicePartOf as SinonStub).restore();
|
|
});
|
|
|
|
it('updates hostname to first 7 digits of device uuid when sent invalid hostname', async () => {
|
|
await unlinkAll(redsocksPath, 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 services
|
|
expect(restartServiceSpy.callCount).to.equal(2);
|
|
expect(restartServiceSpy.args).to.deep.equal([
|
|
['balena-hostname'],
|
|
['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(await exists(redsocksPath)).to.be.false;
|
|
expect(await exists(noProxyPath)).to.be.false;
|
|
});
|
|
|
|
// should restart services
|
|
expect(restartServiceSpy.callCount).to.equal(3);
|
|
expect(restartServiceSpy.args).to.deep.equal([
|
|
['balena-proxy-config'],
|
|
['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 () => {
|
|
// stub servicePartOf to throw exceptions for the new service names
|
|
stub(dbus, 'servicePartOf').callsFake(
|
|
async (serviceName: string): Promise<string> => {
|
|
if (serviceName === 'balena-proxy-config') {
|
|
throw new Error('Unit not loaded.');
|
|
}
|
|
return '';
|
|
},
|
|
);
|
|
// 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);
|
|
});
|
|
|
|
// should restart services
|
|
expect(restartServiceSpy.callCount).to.equal(
|
|
++restartCallCount * 3,
|
|
);
|
|
|
|
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)
|
|
(dbus.servicePartOf as SinonStub).restore();
|
|
});
|
|
|
|
it('skips restarting proxy services when part of redsocks-conf.target', async () => {
|
|
// stub servicePartOf to return the redsocks-conf.target we are looking for
|
|
stub(dbus, 'servicePartOf').callsFake(
|
|
async (): Promise<string> => {
|
|
return 'redsocks-conf.target';
|
|
},
|
|
);
|
|
// Test each proxy patch sequentially to prevent conflicts when writing to fs
|
|
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);
|
|
});
|
|
|
|
// skips restarting proxy services when part of redsocks-conf.target
|
|
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({
|
|
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)
|
|
(dbus.servicePartOf as SinonStub).restore();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// should restart services
|
|
expect(restartServiceSpy.callCount).to.equal(3);
|
|
expect(restartServiceSpy.args).to.deep.equal([
|
|
['balena-proxy-config'],
|
|
['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);
|
|
});
|
|
|
|
// should restart services
|
|
expect(restartServiceSpy.callCount).to.equal(3);
|
|
expect(restartServiceSpy.args).to.deep.equal([
|
|
['balena-proxy-config'],
|
|
['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].serviceName,
|
|
);
|
|
|
|
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,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|