mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-22 06:57:49 +00:00
c04955354a
This commit updates all backends that write to /mnt/boot to do it through a new `lib/host-utils` module. Writes are now done using write + sync as rename is not an atomic operation in vfat. The change also applies for writes through the `/v1/host-config` endpoint. Finally this change includes some improvements on tests. Change-type: patch
550 lines
16 KiB
TypeScript
550 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, 'getState').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, 'main');
|
|
await request
|
|
.get('/v2/applications/2/state')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKey}`)
|
|
.expect(409);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /v2/state/status', () => {
|
|
before(() => {
|
|
// Stub isApplyInProgress is no other tests can impact the response data
|
|
stub(deviceState, 'isApplyInProgress').returns(false);
|
|
});
|
|
|
|
after(() => {
|
|
(deviceState.isApplyInProgress as SinonStub).restore();
|
|
});
|
|
|
|
it('should return scoped application', async () => {
|
|
// Create scoped key for application
|
|
const appScopedKey = await apiKeys.generateScopedKey(1658654, 'main');
|
|
// 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, 'main');
|
|
// 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, 'main');
|
|
// 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, 'main');
|
|
// 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,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
|
|
// manager methods. A refactor is needed
|
|
describe.skip('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, 'main');
|
|
|
|
// 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 mockedDockerode.testWithData({}, 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;
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
|
|
// manager methods. A refactor is needed
|
|
describe.skip('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, 'main');
|
|
|
|
// 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 mockedDockerode.testWithData({}, 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
|
|
});
|