mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
yaml importer slopping it up
This commit is contained in:
@@ -279,7 +279,7 @@ test('administrator storage never exposes hashes or removes the final lockdown a
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('an explicitly uploaded YAML file imports configuration and bcrypt hashes exactly once', () => {
|
||||
test('an explicitly uploaded YAML imports current fields, ignores obsolete keys, and preserves bcrypt hashes exactly once', () => {
|
||||
const yamlText = `
|
||||
admins:
|
||||
- username: owner
|
||||
@@ -289,10 +289,45 @@ admins:
|
||||
timezone: America/Chicago
|
||||
media:
|
||||
whepBaseUrl: http://localhost:8889/video
|
||||
overseerControl:
|
||||
enabled: false
|
||||
heartbeatMs: 30000
|
||||
alwaysRunModel: false
|
||||
homeAssistant:
|
||||
neato:
|
||||
enabled: false
|
||||
brainslugHost: neato-vacuum.local
|
||||
brainslugKey: retired-secret
|
||||
brainslugLogFile: /tmp/retired.log
|
||||
roomCameras:
|
||||
enabled: true
|
||||
cameras:
|
||||
- id: stream-only
|
||||
name: Stream-only camera
|
||||
streamUrl: http://camera.local/stream.mjpg
|
||||
discord:
|
||||
channels:
|
||||
chatBridge: "123456789012345678"
|
||||
roles:
|
||||
stalker: "123456789012345678"
|
||||
fleetReports:
|
||||
discord:
|
||||
immediateCriticalAlerts: true
|
||||
`;
|
||||
const parsed = parseConfigurationFile(yamlText);
|
||||
assert.equal(parsed.config.timezone, 'America/Chicago');
|
||||
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
|
||||
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'alwaysRunModel'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.homeAssistant.neato, 'brainslugHost'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.discord.channels, 'chatBridge'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.discord.roles, 'stalker'), false);
|
||||
assert.equal(Object.hasOwn(parsed.config.fleetReports.discord, 'immediateCriticalAlerts'), false);
|
||||
assert.deepEqual(parsed.config.roomCameras.cameras, [{
|
||||
id: 'stream-only',
|
||||
name: 'Stream-only camera',
|
||||
streamUrl: 'http://camera.local/stream.mjpg',
|
||||
}]);
|
||||
|
||||
const database = createTestDatabase();
|
||||
const result = importConfigurationFile({ text: yamlText, database });
|
||||
@@ -301,3 +336,20 @@ media:
|
||||
assert.throws(() => importConfigurationFile({ text: yamlText, database }), /cannot replace an initialized installation/);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('uploaded YAML still rejects invalid values for fields in the current schema', () => {
|
||||
const yamlText = `
|
||||
admins:
|
||||
- username: owner
|
||||
password_hash: "$2b$10$preservedHash"
|
||||
lockdown: true
|
||||
bandwidthSavings:
|
||||
multiTabProtection: unsupported-mode
|
||||
`;
|
||||
|
||||
assert.throws(() => parseConfigurationFile(yamlText), (error) => {
|
||||
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
|
||||
assert.ok(error.validationErrors.some((entry) => entry.path === '/bandwidthSavings/multiTabProtection'));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,32 @@
|
||||
// Purpose: Validates one YAML file deliberately uploaded during first-run setup and stores it in the configuration database.
|
||||
// Scope: This is an explicit setup action only; startup and installation never search for or consume configuration files.
|
||||
const yaml = require('js-yaml');
|
||||
const { normalizeConfig, assertValidConfig } = require('./validation');
|
||||
const { rootSchema, normalizeConfig, assertValidConfig } = require('./validation');
|
||||
|
||||
function keepCurrentSchemaFields(value, schema) {
|
||||
/*
|
||||
An uploaded file is only a convenient seed for the current configuration;
|
||||
it is not a second schema or a historical migration framework. Legacy YAML
|
||||
was permissive, so real installations naturally contain keys left behind
|
||||
by removed features. At object boundaries, copy only properties that exist
|
||||
in today's schema and recursively apply the same rule to nested objects and
|
||||
array items. Known fields retain their original values and are validated
|
||||
normally afterward, so this cannot hide a malformed current setting.
|
||||
*/
|
||||
if (schema?.type === 'object') {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
||||
return Object.fromEntries(Object.entries(schema.properties || {})
|
||||
.filter(([key]) => Object.hasOwn(value, key))
|
||||
.map(([key, childSchema]) => [key, keepCurrentSchemaFields(value[key], childSchema)]));
|
||||
}
|
||||
|
||||
if (schema?.type === 'array') {
|
||||
if (!Array.isArray(value)) return value;
|
||||
return value.map((item) => keepCurrentSchemaFields(item, schema.items));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUploadedAdministrator(entry, index) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
@@ -33,13 +58,11 @@ function parseConfigurationFile(text) {
|
||||
const configInput = Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => key !== 'admins'),
|
||||
);
|
||||
const config = normalizeConfig(configInput);
|
||||
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
|
||||
|
||||
/*
|
||||
Unknown fields remain in the normalized document so strict schema
|
||||
validation reports them to the operator instead of silently losing data
|
||||
from the explicitly selected file.
|
||||
*/
|
||||
// Filtering applies only to nonexistent keys. Values retained for current
|
||||
// schema fields still have to satisfy every type, range, and format rule
|
||||
// before the importer can atomically initialize the database.
|
||||
assertValidConfig(config);
|
||||
if (!administrators.some((admin) => admin.role === 'lockdown')) {
|
||||
throw new Error('The configuration file must contain at least one lockdown administrator.');
|
||||
|
||||
@@ -35,12 +35,15 @@ module.exports = {
|
||||
items: strictObject({
|
||||
id: string({ description: 'Stable camera identifier used in socket requests, selections, and replay source names.', examples: ['lobby'], minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||
name: string({ description: 'Human-readable camera name shown in the UI.', examples: ['Lobby Camera'], minLength: 1, maxLength: 120 }),
|
||||
description: string({ description: 'Short explanation of the camera location or view shown in the UI.', examples: ['Wide shot of the staging area.'], maxLength: 500 }),
|
||||
url: string({ title: 'Snapshot URL', description: 'HTTP URL fetched when the server needs a still image from this camera.', examples: ['http://192.168.0.50/snapshot.jpg'], format: 'uri', maxLength: 2048 }),
|
||||
streamUrl: string({ title: 'Stream URL', description: 'Live stream URL consumed by the server snapshot engine and replay capture path.', examples: ['http://192.168.0.50/stream.mjpg'], maxLength: 2048 }),
|
||||
description: string({ description: 'Optional explanation of the camera location or view shown in the UI.', examples: ['Wide shot of the staging area.'], maxLength: 500 }),
|
||||
url: string({ title: 'Snapshot URL', description: 'Optional HTTP URL fetched when the server needs a still image from this camera.', examples: ['http://192.168.0.50/snapshot.jpg'], format: 'uri', maxLength: 2048 }),
|
||||
streamUrl: string({ title: 'Stream URL', description: 'Optional live stream URL consumed by the server snapshot engine and replay capture path.', examples: ['http://192.168.0.50/stream.mjpg'], maxLength: 2048 }),
|
||||
}, {
|
||||
description: 'One named room camera with its still-image and live-stream sources.',
|
||||
required: ['id', 'name', 'description', 'url', 'streamUrl'],
|
||||
// The runtime has always accepted snapshot-only and stream-only camera
|
||||
// entries, and treats descriptions as presentation metadata. Requiring
|
||||
// all three optional values made working YAML impossible to import.
|
||||
description: 'One named room camera with an optional description and any snapshot or live-stream sources it provides.',
|
||||
required: ['id', 'name'],
|
||||
}),
|
||||
},
|
||||
}, {
|
||||
|
||||
Reference in New Issue
Block a user