mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-20 06:07:57 +00:00
02aeb4fc1c
Closes: #1512 Change-type: patch Signed-off-by: Rich Bayliss <rich@balena.io>
603 lines
18 KiB
TypeScript
603 lines
18 KiB
TypeScript
import { expect } from 'chai';
|
|
import { spy, stub, SinonStub } from 'sinon';
|
|
import * as supertest from 'supertest';
|
|
|
|
import * as apiBinder from '../src/api-binder';
|
|
import * as deviceState from '../src/device-state';
|
|
import Log from '../src/lib/supervisor-console';
|
|
import SupervisorAPI from '../src/supervisor-api';
|
|
import sampleResponses = require('./data/device-api-responses.json');
|
|
import mockedAPI = require('./lib/mocked-device-api');
|
|
|
|
import * as applicationManager from '../src/compose/application-manager';
|
|
import { InstancedAppState } from '../src/types/state';
|
|
|
|
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 db from '../src/db';
|
|
import * as config from '../src/config';
|
|
import { Service } from '../src/compose/service';
|
|
import { Image } from '../src/compose/images';
|
|
|
|
const mockedOptions = {
|
|
listenPort: 54321,
|
|
timeout: 30000,
|
|
};
|
|
|
|
const mockService = (overrides?: Partial<Service>) => {
|
|
return {
|
|
...{
|
|
appId: 1658654,
|
|
status: 'Running',
|
|
serviceName: 'main',
|
|
imageId: 2885946,
|
|
serviceId: 640681,
|
|
containerId:
|
|
'f93d386599d1b36e71272d46ad69770cff333842db04e2e4c64dda7b54da07c6',
|
|
createdAt: '2020-11-13T20:29:44.143Z',
|
|
},
|
|
...overrides,
|
|
} as Service;
|
|
};
|
|
|
|
const mockImage = (overrides?: Partial<Image>) => {
|
|
return {
|
|
...{
|
|
name:
|
|
'registry2.balena-cloud.com/v2/e2bf6410ffc30850e96f5071cdd1dca8@sha256:e2e87a8139b8fc14510095b210ad652d7d5badcc64fdc686cbf749d399fba15e',
|
|
appId: 1658654,
|
|
serviceName: 'main',
|
|
imageId: 2885946,
|
|
dockerImageId:
|
|
'sha256:4502983d72e2c72bc292effad1b15b49576da3801356f47fd275ba274d409c1a',
|
|
status: 'Downloaded',
|
|
downloadProgress: null,
|
|
},
|
|
...overrides,
|
|
} as Image;
|
|
};
|
|
|
|
describe('SupervisorAPI', () => {
|
|
let api: SupervisorAPI;
|
|
let healthCheckStubs: SinonStub[];
|
|
const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`);
|
|
|
|
before(async () => {
|
|
await apiBinder.initialized;
|
|
await deviceState.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(mockedOptions.listenPort, mockedOptions.timeout);
|
|
|
|
// Create a scoped key
|
|
await apiKeys.initialized;
|
|
await apiKeys.generateCloudKey();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
describe('API Key Scope', () => {
|
|
it('should generate a key which is scoped for a single application', async () => {
|
|
// single app scoped key...
|
|
const appScopedKey = await apiKeys.generateScopedKey(1, 1);
|
|
|
|
await request
|
|
.get('/v2/applications/1/state')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
|
.expect(200);
|
|
});
|
|
it('should generate a key which is scoped for multiple applications', async () => {
|
|
// multi-app scoped key...
|
|
const multiAppScopedKey = await apiKeys.generateScopedKey(1, 2, {
|
|
scopes: [1, 2].map((appId) => {
|
|
return { type: 'app', appId };
|
|
}),
|
|
});
|
|
|
|
await request
|
|
.get('/v2/applications/1/state')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${multiAppScopedKey}`)
|
|
.expect(200);
|
|
|
|
await request
|
|
.get('/v2/applications/2/state')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${multiAppScopedKey}`)
|
|
.expect(200);
|
|
});
|
|
it('should generate a key which is scoped for all applications', async () => {
|
|
await request
|
|
.get('/v2/applications/1/state')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(200);
|
|
|
|
await request
|
|
.get('/v2/applications/2/state')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(200);
|
|
});
|
|
it('should have a cached lookup of the key scopes to save DB loading', async () => {
|
|
const scopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey);
|
|
|
|
const key = 'not-a-normal-key';
|
|
await db.initialized;
|
|
await db
|
|
.models('apiSecret')
|
|
.update({
|
|
key,
|
|
})
|
|
.where({
|
|
key: apiKeys.cloudApiKey,
|
|
});
|
|
|
|
// the key we had is now gone, but the cache should return values
|
|
const cachedScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey);
|
|
expect(cachedScopes).to.deep.equal(scopes);
|
|
|
|
// this should bust the cache...
|
|
await apiKeys.generateCloudKey(true);
|
|
|
|
// the key we changed should be gone now, and the new key should have the cloud scopes
|
|
const missingScopes = await apiKeys.getScopesForKey(key);
|
|
const freshScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey);
|
|
|
|
expect(missingScopes).to.be.null;
|
|
expect(freshScopes).to.deep.equal(scopes);
|
|
});
|
|
it('should regenerate a key and invalidate the old one', async () => {
|
|
// single app scoped key...
|
|
const appScopedKey = await apiKeys.generateScopedKey(1, 1);
|
|
|
|
await request
|
|
.get('/v2/applications/1/state')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
|
.expect(200);
|
|
|
|
const newScopedKey = await apiKeys.refreshKey(appScopedKey);
|
|
|
|
await request
|
|
.get('/v2/applications/1/state')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
|
.expect(401);
|
|
|
|
await request
|
|
.get('/v2/applications/1/state')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${newScopedKey}`)
|
|
.expect(200);
|
|
});
|
|
});
|
|
|
|
describe('/ping', () => {
|
|
it('responds with OK (without auth)', async () => {
|
|
await request.get('/ping').set('Accept', 'application/json').expect(200);
|
|
});
|
|
it('responds with OK (with auth)', async () => {
|
|
await request
|
|
.get('/ping')
|
|
.set('Accept', 'application/json')
|
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
|
.expect(200);
|
|
});
|
|
});
|
|
|
|
describe('V1 endpoints', () => {
|
|
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 () => {
|
|
// Make one of the healthChecks fail
|
|
healthCheckStubs[0].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,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
before(() => {
|
|
const appState = {
|
|
[sampleResponses.V1.GET['/apps/2'].body.appId]: {
|
|
...sampleResponses.V1.GET['/apps/2'].body,
|
|
services: [
|
|
{
|
|
...sampleResponses.V1.GET['/apps/2'].body,
|
|
serviceId: 1,
|
|
serviceName: 'main',
|
|
config: {},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
stub(applicationManager, 'getCurrentApps').resolves(
|
|
(appState as unknown) as InstancedAppState,
|
|
);
|
|
stub(applicationManager, 'executeStep').resolves();
|
|
});
|
|
|
|
after(() => {
|
|
(applicationManager.executeStep as SinonStub).restore();
|
|
(applicationManager.getCurrentApps as SinonStub).restore();
|
|
});
|
|
|
|
// TODO: add tests for V1 endpoints
|
|
describe('GET /v1/apps/:appId', () => {
|
|
it('returns information about a specific application', async () => {
|
|
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('stops a SPECIFIC application and returns a containerId', 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('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('V2 endpoints', () => {
|
|
let serviceManagerMock: SinonStub;
|
|
let imagesMock: SinonStub;
|
|
|
|
before(async () => {
|
|
serviceManagerMock = stub(serviceManager, 'getAll').resolves([]);
|
|
imagesMock = stub(images, 'getStatus').resolves([]);
|
|
});
|
|
|
|
after(async () => {
|
|
serviceManagerMock.restore();
|
|
imagesMock.restore();
|
|
});
|
|
|
|
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([mockService({ appId: 1658654 })]);
|
|
imagesMock.resolves([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([mockService({ appId: 1658654 })]);
|
|
imagesMock.resolves([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([
|
|
mockService({ appId: 1658654 }),
|
|
mockService({ appId: 222222 }),
|
|
]);
|
|
imagesMock.resolves([
|
|
mockImage({ appId: 1658654 }),
|
|
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([
|
|
mockService({ appId: 1658654 }),
|
|
mockService({ appId: 222222 }),
|
|
]);
|
|
imagesMock.resolves([
|
|
mockImage({ appId: 1658654 }),
|
|
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 });
|
|
});
|
|
});
|
|
|
|
// TODO: add tests for rest of V2 endpoints
|
|
});
|
|
|
|
describe('State change logging', () => {
|
|
before(() => {
|
|
// Spy on functions we will be testing
|
|
spy(Log, 'info');
|
|
spy(Log, 'error');
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Start each case with API stopped
|
|
try {
|
|
await api.stop();
|
|
} catch (e) {
|
|
if (e.message !== 'Server is not running.') {
|
|
throw e;
|
|
}
|
|
}
|
|
});
|
|
|
|
after(async () => {
|
|
// @ts-ignore
|
|
Log.info.restore();
|
|
// @ts-ignore
|
|
Log.error.restore();
|
|
// Resume API for other test suites
|
|
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
|
});
|
|
|
|
it('logs successful start', async () => {
|
|
// Start API
|
|
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
|
// Check if success start was logged
|
|
// @ts-ignore
|
|
expect(Log.info.lastCall?.lastArg).to.equal(
|
|
`Supervisor API successfully started on port ${mockedOptions.listenPort}`,
|
|
);
|
|
});
|
|
|
|
it('logs shutdown', async () => {
|
|
// Start API
|
|
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
|
// Stop API
|
|
await api.stop();
|
|
// Check if stopped with info was logged
|
|
// @ts-ignore
|
|
expect(Log.info.lastCall?.lastArg).to.equal('Stopped Supervisor API');
|
|
});
|
|
|
|
it('logs errored shutdown', async () => {
|
|
// Start API
|
|
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
|
// Stop API with error
|
|
await api.stop({ errored: true });
|
|
// Check if stopped with error was logged
|
|
// @ts-ignore
|
|
expect(Log.error.lastCall?.lastArg).to.equal('Stopped Supervisor API');
|
|
});
|
|
});
|
|
});
|