balena-supervisor/test/unit/lib/validation.spec.ts
Felipe Lalanne 67d1503b54 Allow using colon character in config vars
The Raspberry Pi config.txt file defines the use of colon to configure
variables of the same name in different ports, for instance on those
devices with two hdmi ports. This syntax was previously not supported by
the supervisor. This change relaxes the syntax validation on config vars
to allow the use of the colon character.

Relates-to: #1573, #2046
Change-type: minor
2023-01-20 15:48:32 -03:00

541 lines
16 KiB
TypeScript

import * as _ from 'lodash';
import { expect } from 'chai';
import { isRight } from 'fp-ts/lib/Either';
import {
StringIdentifier,
ShortString,
DeviceName,
NumericIdentifier,
TargetApps,
TargetState,
} from '~/src/types';
import * as validation from '~/lib/validation';
describe('validation', () => {
describe('checkBooleanish', () => {
it('returns true for a truthy or falsey value', () => {
expect(validation.checkBooleanish(true)).to.equal(true);
expect(validation.checkBooleanish('true')).to.equal(true);
expect(validation.checkBooleanish('1')).to.equal(true);
expect(validation.checkBooleanish(1)).to.equal(true);
expect(validation.checkBooleanish('on')).to.equal(true);
expect(validation.checkBooleanish(false)).to.equal(true);
expect(validation.checkBooleanish('false')).to.equal(true);
expect(validation.checkBooleanish('0')).to.equal(true);
expect(validation.checkBooleanish(0)).to.equal(true);
expect(validation.checkBooleanish('off')).to.equal(true);
});
it('returns false for invalid values', () => {
expect(validation.checkBooleanish({})).to.equal(false);
expect(validation.checkBooleanish(10)).to.equal(false);
expect(validation.checkBooleanish('on1')).to.equal(false);
expect(validation.checkBooleanish('foo')).to.equal(false);
expect(validation.checkBooleanish(undefined)).to.equal(false);
expect(validation.checkBooleanish(null)).to.equal(false);
expect(validation.checkBooleanish('')).to.equal(false);
});
});
describe('checkFalsey', () => {
it('returns false for a truthy value', () => {
expect(validation.checkFalsey(true)).to.equal(false);
expect(validation.checkFalsey('true')).to.equal(false);
expect(validation.checkFalsey('1')).to.equal(false);
expect(validation.checkFalsey(1)).to.equal(false);
expect(validation.checkFalsey('on')).to.equal(false);
});
it('returns true for a falsey value', () => {
expect(validation.checkFalsey(false)).to.equal(true);
expect(validation.checkFalsey('false')).to.equal(true);
expect(validation.checkFalsey('0')).to.equal(true);
expect(validation.checkFalsey(0)).to.equal(true);
expect(validation.checkFalsey('off')).to.equal(true);
});
it('returns false for invalid values', () => {
expect(validation.checkFalsey({})).to.equal(false);
expect(validation.checkFalsey(10)).to.equal(false);
expect(validation.checkFalsey('on1')).to.equal(false);
expect(validation.checkFalsey('foo')).to.equal(false);
expect(validation.checkFalsey(undefined)).to.equal(false);
expect(validation.checkFalsey(null)).to.equal(false);
expect(validation.checkFalsey('')).to.equal(false);
});
});
describe('checkTruthy', () => {
it('returns true for a truthy value', () => {
expect(validation.checkTruthy(true)).to.equal(true);
expect(validation.checkTruthy('true')).to.equal(true);
expect(validation.checkTruthy('1')).to.equal(true);
expect(validation.checkTruthy(1)).to.equal(true);
expect(validation.checkTruthy('on')).to.equal(true);
});
it('returns false for a falsey value', () => {
expect(validation.checkTruthy(false)).to.equal(false);
expect(validation.checkTruthy('false')).to.equal(false);
expect(validation.checkTruthy('0')).to.equal(false);
expect(validation.checkTruthy(0)).to.equal(false);
expect(validation.checkTruthy('off')).to.equal(false);
});
it('returns false for invalid values', () => {
expect(validation.checkTruthy({})).to.equal(false);
expect(validation.checkTruthy(10)).to.equal(false);
expect(validation.checkTruthy('on1')).to.equal(false);
expect(validation.checkTruthy('foo')).to.equal(false);
expect(validation.checkTruthy(undefined)).to.equal(false);
expect(validation.checkTruthy(null)).to.equal(false);
expect(validation.checkTruthy('')).to.equal(false);
});
});
describe('checkString', () => {
it('validates a string', () => {
expect(validation.checkString('foo')).to.equal('foo');
expect(validation.checkString('bar')).to.equal('bar');
});
it('returns undefined for empty strings or strings that equal null or undefined', () => {
expect(validation.checkString('')).to.be.undefined;
expect(validation.checkString('null')).to.be.undefined;
expect(validation.checkString('undefined')).to.be.undefined;
});
it('returns undefined for things that are not strings', () => {
expect(validation.checkString({})).to.be.undefined;
expect(validation.checkString([])).to.be.undefined;
expect(validation.checkString(123)).to.be.undefined;
expect(validation.checkString(0)).to.be.undefined;
expect(validation.checkString(null)).to.be.undefined;
expect(validation.checkString(undefined)).to.be.undefined;
});
});
describe('checkInt', () => {
it('returns an integer for a string that can be parsed as one', () => {
expect(validation.checkInt('200')).to.equal(200);
expect(validation.checkInt('200.00')).to.equal(200); // Allow since no data is being lost
expect(validation.checkInt('0')).to.equal(0);
expect(validation.checkInt('-3')).to.equal(-3);
});
it('returns the same integer when passed an integer', () => {
expect(validation.checkInt(345)).to.equal(345);
expect(validation.checkInt(-345)).to.equal(-345);
});
it("returns undefined when passed something that can't be parsed as int", () => {
expect(validation.checkInt({})).to.be.undefined;
expect(validation.checkInt([])).to.be.undefined;
expect(validation.checkInt('foo')).to.be.undefined;
expect(validation.checkInt('')).to.be.undefined;
expect(validation.checkInt(null)).to.be.undefined;
expect(validation.checkInt(undefined)).to.be.undefined;
expect(validation.checkInt('45notanumber')).to.be.undefined;
expect(validation.checkInt('000123.45notanumber')).to.be.undefined;
expect(validation.checkInt(50.55)).to.be.undefined; // Fractional digits
expect(validation.checkInt('50.55')).to.be.undefined; // Fractional digits
expect(validation.checkInt('0x11')).to.be.undefined; // Hexadecimal
expect(validation.checkInt('0b11')).to.be.undefined; // Binary
expect(validation.checkInt('0o11')).to.be.undefined; // Octal
});
it('returns undefined when passed a negative or zero value and the positive option is set', () => {
expect(validation.checkInt('-3', { positive: true })).to.be.undefined;
expect(validation.checkInt('0', { positive: true })).to.be.undefined;
});
});
describe('short string', () => {
it('accepts strings below 255 chars', () => {
expect(isRight(ShortString.decode('aaaa'))).to.be.true;
expect(isRight(ShortString.decode('1234'))).to.be.true;
expect(isRight(ShortString.decode('some longish alphanumeric text 1236')))
.to.be.true;
expect(isRight(ShortString.decode('a'.repeat(255)))).to.be.true;
});
it('rejects non strings or strings longer than 255 chars', () => {
expect(isRight(ShortString.decode(null))).to.be.false;
expect(isRight(ShortString.decode(undefined))).to.be.false;
expect(isRight(ShortString.decode([]))).to.be.false;
expect(isRight(ShortString.decode(1234))).to.be.false;
expect(isRight(ShortString.decode('a'.repeat(256)))).to.be.false;
});
});
describe('device name', () => {
it('accepts strings below 255 chars', () => {
expect(isRight(DeviceName.decode('aaaa'))).to.be.true;
expect(isRight(DeviceName.decode('1234'))).to.be.true;
expect(isRight(DeviceName.decode('some longish alphanumeric text 1236')))
.to.be.true;
expect(isRight(DeviceName.decode('a'.repeat(255)))).to.be.true;
});
it('rejects non strings or strings longer than 255 chars', () => {
expect(isRight(DeviceName.decode(null))).to.be.false;
expect(isRight(DeviceName.decode(undefined))).to.be.false;
expect(isRight(DeviceName.decode([]))).to.be.false;
expect(isRight(DeviceName.decode(1234))).to.be.false;
expect(isRight(DeviceName.decode('a'.repeat(256)))).to.be.false;
});
it('rejects strings with new lines', () => {
expect(isRight(DeviceName.decode('\n'))).to.be.false;
expect(isRight(DeviceName.decode('aaaa\nbbbb'))).to.be.false;
expect(isRight(DeviceName.decode('\n' + 'a'.repeat(254)))).to.be.false;
expect(isRight(DeviceName.decode('\n' + 'a'.repeat(255)))).to.be.false;
});
});
describe('string identifier', () => {
it('accepts integer strings as input', () => {
expect(isRight(StringIdentifier.decode('0'))).to.be.true;
expect(isRight(StringIdentifier.decode('1234'))).to.be.true;
expect(isRight(StringIdentifier.decode('51564189199'))).to.be.true;
});
it('rejects non strings or non numeric strings', () => {
expect(isRight(StringIdentifier.decode(null))).to.be.false;
expect(isRight(StringIdentifier.decode(undefined))).to.be.false;
expect(isRight(StringIdentifier.decode([1]))).to.be.false;
expect(isRight(StringIdentifier.decode('[1]'))).to.be.false;
expect(isRight(StringIdentifier.decode(12345))).to.be.false;
expect(isRight(StringIdentifier.decode(-12345))).to.be.false;
expect(isRight(StringIdentifier.decode('aaaa'))).to.be.false;
expect(isRight(StringIdentifier.decode('-125'))).to.be.false;
expect(isRight(StringIdentifier.decode('0xffff'))).to.be.false;
expect(isRight(StringIdentifier.decode('1544.333'))).to.be.false;
});
it('decodes to a string', () => {
expect(StringIdentifier.decode('12345'))
.to.have.property('right')
.that.equals('12345');
});
});
describe('numeric identifier', () => {
it('accepts integers and integer strings as input', () => {
expect(isRight(NumericIdentifier.decode('0'))).to.be.true;
expect(isRight(NumericIdentifier.decode('1234'))).to.be.true;
expect(isRight(NumericIdentifier.decode(1234))).to.be.true;
expect(isRight(NumericIdentifier.decode(51564189199))).to.be.true;
expect(isRight(NumericIdentifier.decode('51564189199'))).to.be.true;
});
it('rejects non strings or non numeric strings', () => {
expect(isRight(NumericIdentifier.decode(null))).to.be.false;
expect(isRight(NumericIdentifier.decode(undefined))).to.be.false;
expect(isRight(NumericIdentifier.decode([1]))).to.be.false;
expect(isRight(NumericIdentifier.decode('[1]'))).to.be.false;
expect(isRight(NumericIdentifier.decode('aaaa'))).to.be.false;
expect(isRight(NumericIdentifier.decode('-125'))).to.be.false;
expect(isRight(NumericIdentifier.decode('0xffff'))).to.be.false;
expect(isRight(NumericIdentifier.decode('1544.333'))).to.be.false;
expect(isRight(NumericIdentifier.decode(1544.333))).to.be.false;
expect(isRight(NumericIdentifier.decode(-1544.333))).to.be.false;
});
it('decodes to a number', () => {
expect(NumericIdentifier.decode('12345'))
.to.have.property('right')
.that.equals(12345);
expect(NumericIdentifier.decode(12345))
.to.have.property('right')
.that.equals(12345);
});
});
describe('target apps', () => {
it('accept valid target apps', () => {
expect(
isRight(
TargetApps.decode({
abcd: {
id: 1234,
name: 'something',
class: 'fleet',
releases: {},
},
}),
),
'accepts apps with no no release id or commit',
).to.be.true;
expect(
isRight(
TargetApps.decode({
abcd: {
id: 1234,
name: 'something',
releases: {
bar: {
id: 123,
services: {},
},
},
},
}),
),
'accepts apps with no services',
).to.be.true;
const target = TargetApps.decode({
abcd: {
id: 1234,
name: 'something',
releases: {
bar: {
id: 123,
services: {
bazbaz: {
id: 45,
image_id: 34,
image: 'foo',
environment: { MY_SERVICE_ENV_VAR: '123' },
labels: {
'io.balena.features.supervisor-api': 'true',
'caddy.@match.path': '/sourcepath /sourcepath/*',
'traefik.http.routers.router0.tls.domains[0].main':
'foobar',
_not_first_alpha: '1',
},
running: false,
},
},
volumes: {},
networks: {},
},
},
},
});
expect(isRight(target), 'accepts apps with a service').to.be.true;
expect((target as any).right).to.deep.equal({
abcd: {
id: 1234,
name: 'something',
class: 'fleet',
releases: {
bar: {
id: 123,
services: {
bazbaz: {
id: 45,
image_id: 34,
image: 'foo',
environment: { MY_SERVICE_ENV_VAR: '123' },
labels: {
'io.balena.features.supervisor-api': 'true',
'caddy.@match.path': '/sourcepath /sourcepath/*',
'traefik.http.routers.router0.tls.domains[0].main':
'foobar',
_not_first_alpha: '1',
},
running: false,
},
},
volumes: {},
networks: {},
},
},
},
});
});
it('rejects app with invalid environment', () => {
expect(
isRight(
TargetApps.decode({
abcd: {
id: 1234,
name: 'something',
releases: {
bar: {
id: 123,
services: {
bazbaz: {
id: 45,
image_id: 34,
image: 'foo',
environment: { ' aaa': '123' },
labels: {},
},
},
volumes: {},
networks: {},
},
},
},
}),
),
).to.be.false;
});
it('rejects app with invalid labels', () => {
expect(
isRight(
TargetApps.decode({
abcd: {
id: 1234,
name: 'something',
releases: {
bar: {
id: 123,
services: {
bazbaz: {
id: 45,
image_id: 34,
image: 'foo',
environment: {},
labels: { ' not a valid "name': 'label value' },
},
},
volumes: {},
networks: {},
},
},
},
}),
),
).to.be.false;
});
it('rejects an invalid appId', () => {
expect(
isRight(
TargetApps.decode({
abcd: {
id: 'booo',
name: 'something',
releases: {},
},
}),
),
).to.be.false;
});
it('rejects a release uuid that is too long', () => {
expect(
isRight(
TargetApps.decode({
abcd: {
id: '123',
name: 'something',
releases: {
['a'.repeat(256)]: {
id: 1234,
services: {
bazbaz: {
id: 45,
image_id: 34,
image: 'foo',
environment: {},
labels: {},
},
},
},
},
},
}),
),
).to.be.false;
it('rejects a service with an invalid docker name', () => {
expect(
isRight(
TargetApps.decode({
abcd: {
id: '123',
name: 'something',
releases: {
aaaa: {
id: 1234,
services: {
' not a valid name': {
id: 45,
image_id: 34,
image: 'foo',
environment: {},
labels: {},
},
},
},
},
},
}),
),
).to.be.false;
});
});
it('rejects app with an invalid releaseId', () => {
expect(
isRight(
TargetApps.decode({
abcd: {
id: '123',
name: 'something',
releases: {
aaaa: {
id: 'boooo',
services: {},
},
},
},
}),
),
).to.be.false;
});
});
describe('target state', () => {
it('accepts target state with config vars and apps', () => {
expect(
isRight(
TargetState.decode({
one: {
name: 'angry-einstein',
config: {
BALENA_HOST_CONFIG_hdmi_force_hotplug: '0',
'BALENA_HOST_CONFIG_hdmi_force_hotplug:1': '1',
BALENA_HOST_CONFIG_dtoverlay: 'balena-fin',
},
apps: {},
},
}),
),
).to.be.true;
});
it('rejects target state with an invalid config vars', () => {
expect(
isRight(
TargetState.decode({
one: {
name: 'angry-einstein',
config: {
'BALENA_CONFIG_ INVALID VAR': '123',
},
apps: {
abcd: {
id: 1234,
name: 'something',
class: 'fleet',
releases: {},
},
},
},
}),
),
).to.be.false;
});
});
});