yaml importer slopping it up

This commit is contained in:
legop3
2026-09-14 13:48:58 -04:00
parent 8ff3c39765
commit 881583ee0a
4 changed files with 95 additions and 16 deletions
+53 -1
View File
@@ -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'],
}),
},
}, {