mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-18 18:56:24 +00:00
Consolidated Supervisor API tests into clearer files
Change-type: patch Signed-off-by: Miguel Casqueira <miguel@balena.io>
This commit is contained in:
parent
f6ba69144b
commit
733a2c5dc0
@ -1,79 +1,29 @@
|
||||
import { expect } from 'chai';
|
||||
import { spy, stub, SinonStub } from 'sinon';
|
||||
import { spy } from 'sinon';
|
||||
import * as supertest from 'supertest';
|
||||
|
||||
import mockedAPI = require('./lib/mocked-device-api');
|
||||
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';
|
||||
import { cloudApiKey } from '../src/lib/api-keys';
|
||||
|
||||
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();
|
||||
@ -94,12 +44,23 @@ describe('SupervisorAPI', () => {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// Restore healthcheck stubs
|
||||
healthCheckStubs.forEach((hc) => hc.restore);
|
||||
// Remove any test data generated
|
||||
await mockedAPI.cleanUp();
|
||||
});
|
||||
|
||||
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('API Key Scope', () => {
|
||||
it('should generate a key which is scoped for a single application', async () => {
|
||||
// single app scoped key...
|
||||
@ -198,350 +159,6 @@ describe('SupervisorAPI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
@ -599,4 +216,67 @@ describe('SupervisorAPI', () => {
|
||||
expect(Log.error.lastCall?.lastArg).to.equal('Stopped Supervisor API');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication', () => {
|
||||
const INVALID_SECRET = 'bad_api_secret';
|
||||
|
||||
it('finds no apiKey and rejects', async () => {
|
||||
return request.post('/v1/blink').expect(401);
|
||||
});
|
||||
|
||||
it('finds apiKey from query', async () => {
|
||||
return request.post(`/v1/blink?apikey=${cloudApiKey}`).expect(200);
|
||||
});
|
||||
|
||||
it('finds apiKey from Authorization header (ApiKey scheme)', async () => {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `ApiKey ${cloudApiKey}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('finds apiKey from Authorization header (Bearer scheme)', async () => {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `Bearer ${cloudApiKey}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('finds apiKey from Authorization header (case insensitive)', async () => {
|
||||
const randomCases = [
|
||||
'Bearer',
|
||||
'bearer',
|
||||
'BEARER',
|
||||
'BeAReR',
|
||||
'ApiKey',
|
||||
'apikey',
|
||||
'APIKEY',
|
||||
'ApIKeY',
|
||||
];
|
||||
for (const scheme of randomCases) {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `${scheme} ${cloudApiKey}`)
|
||||
.expect(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid apiKey from query', async () => {
|
||||
return request.post(`/v1/blink?apikey=${INVALID_SECRET}`).expect(401);
|
||||
});
|
||||
|
||||
it('rejects invalid apiKey from Authorization header (ApiKey scheme)', async () => {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `ApiKey ${INVALID_SECRET}`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('rejects invalid apiKey from Authorization header (Bearer scheme)', async () => {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `Bearer ${INVALID_SECRET}`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,95 +0,0 @@
|
||||
import * as supertest from 'supertest';
|
||||
|
||||
import SupervisorAPI from '../src/supervisor-api';
|
||||
import mockedAPI = require('./lib/mocked-device-api');
|
||||
import { cloudApiKey } from '../src/lib/api-keys';
|
||||
|
||||
const mockedOptions = {
|
||||
listenPort: 12345,
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
const INVALID_SECRET = 'bad_api_secret';
|
||||
|
||||
describe('SupervisorAPI authentication', () => {
|
||||
let api: SupervisorAPI;
|
||||
const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`);
|
||||
|
||||
before(async () => {
|
||||
// Create test API
|
||||
api = await mockedAPI.create();
|
||||
// Start test API
|
||||
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('finds no apiKey and rejects', async () => {
|
||||
return request.post('/v1/blink').expect(401);
|
||||
});
|
||||
|
||||
it('finds apiKey from query', async () => {
|
||||
return request.post(`/v1/blink?apikey=${cloudApiKey}`).expect(200);
|
||||
});
|
||||
|
||||
it('finds apiKey from Authorization header (ApiKey scheme)', async () => {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `ApiKey ${cloudApiKey}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('finds apiKey from Authorization header (Bearer scheme)', async () => {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `Bearer ${cloudApiKey}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('finds apiKey from Authorization header (case insensitive)', async () => {
|
||||
const randomCases = [
|
||||
'Bearer',
|
||||
'bearer',
|
||||
'BEARER',
|
||||
'BeAReR',
|
||||
'ApiKey',
|
||||
'apikey',
|
||||
'APIKEY',
|
||||
'ApIKeY',
|
||||
];
|
||||
for (const scheme of randomCases) {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `${scheme} ${cloudApiKey}`)
|
||||
.expect(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid apiKey from query', async () => {
|
||||
return request.post(`/v1/blink?apikey=${INVALID_SECRET}`).expect(401);
|
||||
});
|
||||
|
||||
it('rejects invalid apiKey from Authorization header (ApiKey scheme)', async () => {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `ApiKey ${INVALID_SECRET}`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('rejects invalid apiKey from Authorization header (Bearer scheme)', async () => {
|
||||
return request
|
||||
.post('/v1/blink')
|
||||
.set('Authorization', `Bearer ${INVALID_SECRET}`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
162
test/41-device-api-v1.spec.ts
Normal file
162
test/41-device-api-v1.spec.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { expect } from 'chai';
|
||||
import { stub, SinonStub } from 'sinon';
|
||||
import * as supertest from 'supertest';
|
||||
|
||||
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 applicationManager from '../src/compose/application-manager';
|
||||
import { InstancedAppState } from '../src/types/state';
|
||||
import * as apiKeys from '../src/lib/api-keys';
|
||||
|
||||
const mockedOptions = {
|
||||
listenPort: 54321,
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
describe('SupervisorAPI [V1 Endpoints]', () => {
|
||||
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();
|
||||
|
||||
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(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();
|
||||
(applicationManager.executeStep as SinonStub).restore();
|
||||
(applicationManager.getCurrentApps as SinonStub).restore();
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: add tests for V1 endpoints
|
||||
});
|
299
test/42-device-api-v2.spec.ts
Normal file
299
test/42-device-api-v2.spec.ts
Normal file
@ -0,0 +1,299 @@
|
||||
import { expect } from 'chai';
|
||||
import { stub, SinonStub } from 'sinon';
|
||||
import * as supertest from 'supertest';
|
||||
|
||||
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 { 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 [V2 Endpoints]', () => {
|
||||
let serviceManagerMock: SinonStub;
|
||||
let imagesMock: SinonStub;
|
||||
let api: SupervisorAPI;
|
||||
const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`);
|
||||
|
||||
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(mockedOptions.listenPort, mockedOptions.timeout);
|
||||
|
||||
// Create a scoped key
|
||||
await apiKeys.initialized;
|
||||
await apiKeys.generateCloudKey();
|
||||
serviceManagerMock = stub(serviceManager, 'getAll').resolves([]);
|
||||
imagesMock = stub(images, 'getStatus').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();
|
||||
});
|
||||
|
||||
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
|
||||
});
|
Loading…
Reference in New Issue
Block a user