Create & unify src/device-state/current-state tests

Signed-off-by: Christina Wang <christina@balena.io>
This commit is contained in:
Christina Wang 2021-05-05 22:12:51 +09:00
parent 39601473c0
commit ea3e50e96e
No known key found for this signature in database
GPG Key ID: 7C3ED0230F440835
5 changed files with 509 additions and 81 deletions

View File

@ -1,17 +1,19 @@
import Bluebird = require('bluebird');
import constants = require('../lib/constants');
import log from '../lib/supervisor-console';
import * as _ from 'lodash';
import { InternalInconsistencyError } from '../lib/errors';
import { DeviceStatus } from '../types/state';
import * as url from 'url';
import { CoreOptions } from 'request';
import * as constants from '../lib/constants';
import { log } from '../lib/supervisor-console';
import { InternalInconsistencyError, StatusError } from '../lib/errors';
import { getRequestInstance } from '../lib/request';
import * as sysInfo from '../lib/system-info';
import { DeviceStatus } from '../types/state';
import * as config from '../config';
import { SchemaTypeKey, SchemaReturn } from '../config/schema-type';
import * as eventTracker from '../event-tracker';
import * as deviceState from '../device-state';
import { CoreOptions } from 'request';
import * as url from 'url';
import * as sysInfo from '../lib/system-info';
// The exponential backoff starts at 15s
const MINIMUM_BACKOFF_DELAY = 15000;
@ -33,21 +35,23 @@ const stateForReport: DeviceStatus = {
};
let reportPending = false;
class StatusError extends Error {
constructor(public statusCode: number, public statusMessage?: string) {
super(statusMessage);
}
}
type CurrentStateReportConf = {
[key in keyof Pick<
config.ConfigMap<SchemaTypeKey>,
| 'uuid'
| 'apiEndpoint'
| 'apiTimeout'
| 'deviceApiKey'
| 'deviceId'
| 'localMode'
>]: SchemaReturn<key>;
};
/**
* Returns an object that contains only status fields relevant for the local mode.
* It basically removes information about applications state.
*
* Exported for tests
*/
export const stripDeviceStateInLocalMode = (
state: DeviceStatus,
): DeviceStatus => {
const stripDeviceStateInLocalMode = (state: DeviceStatus): DeviceStatus => {
return {
local: _.cloneDeep(
_.omit(state.local, 'apps', 'is_on__commit', 'logs_channel'),
@ -57,10 +61,11 @@ export const stripDeviceStateInLocalMode = (
const sendReportPatch = async (
stateDiff: DeviceStatus,
conf: { apiEndpoint: string; uuid: string; localMode: boolean },
conf: Omit<CurrentStateReportConf, 'deviceId'>,
) => {
let body = stateDiff;
if (conf.localMode) {
const { apiEndpoint, apiTimeout, deviceApiKey, localMode, uuid } = conf;
if (localMode) {
body = stripDeviceStateInLocalMode(stateDiff);
// In local mode, check if it still makes sense to send any updates after data strip.
if (_.isEmpty(body.local)) {
@ -68,12 +73,6 @@ const sendReportPatch = async (
return;
}
}
const { uuid, apiEndpoint, apiTimeout, deviceApiKey } = await config.getMany([
'uuid',
'apiEndpoint',
'apiTimeout',
'deviceApiKey',
]);
const endpoint = url.resolve(apiEndpoint, `/device/v2/${uuid}/state`);
const request = await getRequestInstance();
@ -101,7 +100,7 @@ const getStateDiff = (): DeviceStatus => {
const lastReportedDependent = lastReportedState.dependent;
if (lastReportedLocal == null || lastReportedDependent == null) {
throw new InternalInconsistencyError(
`No local or dependent component of lastReportedLocal in ApiBinder.getStateDiff: ${JSON.stringify(
`No local or dependent component of lastReportedLocal in CurrentState.getStateDiff: ${JSON.stringify(
lastReportedState,
)}`,
);
@ -132,7 +131,7 @@ const getStateDiff = (): DeviceStatus => {
const report = _.throttle(async () => {
const conf = await config.getMany([
'deviceId',
'deviceApiKey',
'apiTimeout',
'apiEndpoint',
'uuid',
@ -144,15 +143,14 @@ const report = _.throttle(async () => {
return 0;
}
const { apiEndpoint, uuid, localMode } = conf;
if (uuid == null || apiEndpoint == null) {
if (conf.uuid == null || conf.apiEndpoint == null) {
throw new InternalInconsistencyError(
'No uuid or apiEndpoint provided to ApiBinder.report',
'No uuid or apiEndpoint provided to CurrentState.report',
);
}
try {
await sendReportPatch(stateDiff, { apiEndpoint, uuid, localMode });
await sendReportPatch(stateDiff, conf);
stateReportErrors = 0;
_.assign(lastReportedState.local, stateDiff.local);

View File

@ -9,6 +9,12 @@ export interface StatusCodeError extends Error {
statusCode?: string | number;
}
export class StatusError extends Error {
constructor(public statusCode: number, public statusMessage?: string) {
super(statusMessage);
}
}
interface CodedSysError extends Error {
code?: string;
}

View File

@ -298,8 +298,6 @@ describe('deviceState', () => {
deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any);
});
it('returns the current state');
it.skip('writes the target state to the db with some extra defaults', async () => {
const testTarget = _.cloneDeep(testTargetWithDefaults2);

View File

@ -12,8 +12,6 @@ import balenaAPI = require('./lib/mocked-balena-api');
import { schema } from '../src/config/schema';
import ConfigJsonConfigBackend from '../src/config/configJson';
import * as TargetState from '../src/device-state/target-state';
import { DeviceStatus } from '../src/types/state';
import * as CurrentState from '../src/device-state/current-state';
import * as ApiHelper from '../src/lib/api-helper';
import supervisorVersion = require('../src/lib/supervisor-version');
import * as eventTracker from '../src/event-tracker';
@ -319,51 +317,6 @@ describe('ApiBinder', () => {
});
});
describe('local mode', () => {
const components: Dictionary<any> = {};
before(() => {
return initModels(components, '/config-apibinder.json');
});
after(async () => {
// @ts-expect-error setting read-only property
config.configJsonBackend = defaultConfigBackend;
await config.generateRequiredFields();
});
const sampleState = {
local: {
ip_address: '192.168.1.42 192.168.1.99',
api_port: 48484,
api_secret:
'20ffbd6e15aba827dca6381912d6aeb6c3a7a7c7206d4dfadf0d2f0a9e1136',
os_version: 'balenaOS 2.32.0+rev4',
os_variant: 'dev',
supervisor_version: '9.16.3',
provisioning_progress: null,
provisioning_state: '',
status: 'Idle',
logs_channel: null,
apps: {},
is_on__commit: 'whatever',
},
dependent: { apps: {} },
} as DeviceStatus;
it('should strip applications data', () => {
const result = CurrentState.stripDeviceStateInLocalMode(
sampleState,
) as Dictionary<any>;
expect(result).to.not.have.property('dependent');
const local = result['local'];
expect(local).to.not.have.property('apps');
expect(local).to.not.have.property('is_on__commit');
expect(local).to.not.have.property('logs_channel');
});
});
describe('healthchecks', () => {
const components: Dictionary<any> = {};
let configStub: SinonStub;

View File

@ -0,0 +1,473 @@
import { expect } from 'chai';
import { stub, SinonStub, spy, SinonSpy } from 'sinon';
import rewire = require('rewire');
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import * as config from '../../../src/config';
import * as deviceState from '../../../src/device-state';
import { stateReportErrors } from '../../../src/device-state/current-state';
import * as request from '../../../src/lib/request';
import log from '../../../src/lib/supervisor-console';
import {
InternalInconsistencyError,
StatusError,
} from '../../../src/lib/errors';
import * as sysInfo from '../../../src/lib/system-info';
describe('device-state/current-state', () => {
// Set up rewire to track private methods and variables
const currentState = rewire('../../../src/device-state/current-state');
const stripDeviceStateInLocalMode = currentState.__get__(
'stripDeviceStateInLocalMode',
);
const sendReportPatch = currentState.__get__('sendReportPatch');
const getStateDiff = currentState.__get__('getStateDiff');
const lastReportedState = currentState.__get__('lastReportedState');
const stateForReport = currentState.__get__('stateForReport');
const resetGlobalStateObjects = () => {
lastReportedState.local = {};
lastReportedState.dependent = {};
stateForReport.local = {};
stateForReport.dependent = {};
};
// Global spies & stubs
let stubbedGetReqInstance: SinonStub;
// Mock the patchAsync method from src/lib/request.ts
let patchAsyncSpy: SinonSpy;
const requestInstance = {
patchAsync: () => Bluebird.resolve([{ statusCode: 200 }]),
};
// Suite-level hooks
before(() => {
stubbedGetReqInstance = stub(request, 'getRequestInstance');
stubbedGetReqInstance.resolves(requestInstance);
});
after(() => {
stubbedGetReqInstance.restore();
});
beforeEach(() => {
resetGlobalStateObjects();
patchAsyncSpy = spy(requestInstance, 'patchAsync');
});
afterEach(() => {
patchAsyncSpy.restore();
});
// Test fixtures
const testDeviceConf = {
uuid: 'testuuid',
apiEndpoint: 'http://127.0.0.1',
apiTimeout: 100,
deviceApiKey: 'testapikey',
deviceId: 1337,
localMode: false,
disableHardwareMetrics: true,
};
const testDeviceReportFields = {
api_port: 48484,
api_secret:
'20ffbd6e15aba827dca6381912d6aeb6c3a7a7c7206d4dfadf0d2f0a9e1136',
ip_address: '192.168.1.42 192.168.1.99',
os_version: 'balenaOS 2.32.0+rev4',
os_variant: 'dev',
supervisor_version: '9.16.3',
provisioning_progress: null,
provisioning_state: '',
status: 'Idle',
update_failed: false,
update_pending: false,
update_downloaded: false,
is_on__commit: 'whatever',
logs_channel: null,
mac_addresss: '1C:69:7A:6E:B2:FE D8:3B:BF:51:F1:E4',
};
const testStateConfig = {
ENV_VAR_1: '1',
ENV_VAR_2: '1',
};
const testStateApps = {
'123': {
services: {
'456': {
status: 'Downloaded',
releaseId: '12345',
download_progress: null,
},
'789': {
status: 'Downloaded',
releaseId: '12346',
download_progress: null,
},
},
},
};
const testStateApps2 = {
'321': {
services: {
'654': {
status: 'Downloaded',
releaseId: '12347',
download_progress: null,
},
},
},
};
const testCurrentState = {
local: {
...testDeviceReportFields,
config: testStateConfig,
apps: testStateApps,
},
dependent: { apps: testStateApps2 },
commit: 'whatever',
};
describe('stripDeviceStateInLocalMode', () => {
it('should strip applications data', () => {
const result = stripDeviceStateInLocalMode(testCurrentState);
expect(result).to.not.have.property('dependent');
const local = result['local'];
expect(local).to.not.have.property('apps');
expect(local).to.not.have.property('is_on__commit');
expect(local).to.not.have.property('logs_channel');
});
it('should not mutate the input state', () => {
const result = stripDeviceStateInLocalMode(testCurrentState);
expect(result).to.not.deep.equal(testCurrentState);
});
});
describe('sendReportPatch', () => {
it('should only strip app state in local mode', async () => {
// Strip state in local mode
await sendReportPatch(testCurrentState, {
...testDeviceConf,
localMode: true,
});
// Request body's stripped state should be different than input state
expect(patchAsyncSpy.lastCall.lastArg.body).to.not.deep.equal(
testCurrentState,
);
// Don't strip state out of local mode
await sendReportPatch(testCurrentState, testDeviceConf);
expect(patchAsyncSpy.lastCall.lastArg.body).to.deep.equal(
testCurrentState,
);
});
it('should patch state with empty local objects depending on local mode config', async () => {
// Don't patch state with empty state.local in local mode
await sendReportPatch(
{ ...testCurrentState, local: {} },
{ ...testDeviceConf, localMode: true },
);
expect(patchAsyncSpy).to.not.have.been.called;
// Patch state with empty state.local out of local mode
await sendReportPatch({ ...testCurrentState, local: {} }, testDeviceConf);
expect(patchAsyncSpy).to.have.been.called;
});
it('should patch with specified endpoint and params', async () => {
await sendReportPatch(testCurrentState, testDeviceConf);
const [endpoint, params] = patchAsyncSpy.lastCall.args;
expect(endpoint).to.equal(
`${testDeviceConf.apiEndpoint}/device/v2/${testDeviceConf.uuid}/state`,
);
expect(params).to.deep.equal({
json: true,
headers: { Authorization: `Bearer ${testDeviceConf.deviceApiKey}` },
body: testCurrentState,
});
});
it('should timeout patch request after apiTimeout milliseconds', async () => {
// Overwrite mock patchAsync to delay past apiTimeout ms
patchAsyncSpy.restore();
requestInstance.patchAsync = () =>
Bluebird.delay(
testDeviceConf.apiTimeout + 100,
Bluebird.resolve([{ statusCode: 200 }]),
);
patchAsyncSpy = spy(requestInstance, 'patchAsync');
await expect(
sendReportPatch(testCurrentState, testDeviceConf),
).to.be.rejectedWith(Bluebird.TimeoutError);
// Reset to default patchAsync
requestInstance.patchAsync = () =>
Bluebird.resolve([{ statusCode: 200 }]);
});
it('should communicate string error messages from the API', async () => {
// Overwrite mock patchAsync to reject patch request
patchAsyncSpy.restore();
requestInstance.patchAsync = () =>
Bluebird.resolve([{ statusCode: 400, body: 'string error message' }]);
patchAsyncSpy = spy(requestInstance, 'patchAsync');
stub(log, 'error');
await expect(
sendReportPatch(testCurrentState, testDeviceConf),
).to.be.rejected.then((err) => {
expect(err).to.be.instanceOf(StatusError);
expect(err).to.have.property('statusMessage', '"string error message"');
});
expect(log.error as SinonStub).to.have.been.calledWith(
`Error from the API: ${400}`,
);
// Reset to default patchAsync
requestInstance.patchAsync = () =>
Bluebird.resolve([{ statusCode: 200 }]);
(log.error as SinonStub).restore();
});
it('should communicate multiline object error messages from the API', async () => {
const objectErrorMessage = {
testKey: 'testErrorVal',
testChild: { testNestedKey: 'testNestedVal' },
};
// Overwrite mock patchAsync to reject patch request
patchAsyncSpy.restore();
requestInstance.patchAsync = () =>
Bluebird.resolve([
{
statusCode: 400,
body: objectErrorMessage,
},
]);
patchAsyncSpy = spy(requestInstance, 'patchAsync');
stub(log, 'error');
await expect(
sendReportPatch(testCurrentState, testDeviceConf),
).to.be.rejected.then((err) => {
expect(err).to.be.instanceOf(StatusError);
expect(err).to.have.property(
'statusMessage',
JSON.stringify(objectErrorMessage, null, 2),
);
});
expect(log.error as SinonStub).to.have.been.calledWith(
`Error from the API: ${400}`,
);
// Reset to default patchAsync
requestInstance.patchAsync = () =>
Bluebird.resolve([{ statusCode: 200 }]);
(log.error as SinonStub).restore();
});
});
describe('getStateDiff', () => {
it('should error if last reported state is missing local or dependent properties', () => {
lastReportedState.local = null;
lastReportedState.dependent = null;
expect(() => getStateDiff()).to.throw(InternalInconsistencyError);
});
it('should not modify global stateForReport or lastReportedState after call', async () => {
lastReportedState.local = {
status: 'Downloading',
config: testStateConfig,
};
stateForReport.local = {
status: 'Idle',
config: { ...testStateConfig, ENV_VAR_3: '1' },
};
getStateDiff();
expect(lastReportedState.local).to.deep.equal({
status: 'Downloading',
config: {
ENV_VAR_1: '1',
ENV_VAR_2: '1',
},
});
expect(stateForReport.local).to.deep.equal({
status: 'Idle',
config: {
ENV_VAR_1: '1',
ENV_VAR_2: '1',
ENV_VAR_3: '1',
},
});
});
it('should return any changed fields', async () => {
// No diffs when lastReportedState and stateForReport are the same
expect(getStateDiff()).to.deep.equal({});
// Changed config fields
lastReportedState.local = {
config: { ENV_VAR_3: '1' },
};
stateForReport.local = {
config: { ENV_VAR_3: '0' },
};
expect(getStateDiff()).to.deep.equal({
local: { config: { ENV_VAR_3: '0' } },
});
resetGlobalStateObjects();
// Changed apps fields
lastReportedState.local = { apps: testStateApps };
stateForReport.local = { apps: testStateApps2 };
expect(getStateDiff()).to.deep.equal({ local: { apps: testStateApps2 } });
resetGlobalStateObjects();
// Changed dependent fields
lastReportedState.dependent = { apps: testStateApps2 };
stateForReport.dependent = { apps: testStateApps };
expect(getStateDiff()).to.deep.equal({
dependent: { apps: testStateApps },
});
resetGlobalStateObjects();
// Changed sys info fields
lastReportedState.local = { cpu_temp: 10 };
stateForReport.local = { cpu_temp: 16 };
expect(getStateDiff()).to.deep.equal({ local: { cpu_temp: 16 } });
resetGlobalStateObjects();
});
it('should omit internal state keys and report DeviceReportField (type) info', async () => {
// INTERNAL_STATE_KEYS are: ['update_pending', 'update_downloaded', 'update_failed']
stateForReport.local = _.pick(testDeviceReportFields, [
'update_pending',
'update_downloaded',
'update_failed',
'os_version',
]);
stateForReport.dependent = _.pick(testDeviceReportFields, [
'update_pending',
'update_downloaded',
'update_failed',
'status',
]);
expect(getStateDiff()).to.deep.equal({
local: _.pick(testDeviceReportFields, ['os_version']),
dependent: _.pick(testDeviceReportFields, ['status']),
});
});
});
describe('throttled report', () => {
const report = currentState.__get__('report');
let configGetManyStub: SinonStub;
before(() => {
configGetManyStub = stub(config, 'getMany');
requestInstance.patchAsync = () =>
Bluebird.resolve([{ statusCode: 200 }]);
});
after(() => {
configGetManyStub.restore();
});
beforeEach(() => {
configGetManyStub.resolves(
_.omit(testDeviceConf, ['deviceId', 'disableHardwareMetrics']) as any,
);
});
afterEach(() => {
// Clear the throttle time limit between tests
report.cancel();
});
it("doesn't report if current state hasn't changed", async () => {
// A beforeEach hook resets the global state objects to all be empty, so
// by default, report() will return 0 in this test env
expect(await report()).to.equal(0);
});
it('errors when provided invalid uuid or apiEndpoint', async () => {
configGetManyStub.resolves(
_.omit(testDeviceConf, [
'deviceId',
'disableHardwareMetrics',
'uuid',
'apiEndpoint',
]) as any,
);
stateForReport.local = { ...testDeviceReportFields };
await expect(report()).to.be.rejectedWith(InternalInconsistencyError);
});
it('resets stateReportErrors to 0 on patch success', async () => {
spy(_, 'assign');
stateForReport.local = { ...testDeviceReportFields };
await report();
expect(stateReportErrors).to.equal(0);
expect(_.assign as SinonSpy).to.have.been.calledTwice;
(_.assign as SinonSpy).restore();
});
it('handles errors on state patch failure', async () => {
// Overwrite default patchAsync response to return erroring statusCode
patchAsyncSpy.restore();
requestInstance.patchAsync = () =>
Bluebird.resolve([{ statusCode: 400 }]);
patchAsyncSpy = spy(requestInstance, 'patchAsync');
stub(log, 'error');
stateForReport.local = { ...testDeviceReportFields };
await report();
expect((log.error as SinonStub).lastCall.args[0]).to.equal(
'Non-200 response from the API! Status code: 400 - message:',
);
(log.error as SinonStub).restore();
// Reset to default patchAsync
requestInstance.patchAsync = () =>
Bluebird.resolve([{ statusCode: 200 }]);
});
});
describe.skip('reportCurrentState', () => {
const reportPending = currentState.__get__('reportPending');
before(() => {
stub(deviceState, 'getStatus').resolves(testCurrentState as any);
stub(config, 'get').resolves(true);
});
after(() => {
(deviceState.getStatus as SinonStub).restore();
(config.get as SinonStub).restore();
});
it('does not report if current state has not changed');
});
});