balena-supervisor/test/42-device-api-v2.spec.ts
Felipe Lalanne 4cdf26f82f Improve supervisor API behavior when locks are set
This PR adds the following

* Supervisor v1 API application actions now return HTTP status code 423 when locks
  are preventing the action to be performed. Previously this resulted in a
  503 error
* Supervisor API v2 service actions now returns HTTP status code 423 when locks are
  preventing the action to be performed. Previously, this resulted in an
  exception logged by the supervisor and the API query timing out
* Supervisor API `/v2/applications/:appId/start-service` now does not
  check for a lock. Lock handling in v2 actions is now performed by each
  step executor
* `/v1/apps/:appId/start` now queries the target state and uses that
  information to execute the start step (as v2 does). Previously start
  resulted in `cannot get appId from undefined`
* Extra tests for API methods

Change-type: patch
Connects-to: #1523
Signed-off-by: Felipe Lalanne <felipe@balena.io>
2020-12-14 10:43:41 -03:00

533 lines
16 KiB
TypeScript

import { expect } from 'chai';
import { stub, SinonStub, spy, SinonSpy } from 'sinon';
import * as supertest from 'supertest';
import * as Bluebird from 'bluebird';
import sampleResponses = require('./data/device-api-responses.json');
import mockedAPI = require('./lib/mocked-device-api');
import * as apiBinder from '../src/api-binder';
import * as deviceState from '../src/device-state';
import SupervisorAPI from '../src/supervisor-api';
import * as serviceManager from '../src/compose/service-manager';
import * as images from '../src/compose/images';
import * as apiKeys from '../src/lib/api-keys';
import * as config from '../src/config';
import * as updateLock from '../src/lib/update-lock';
import * as targetStateCache from '../src/device-state/target-state-cache';
import * as mockedDockerode from './lib/mocked-dockerode';
import * as applicationManager from '../src/compose/application-manager';
import * as logger from '../src/logger';
import { UpdatesLockedError } from '../src/lib/errors';
describe('SupervisorAPI [V2 Endpoints]', () => {
let serviceManagerMock: SinonStub;
let imagesMock: SinonStub;
let applicationManagerSpy: SinonSpy;
let api: SupervisorAPI;
const request = supertest(
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
);
let loggerStub: SinonStub;
before(async () => {
await apiBinder.initialized;
await deviceState.initialized;
// 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,
);
// Create a scoped key
await apiKeys.initialized;
await apiKeys.generateCloudKey();
serviceManagerMock = stub(serviceManager, 'getAll').resolves([]);
imagesMock = stub(images, 'getStatus').resolves([]);
// We want to check the actual step that was triggered
applicationManagerSpy = spy(applicationManager, 'executeStep');
// 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;
}
}
// Remove any test data generated
await mockedAPI.cleanUp();
serviceManagerMock.restore();
imagesMock.restore();
applicationManagerSpy.restore();
loggerStub.restore();
});
afterEach(() => {
mockedDockerode.resetHistory();
applicationManagerSpy.resetHistory();
});
describe('GET /v2/device/vpn', () => {
it('returns information about VPN connection', async () => {
await request
.get('/v2/device/vpn')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect('Content-Type', /json/)
.expect(sampleResponses.V2.GET['/device/vpn'].statusCode)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/device/vpn'].body,
);
});
});
});
describe('GET /v2/applications/:appId/state', () => {
it('returns information about a SPECIFIC application', async () => {
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V2.GET['/applications/1/state'].statusCode)
.expect('Content-Type', /json/)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/applications/1/state'].body,
);
});
});
it('returns 400 for invalid appId', async () => {
await request
.get('/v2/applications/123invalid/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect('Content-Type', /json/)
.expect(
sampleResponses.V2.GET['/applications/123invalid/state'].statusCode,
)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/applications/123invalid/state'].body,
);
});
});
it('returns 409 because app does not exist', async () => {
await request
.get('/v2/applications/9000/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(sampleResponses.V2.GET['/applications/9000/state'].statusCode)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/applications/9000/state'].body,
);
});
});
describe('Scoped API Keys', () => {
it('returns 409 because app is out of scope of the key', async () => {
const apiKey = await apiKeys.generateScopedKey(3, 1);
await request
.get('/v2/applications/2/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKey}`)
.expect(409);
});
});
});
describe('GET /v2/state/status', () => {
it('should return scoped application', async () => {
// Create scoped key for application
const appScopedKey = await apiKeys.generateScopedKey(1658654, 640681);
// Setup device conditions
serviceManagerMock.resolves([mockedAPI.mockService({ appId: 1658654 })]);
imagesMock.resolves([mockedAPI.mockImage({ appId: 1658654 })]);
// Make request and evaluate response
await request
.get('/v2/state/status')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect('Content-Type', /json/)
.expect(
sampleResponses.V2.GET['/state/status?desc=single_application']
.statusCode,
)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/state/status?desc=single_application']
.body,
);
});
});
it('should return no application info due to lack of scope', async () => {
// Create scoped key for wrong application
const appScopedKey = await apiKeys.generateScopedKey(1, 1);
// Setup device conditions
serviceManagerMock.resolves([mockedAPI.mockService({ appId: 1658654 })]);
imagesMock.resolves([mockedAPI.mockImage({ appId: 1658654 })]);
// Make request and evaluate response
await request
.get('/v2/state/status')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect('Content-Type', /json/)
.expect(
sampleResponses.V2.GET['/state/status?desc=no_applications']
.statusCode,
)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/state/status?desc=no_applications'].body,
);
});
});
it('should return success when device has no applications', async () => {
// Create scoped key for any application
const appScopedKey = await apiKeys.generateScopedKey(1658654, 1658654);
// Setup device conditions
serviceManagerMock.resolves([]);
imagesMock.resolves([]);
// Make request and evaluate response
await request
.get('/v2/state/status')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect('Content-Type', /json/)
.expect(
sampleResponses.V2.GET['/state/status?desc=no_applications']
.statusCode,
)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/state/status?desc=no_applications'].body,
);
});
});
it('should only return 1 application when N > 1 applications on device', async () => {
// Create scoped key for application
const appScopedKey = await apiKeys.generateScopedKey(1658654, 640681);
// Setup device conditions
serviceManagerMock.resolves([
mockedAPI.mockService({ appId: 1658654 }),
mockedAPI.mockService({ appId: 222222 }),
]);
imagesMock.resolves([
mockedAPI.mockImage({ appId: 1658654 }),
mockedAPI.mockImage({ appId: 222222 }),
]);
// Make request and evaluate response
await request
.get('/v2/state/status')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect('Content-Type', /json/)
.expect(
sampleResponses.V2.GET['/state/status?desc=single_application']
.statusCode,
)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/state/status?desc=single_application']
.body,
);
});
});
it('should only return 1 application when in LOCAL MODE (no auth)', async () => {
// Activate localmode
await config.set({ localMode: true });
// Setup device conditions
serviceManagerMock.resolves([
mockedAPI.mockService({ appId: 1658654 }),
mockedAPI.mockService({ appId: 222222 }),
]);
imagesMock.resolves([
mockedAPI.mockImage({ appId: 1658654 }),
mockedAPI.mockImage({ appId: 222222 }),
]);
// Make request and evaluate response
await request
.get('/v2/state/status')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(
sampleResponses.V2.GET['/state/status?desc=single_application']
.statusCode,
)
.then((response) => {
expect(response.body).to.deep.equal(
sampleResponses.V2.GET['/state/status?desc=single_application']
.body,
);
});
// Deactivate localmode
await config.set({ localMode: false });
});
});
describe('POST /v2/applications/:appId/start-service', function () {
let appScopedKey: string;
let targetStateCacheMock: SinonStub;
let lockMock: SinonStub;
const service = {
serviceName: 'main',
containerId: 'abc123',
appId: 1658654,
serviceId: 640681,
};
const mockContainers = [mockedAPI.mockService(service)];
const mockImages = [mockedAPI.mockImage(service)];
beforeEach(() => {
// Setup device conditions
serviceManagerMock.resolves(mockContainers);
imagesMock.resolves(mockImages);
targetStateCacheMock.resolves({
appId: 2,
commit: 'abcdef2',
name: 'test-app2',
source: 'https://api.balena-cloud.com',
releaseId: 1232,
services: JSON.stringify([service]),
networks: '{}',
volumes: '{}',
});
lockMock.reset();
});
before(async () => {
// Create scoped key for application
appScopedKey = await apiKeys.generateScopedKey(1658654, 640681);
// Mock target state cache
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
lockMock = stub(updateLock, 'lock');
});
after(async () => {
targetStateCacheMock.restore();
lockMock.restore();
});
it('should return 200 for an existing service', async () => {
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/start-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main' })
.set('Content-type', 'application/json')
.expect(200);
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
it('should return 404 for an unknown service', async () => {
await request
.post(`/v2/applications/1658654/start-service?apikey=${appScopedKey}`)
.send({ serviceName: 'unknown' })
.set('Content-type', 'application/json')
.expect(404);
expect(applicationManagerSpy).to.not.have.been.called;
});
it('should ignore locks and return 200', async () => {
// Turn lock on
lockMock.throws(new UpdatesLockedError('Updates locked'));
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/start-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main' })
.set('Content-type', 'application/json')
.expect(200);
expect(lockMock).to.not.have.been.called;
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
});
describe('POST /v2/applications/:appId/restart-service', () => {
let appScopedKey: string;
let targetStateCacheMock: SinonStub;
let lockMock: SinonStub;
const service = {
serviceName: 'main',
containerId: 'abc123',
appId: 1658654,
serviceId: 640681,
};
const mockContainers = [mockedAPI.mockService(service)];
const mockImages = [mockedAPI.mockImage(service)];
const lockFake = (_: any, opts: { force: boolean }, fn: () => any) => {
if (opts.force) {
return Bluebird.resolve(fn());
}
throw new UpdatesLockedError('Updates locked');
};
beforeEach(() => {
// Setup device conditions
serviceManagerMock.resolves(mockContainers);
imagesMock.resolves(mockImages);
targetStateCacheMock.resolves({
appId: 2,
commit: 'abcdef2',
name: 'test-app2',
source: 'https://api.balena-cloud.com',
releaseId: 1232,
services: JSON.stringify(mockContainers),
networks: '{}',
volumes: '{}',
});
lockMock.reset();
});
before(async () => {
// Create scoped key for application
appScopedKey = await apiKeys.generateScopedKey(1658654, 640681);
// Mock target state cache
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
lockMock = stub(updateLock, 'lock');
});
after(async () => {
targetStateCacheMock.restore();
lockMock.restore();
});
it('should return 200 for an existing service', async () => {
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main' })
.set('Content-type', 'application/json')
.expect(200);
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
it('should return 404 for an unknown service', async () => {
await request
.post(`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`)
.send({ serviceName: 'unknown' })
.set('Content-type', 'application/json')
.expect(404);
expect(applicationManagerSpy).to.not.have.been.called;
});
it('should return 423 for a service with update locks', async () => {
// Turn lock on
lockMock.throws(new UpdatesLockedError('Updates locked'));
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main' })
.set('Content-type', 'application/json')
.expect(423);
expect(lockMock).to.be.calledOnce;
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
it('should return 200 for a service with update locks and force true', async () => {
// Turn lock on
lockMock.callsFake(lockFake);
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main', force: true })
.set('Content-type', 'application/json')
.expect(200);
expect(lockMock).to.be.calledOnce;
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
it('should return 423 if force is explicitely set to false', async () => {
// Turn lock on
lockMock.callsFake(lockFake);
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
await request
.post(
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
)
.send({ serviceName: 'main', force: false })
.set('Content-type', 'application/json')
.expect(423);
expect(lockMock).to.be.calledOnce;
expect(applicationManagerSpy).to.have.been.calledOnce;
},
);
});
});
// TODO: add tests for rest of V2 endpoints
});