diff --git a/test/41-device-api-v1.spec.ts b/test/41-device-api-v1.spec.ts index 5ce7c1fe..ea8dfdcf 100644 --- a/test/41-device-api-v1.spec.ts +++ b/test/41-device-api-v1.spec.ts @@ -1,25 +1,23 @@ +import * as _ from 'lodash'; import { expect } from 'chai'; import { stub, SinonStub } from 'sinon'; import * as supertest from 'supertest'; -import sampleResponses = require('./data/device-api-responses.json'); +import * as appMock from './lib/application-state-mock'; +import * as mockedDockerode from './lib/mocked-dockerode'; import mockedAPI = require('./lib/mocked-device-api'); +import sampleResponses = require('./data/device-api-responses.json'); +import SupervisorAPI from '../src/supervisor-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}`); + const request = supertest( + `http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`, + ); before(async () => { await apiBinder.initialized; @@ -36,30 +34,14 @@ describe('SupervisorAPI [V1 Endpoints]', () => { api = await mockedAPI.create(); // Start test API - await api.listen(mockedOptions.listenPort, mockedOptions.timeout); + await api.listen( + mockedAPI.mockedOptions.listenPort, + mockedAPI.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 () => { @@ -74,8 +56,89 @@ describe('SupervisorAPI [V1 Endpoints]', () => { healthCheckStubs.forEach((hc) => hc.restore); // Remove any test data generated await mockedAPI.cleanUp(); - (applicationManager.executeStep as SinonStub).restore(); - (applicationManager.getCurrentApps as SinonStub).restore(); + }); + + beforeEach(() => { + // Sane defaults + appMock.mockSupervisorNetwork(true); + appMock.mockManagers([], [], []); + appMock.mockImages([], false, []); + }); + + afterEach(() => { + appMock.unmockAll(); + // Clear Dockerode actions recorded for each test + mockedDockerode.resetHistory(); + }); + + describe('POST /v1/restart', () => { + it('restarts application', async () => { + const ID_TO_RESTART = 2; + // single app scoped key... + const appScopedKey = await apiKeys.generateScopedKey( + ID_TO_RESTART, + 640681, + ); + const service = mockedAPI.mockService({ + appId: ID_TO_RESTART, + serviceId: 640681, + }); + const image = mockedAPI.mockImage({ + appId: ID_TO_RESTART, + serviceId: 640681, + }); + const images = [image]; + const containers = [service]; + // Setup device conditions + appMock.mockManagers([service], [], []); + appMock.mockImages([], false, images); + // Perform the test with our specially crafted data + await mockedDockerode.testWithData({ containers, images }, async () => { + // Perform test + await request + .post('/v1/restart') + .send({ appId: ID_TO_RESTART }) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${appScopedKey}`) + .expect(sampleResponses.V1.POST['/restart'].statusCode) + .then((response) => { + expect(response.body).to.deep.equal( + sampleResponses.V1.POST['/restart'].body, + ); + expect(response.text).to.deep.equal( + sampleResponses.V1.POST['/restart'].text, + ); + }); + // Check that mockedDockerode contains 1 stop and start action + const removeSteps = _(mockedDockerode.actions) + .pickBy({ name: 'stop' }) + .map() + .value(); + expect(removeSteps).to.have.lengthOf(1); + const startSteps = _(mockedDockerode.actions) + .pickBy({ name: 'start' }) + .map() + .value(); + expect(startSteps).to.have.lengthOf(1); + }); + }); + + it('validates request body parameters', async () => { + await request + .post('/v1/restart') + .send({ thing: '' }) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(sampleResponses.V1.POST['/restart [Invalid Body]'].statusCode) + .then((response) => { + expect(response.body).to.deep.equal( + sampleResponses.V1.POST['/restart [Invalid Body]'].body, + ); + expect(response.text).to.deep.equal( + sampleResponses.V1.POST['/restart [Invalid Body]'].text, + ); + }); + }); }); describe('GET /v1/healthy', () => { diff --git a/test/42-device-api-v2.spec.ts b/test/42-device-api-v2.spec.ts index 58f797e2..a155366d 100644 --- a/test/42-device-api-v2.spec.ts +++ b/test/42-device-api-v2.spec.ts @@ -11,52 +11,14 @@ 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}`); + const request = supertest( + `http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`, + ); before(async () => { await apiBinder.initialized; @@ -67,7 +29,10 @@ describe('SupervisorAPI [V2 Endpoints]', () => { api = await mockedAPI.create(); // Start test API - await api.listen(mockedOptions.listenPort, mockedOptions.timeout); + await api.listen( + mockedAPI.mockedOptions.listenPort, + mockedAPI.mockedOptions.timeout, + ); // Create a scoped key await apiKeys.initialized; @@ -167,8 +132,8 @@ describe('SupervisorAPI [V2 Endpoints]', () => { // 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 })]); + serviceManagerMock.resolves([mockedAPI.mockService({ appId: 1658654 })]); + imagesMock.resolves([mockedAPI.mockImage({ appId: 1658654 })]); // Make request and evaluate response await request .get('/v2/state/status') @@ -191,8 +156,8 @@ describe('SupervisorAPI [V2 Endpoints]', () => { // 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 })]); + serviceManagerMock.resolves([mockedAPI.mockService({ appId: 1658654 })]); + imagesMock.resolves([mockedAPI.mockImage({ appId: 1658654 })]); // Make request and evaluate response await request .get('/v2/state/status') @@ -238,12 +203,12 @@ describe('SupervisorAPI [V2 Endpoints]', () => { const appScopedKey = await apiKeys.generateScopedKey(1658654, 640681); // Setup device conditions serviceManagerMock.resolves([ - mockService({ appId: 1658654 }), - mockService({ appId: 222222 }), + mockedAPI.mockService({ appId: 1658654 }), + mockedAPI.mockService({ appId: 222222 }), ]); imagesMock.resolves([ - mockImage({ appId: 1658654 }), - mockImage({ appId: 222222 }), + mockedAPI.mockImage({ appId: 1658654 }), + mockedAPI.mockImage({ appId: 222222 }), ]); // Make request and evaluate response await request @@ -268,12 +233,12 @@ describe('SupervisorAPI [V2 Endpoints]', () => { await config.set({ localMode: true }); // Setup device conditions serviceManagerMock.resolves([ - mockService({ appId: 1658654 }), - mockService({ appId: 222222 }), + mockedAPI.mockService({ appId: 1658654 }), + mockedAPI.mockService({ appId: 222222 }), ]); imagesMock.resolves([ - mockImage({ appId: 1658654 }), - mockImage({ appId: 222222 }), + mockedAPI.mockImage({ appId: 1658654 }), + mockedAPI.mockImage({ appId: 222222 }), ]); // Make request and evaluate response await request diff --git a/test/data/device-api-responses.json b/test/data/device-api-responses.json index d5a2243c..815d8ced 100644 --- a/test/data/device-api-responses.json +++ b/test/data/device-api-responses.json @@ -27,6 +27,18 @@ "containerId": "abc123" } } + }, + "POST": { + "/restart": { + "statusCode": 200, + "body": {}, + "text": "OK" + }, + "/restart [Invalid Body]": { + "statusCode": 400, + "body": {}, + "text": "Missing app id" + } } }, "V2": { diff --git a/test/data/etc/os-release b/test/data/etc/os-release index da088a86..c5073903 100644 --- a/test/data/etc/os-release +++ b/test/data/etc/os-release @@ -1,3 +1,12 @@ -PRETTY_NAME="balenaOS 2.0.6 (fake)" -VARIANT_ID="dev" +ID="balena-os" +NAME="balenaOS" VERSION="2.0.6" +VERSION_ID="2.0.6+rev1" +PRETTY_NAME="balenaOS 2.0.6+rev1" +MACHINE="raspberrypi4-64" +VARIANT="Development" +VARIANT_ID="dev" +META_BALENA_VERSION="2.0.6" +RESIN_BOARD_REV="b57b01a" +META_RESIN_REV="ef55525" +SLUG="raspberrypi4-64" \ No newline at end of file diff --git a/test/data/mnt/boot/device-type.json b/test/data/mnt/boot/device-type.json new file mode 100644 index 00000000..5b969bd8 --- /dev/null +++ b/test/data/mnt/boot/device-type.json @@ -0,0 +1,102 @@ +{ + "slug": "raspberrypi4-64", + "version": 1, + "aliases": [ + "raspberrypi4-64" + ], + "name": "Raspberry Pi 4", + "arch": "aarch64", + "state": "RELEASED", + "private": false, + "instructions": [ + "Write the OS file you downloaded to your SD card. We recommend using Etcher.", + "Insert the freshly burnt SD card into the Raspberry Pi 4.", + "Connect your Raspberry Pi 4 to the internet, then power it up." + ], + "gettingStartedLink": { + "windows": "https://www.balena.io/docs/learn/getting-started/raspberrypi4/nodejs/", + "osx": "https://www.balena.io/docs/learn/getting-started/raspberrypi4/nodejs/", + "linux": "https://www.balena.io/docs/learn/getting-started/raspberrypi4/nodejs/" + }, + "supportsBlink": true, + "options": [ + { + "isGroup": true, + "name": "network", + "message": "Network", + "options": [ + { + "message": "Network Connection", + "name": "network", + "type": "list", + "choices": [ + "ethernet", + "wifi" + ] + }, + { + "message": "Wifi SSID", + "name": "wifiSsid", + "type": "text", + "when": { + "network": "wifi" + } + }, + { + "message": "Wifi Passphrase", + "name": "wifiKey", + "type": "password", + "when": { + "network": "wifi" + } + } + ] + }, + { + "isGroup": true, + "isCollapsible": true, + "collapsed": true, + "name": "advanced", + "message": "Advanced", + "options": [ + { + "message": "Check for updates every X minutes", + "name": "appUpdatePollInterval", + "type": "number", + "min": 10, + "default": 10 + } + ] + } + ], + "yocto": { + "machine": "raspberrypi4-64", + "image": "resin-image", + "fstype": "resinos-img", + "version": "yocto-warrior", + "deployArtifact": "resin-image-raspberrypi4-64.resinos-img", + "compressed": true + }, + "configuration": { + "config": { + "partition": { + "primary": 1 + }, + "path": "/config.json" + } + }, + "initialization": { + "options": [ + { + "message": "Select a drive", + "type": "drive", + "name": "drive" + } + ], + "operations": [ + { + "command": "burn" + } + ] + } +} \ No newline at end of file diff --git a/test/lib/application-state-mock.ts b/test/lib/application-state-mock.ts index 48ab202a..12c4b2db 100644 --- a/test/lib/application-state-mock.ts +++ b/test/lib/application-state-mock.ts @@ -22,6 +22,15 @@ export function mockManagers(svcs: Service[], vols: Volume[], nets: Network[]) { networkManager.getAll = async () => nets; // @ts-expect-error Assigning to a RO property serviceManager.getAll = async () => { + // Filter services that are being removed + svcs = svcs.filter((s) => s.status !== 'removing'); + // Update Installing containers to Running + svcs = svcs.map((s) => { + if (s.status === 'Installing') { + s.status = 'Running'; + } + return s; + }); console.log('Calling the mock', svcs); return svcs; }; @@ -43,7 +52,6 @@ export function mockImages( ) { // @ts-expect-error Assigning to a RO property imageManager.getDownloadingImageIds = () => { - console.log('CALLED'); return downloading; }; // @ts-expect-error Assigning to a RO property diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts index db675413..47cd6d4a 100644 --- a/test/lib/mocked-device-api.ts +++ b/test/lib/mocked-device-api.ts @@ -14,8 +14,11 @@ import { createV2Api } from '../../src/device-api/v2'; import * as apiBinder from '../../src/api-binder'; import * as deviceState from '../../src/device-state'; import SupervisorAPI from '../../src/supervisor-api'; +import { Service } from '../../src/compose/service'; +import { Image } from '../../src/compose/images'; const DB_PATH = './test/data/supervisor-api.sqlite'; + // Holds all values used for stubbing const STUBBED_VALUES = { commits: { @@ -54,6 +57,49 @@ const STUBBED_VALUES = { volumes: [], }; +// Useful for creating mock services in the ServiceManager +const mockService = (overrides?: Partial) => { + return { + ...{ + appId: 1658654, + status: 'Running', + serviceName: 'main', + imageId: 2885946, + serviceId: 640681, + containerId: + 'f93d386599d1b36e71272d46ad69770cff333842db04e2e4c64dda7b54da07c6', + createdAt: '2020-11-13T20:29:44.143Z', + config: { + labels: {}, + }, + }, + ...overrides, + } as Service; +}; + +// Useful for creating mock images that are returned from Images.getStatus +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; +}; + +const mockedOptions = { + listenPort: 54321, + timeout: 30000, +}; + /** * THIS MOCKED API CONTAINS STUBS THAT MIGHT CAUSE UNEXPECTED RESULTS * IF YOU WANT TO ADD/MODIFY STUBS THAT INVOLVE API OPERATIONS @@ -156,4 +202,11 @@ function restoreStubs() { serviceManager.getAllByAppId = originalSvcGetAppId; } -export = { create, cleanUp, STUBBED_VALUES }; +export = { + create, + cleanUp, + STUBBED_VALUES, + mockService, + mockImage, + mockedOptions, +}; diff --git a/test/lib/mocked-dockerode.ts b/test/lib/mocked-dockerode.ts index 50471510..aaae7999 100644 --- a/test/lib/mocked-dockerode.ts +++ b/test/lib/mocked-dockerode.ts @@ -75,10 +75,43 @@ export function registerOverride< export interface TestData { networks: Dictionary; images: Dictionary; + containers: Dictionary; } function createMockedDockerode(data: TestData) { const mockedDockerode = dockerode.prototype; + mockedDockerode.getContainer = (id: string) => { + addAction('getContainer', { id }); + return { + start: async () => { + addAction('start', {}); + data.containers = data.containers.map((c: any) => { + if (c.containerId === id) { + c.status = 'Installing'; + } + return c; + }); + }, + stop: async () => { + addAction('stop', {}); + data.containers = data.containers.map((c: any) => { + if (c.containerId === id) { + c.status = 'Stopping'; + } + return c; + }); + }, + remove: async () => { + addAction('remove', {}); + data.containers = data.containers.map((c: any) => { + if (c.containerId === id) { + c.status = 'removing'; + } + return c; + }); + }, + } as dockerode.Container; + }; mockedDockerode.getNetwork = (id: string) => { addAction('getNetwork', { id }); return {