balena-supervisor/test/41-device-api-v1.spec.ts

730 lines
21 KiB
TypeScript
Raw Normal View History

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 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 { UpdatesLockedError } from '../src/lib/errors';
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();
});
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();
appMock.unmockAll();
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 () => {
// Every test case in this suite has a 3 service release mocked so just make the request
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]);
// 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,
);
});
});
});
describe('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 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,
);
});
},
);
});
});
describe('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 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]);
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]);
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]);
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]);
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]);
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]);
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();
});
});
// TODO: add tests for V1 endpoints
});