balena-supervisor/test/21-supervisor-api.spec.ts
Rich Bayliss 591598e102
fix: Scoped keys not working in LocalMode
Some endpoints filter data based on the scope of the API key
used to make the request. When in LocalMode the check was not
being made correctly and all apps were considered out of scope.

Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
2020-11-11 10:58:58 +00:00

483 lines
14 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 apiKeys from '../src/lib/api-keys';
import * as db from '../src/db';
import * as config from '../src/config';
import { Service } from '../src/compose/service';
const mockedOptions = {
listenPort: 54321,
timeout: 30000,
};
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', () => {
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', () => {
let serviceManagerMock: SinonStub;
const mockService = (
appId: number,
serviceId: number,
serviceName: string,
) => {
return {
appId,
status: 'Running',
serviceName,
imageId: appId,
serviceId,
containerId: Math.random()
.toString(36)
.replace(/[^a-z]+/g, '')
.substr(0, 16),
createdAt: new Date(),
} as Service;
};
before(async () => {
await config.set({ localMode: true });
serviceManagerMock = stub(serviceManager, 'getAll').resolves([]);
});
after(async () => {
await config.set({ localMode: false });
serviceManagerMock.restore();
});
it('should succeed in LocalMode with a single application', async () => {
serviceManagerMock.resolves([mockService(1, 1, 'main')]);
const { body } = await request
.get('/v2/state/status')
.set('Accept', 'application/json')
.expect(200);
expect(body).to.have.property('status').which.equals('success');
expect(body).to.have.property('appState').which.equals('applied');
expect(body)
.to.have.property('containers')
.which.is.an('array')
.with.lengthOf(1);
});
it('should error in LocalMode with multiple applications', async () => {
serviceManagerMock.resolves([
mockService(1, 1, 'main'),
mockService(2, 2, 'extra'),
]);
await request
.get('/v2/state/status')
.set('Accept', 'application/json')
.expect(405);
});
});
// 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');
});
});
});