From 733a2c5dc068f01e803505822e03d09c59a2a3b8 Mon Sep 17 00:00:00 2001 From: Miguel Casqueira Date: Wed, 18 Nov 2020 13:24:18 -0500 Subject: [PATCH] Consolidated Supervisor API tests into clearer files Change-type: patch Signed-off-by: Miguel Casqueira --- test/21-supervisor-api.spec.ts | 478 +++++----------------------- test/26-supervisor-api-auth.spec.ts | 95 ------ test/41-device-api-v1.spec.ts | 162 ++++++++++ test/42-device-api-v2.spec.ts | 299 +++++++++++++++++ 4 files changed, 540 insertions(+), 494 deletions(-) delete mode 100644 test/26-supervisor-api-auth.spec.ts create mode 100644 test/41-device-api-v1.spec.ts create mode 100644 test/42-device-api-v2.spec.ts diff --git a/test/21-supervisor-api.spec.ts b/test/21-supervisor-api.spec.ts index 401cca74..b5f0d085 100644 --- a/test/21-supervisor-api.spec.ts +++ b/test/21-supervisor-api.spec.ts @@ -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) => { - 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) => { - 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); + }); + }); }); diff --git a/test/26-supervisor-api-auth.spec.ts b/test/26-supervisor-api-auth.spec.ts deleted file mode 100644 index e6ad28cf..00000000 --- a/test/26-supervisor-api-auth.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/test/41-device-api-v1.spec.ts b/test/41-device-api-v1.spec.ts new file mode 100644 index 00000000..5ce7c1fe --- /dev/null +++ b/test/41-device-api-v1.spec.ts @@ -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 +}); diff --git a/test/42-device-api-v2.spec.ts b/test/42-device-api-v2.spec.ts new file mode 100644 index 00000000..58f797e2 --- /dev/null +++ b/test/42-device-api-v2.spec.ts @@ -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) => { + 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) => { + 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 +});