mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 07:24:52 +09:00
Merge pull request #856 from Cooldude2606/feature/config-validation
Add extended config validation
This commit is contained in:
25 files changed
+1912
-318
No files matched your search
@@ -182,7 +182,8 @@ async function handleBootstrapCommand(
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(jwt.sign(
|
||||
{ aud: "host", host: args.id },
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
|
||||
// Auth secret is never null after startup
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
));
|
||||
|
||||
} else if (subCommand === "create-ctl-config") {
|
||||
|
||||
@@ -73,6 +73,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
|
||||
this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
|
||||
@@ -84,6 +85,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
|
||||
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
|
||||
@@ -195,6 +197,26 @@ export default class ControlConnection extends BaseConnection {
|
||||
return this._controller.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleControllerConfigSetRequest(request: lib.ControllerConfigSetRequest) {
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
this._controller.config.stageProp(
|
||||
fieldName as keyof lib.ControllerConfigFields, propName, propValue, "control"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._controller.config.stage(fieldName as keyof lib.ControllerConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.config.commitStaging();
|
||||
} finally {
|
||||
this._controller.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
|
||||
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
|
||||
}
|
||||
@@ -280,17 +302,42 @@ export default class ControlConnection extends BaseConnection {
|
||||
return instance.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
let instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
if (request.field === "instance.assigned_host") {
|
||||
private static validateInstanceConfigSetField(fieldName: string) {
|
||||
if (fieldName === "instance.assigned_host") {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
|
||||
if (request.field === "instance.id") {
|
||||
if (fieldName === "instance.id") {
|
||||
// XXX is this worth implementing? It's race condition galore.
|
||||
throw new lib.RequestError("Setting instance.id is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetRequest(request: lib.InstanceConfigSetRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
ControlConnection.validateInstanceConfigSetField(fieldName);
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
instance.config.stageProp(
|
||||
fieldName as keyof lib.InstanceConfigFields, propName, propValue, "control"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
instance.config.stage(fieldName as keyof lib.InstanceConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
instance.config.commitStaging();
|
||||
} finally {
|
||||
instance.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
ControlConnection.validateInstanceConfigSetField(request.field);
|
||||
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
@@ -298,6 +345,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
|
||||
let instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
let { field, prop, value } = request;
|
||||
ControlConnection.validateInstanceConfigSetField(field);
|
||||
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
@@ -268,6 +268,11 @@ export default class Controller {
|
||||
this.users.records.on("update", this.usersUpdated.bind(this));
|
||||
}
|
||||
|
||||
get authSecret() {
|
||||
// Auth secret is never null after startup
|
||||
return Buffer.from(this.config.get("controller.auth_secret")!, "base64");
|
||||
}
|
||||
|
||||
async start(args: ControllerArgs) {
|
||||
if (this._state !== "new") {
|
||||
throw new Error(`Cannot start in state ${this._state}`);
|
||||
@@ -883,10 +888,7 @@ export default class Controller {
|
||||
* @param hostId - ID of host to generate a token for.
|
||||
*/
|
||||
generateHostToken(hostId: number): string {
|
||||
return jwt.sign(
|
||||
{ aud: "host", host: hostId },
|
||||
Buffer.from(this.config.get("controller.auth_secret"), "base64")
|
||||
);
|
||||
return jwt.sign({ aud: "host", host: hostId }, this.authSecret);
|
||||
}
|
||||
|
||||
hostsUpdated(hosts: HostRecord[]) {
|
||||
|
||||
@@ -151,7 +151,8 @@ export default class UserManager {
|
||||
signUserToken(user: UserRecord): string {
|
||||
return jwt.sign(
|
||||
{ aud: "user", user: user.id },
|
||||
Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64")
|
||||
// auth secret is never null after startup
|
||||
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -223,10 +223,7 @@ ${err.stack}`
|
||||
createSession(dst: lib.Address): [ WsServerConnector, string ] {
|
||||
let sessionId = this.nextSessionId;
|
||||
this.nextSessionId += 1;
|
||||
let sessionToken = jwt.sign(
|
||||
{ aud: this.sessionAud, sid: sessionId },
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
);
|
||||
let sessionToken = jwt.sign({ aud: this.sessionAud, sid: sessionId }, this.controller.authSecret);
|
||||
let sessionTimeout = this.controller.config.get("controller.session_timeout");
|
||||
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
|
||||
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
|
||||
@@ -247,13 +244,8 @@ ${err.stack}`
|
||||
}
|
||||
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
{ audience: ["host", "slave"] }
|
||||
);
|
||||
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -324,12 +316,7 @@ ${err.stack}`
|
||||
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
|
||||
let user;
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -376,12 +363,7 @@ ${err.stack}`
|
||||
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
|
||||
let connector;
|
||||
try {
|
||||
let payload = jwt.verify(
|
||||
data.sessionToken,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: this.sessionAud }
|
||||
);
|
||||
|
||||
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { audience: this.sessionAud });
|
||||
if (typeof payload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Application, Request, Response } from "express";
|
||||
import type Controller from "./Controller";
|
||||
import Controller from "./Controller";
|
||||
|
||||
import busboy from "busboy";
|
||||
import crypto from "crypto";
|
||||
@@ -148,12 +148,7 @@ function validateHostToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "host" }
|
||||
);
|
||||
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -180,12 +175,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
@@ -387,6 +387,7 @@ export default class Host extends lib.Link {
|
||||
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
|
||||
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
|
||||
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetRequest, this.handleHostConfigSetRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
|
||||
@@ -503,18 +504,41 @@ export default class Host extends lib.Link {
|
||||
return this.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
|
||||
if (request.field === "host.id") {
|
||||
private static validateHostConfigSetField(fieldName: string) {
|
||||
if (fieldName === "host.id") {
|
||||
// Changing host.id at runtime is not worth the effort to
|
||||
// support and will break a lot of things if it is allowed.
|
||||
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
async handleHostConfigSetRequest(request: lib.HostConfigSetRequest) {
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
Host.validateHostConfigSetField(fieldName);
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
this.config.stageProp(fieldName as keyof lib.HostConfigFields, propName, propValue, "control");
|
||||
}
|
||||
} else {
|
||||
this.config.stage(fieldName as keyof lib.HostConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
this.config.commitStaging();
|
||||
} finally {
|
||||
this.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
|
||||
Host.validateHostConfigSetField(request.field);
|
||||
this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control");
|
||||
}
|
||||
|
||||
async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) {
|
||||
let { field, prop, value } = request;
|
||||
Host.validateHostConfigSetField(field);
|
||||
this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { basicType } from "../helpers";
|
||||
import * as libSchema from "../schema";
|
||||
import { StringEnum } from "../data/composites";
|
||||
import { safeOutputFile } from "../file_ops";
|
||||
import * as validators from "./validators";
|
||||
|
||||
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
||||
export type ConfigLocation = Static<typeof ConfigLocation>;
|
||||
@@ -63,14 +64,24 @@ export const ConfigSchema = Type.Record(Type.String(), Type.Union([
|
||||
]));
|
||||
export type ConfigSchema = Static<typeof ConfigSchema>;
|
||||
|
||||
type FieldType = "boolean" | "string" | "number" | "object";
|
||||
type FieldValue = null | boolean | string | number | object;
|
||||
export interface FieldDefinition {
|
||||
|
||||
type FieldType<T extends FieldValue> =
|
||||
T extends boolean ? "boolean" :
|
||||
T extends string ? "string" :
|
||||
T extends number ? "number" :
|
||||
T extends object ? "object" :
|
||||
never;
|
||||
|
||||
export interface FieldDefinition<
|
||||
Type extends FieldValue = any,
|
||||
Fields extends Record<keyof Fields, FieldValue> = any,
|
||||
> {
|
||||
/**
|
||||
* string declaring the type of the config value, supports boolean,
|
||||
* string, number and object.
|
||||
*/
|
||||
type: FieldType;
|
||||
type: FieldType<Type>;
|
||||
/**
|
||||
* Text used to identify the config field in user interfaces.
|
||||
*/
|
||||
@@ -79,6 +90,16 @@ export interface FieldDefinition {
|
||||
* Text used to describe the config field in user interfaces.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Validation function ran to confirm the value is consistent with other config values.
|
||||
* Should throw an error if the value in invalid / inconsistent
|
||||
*/
|
||||
validator?: (value: Type, config: Config<Fields>) => void;
|
||||
/**
|
||||
* Names the config fields that the validator depends on.
|
||||
* This will ensure it is checked when these fields are changed.
|
||||
*/
|
||||
dependsOn?: string[];
|
||||
/**
|
||||
* Value to use for the autocomplete attribute in the Web UI's input element.
|
||||
* Useful for making browsers behave properly with credential inputs.
|
||||
@@ -93,7 +114,7 @@ export interface FieldDefinition {
|
||||
/**
|
||||
* True if this field can be set to null. Defaults to false.
|
||||
*/
|
||||
optional?: boolean;
|
||||
optional?: Type extends null ? true : false;
|
||||
/**
|
||||
* Value this config field should take in a newly initialized config.
|
||||
* This can also be an function returning the value to use.
|
||||
@@ -137,8 +158,10 @@ export interface FieldDefinition {
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export type ConfigDefs<Fields> = {
|
||||
[Prop in keyof Fields]: FieldDefinition;
|
||||
export type ConfigDefs<
|
||||
Fields extends Record<keyof Fields, FieldValue>
|
||||
> = {
|
||||
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
|
||||
}
|
||||
|
||||
function defaultValue(def: FieldDefinition) {
|
||||
@@ -177,14 +200,17 @@ export enum ConfigAccess {
|
||||
* @extends events.EventEmitter
|
||||
*/
|
||||
export class Config<
|
||||
Fields extends { [Field in keyof Fields]: FieldValue },
|
||||
Fields extends Record<keyof Fields, FieldValue>
|
||||
> extends events.EventEmitter<ConfigEvents> {
|
||||
/**
|
||||
* Mapping of config name to field meta data
|
||||
* Note that mutating this object will lead to unexpected behaviour.
|
||||
*/
|
||||
declare static fieldDefinitions: ConfigDefs<any>;
|
||||
declare ["constructor"]: typeof Config;
|
||||
declare ["constructor"]: typeof Config<Fields>;
|
||||
|
||||
/** A set of common validators and validator factories */
|
||||
static validators = validators;
|
||||
|
||||
/**
|
||||
* Handle migration between clusterio versions
|
||||
@@ -204,6 +230,8 @@ export class Config<
|
||||
private fields: Fields;
|
||||
private _unknownFields: Record<string, FieldValue> = {};
|
||||
|
||||
/** Set to true during validators to prevent edits */
|
||||
private _readonly = false; // TODO replace with "using config.asReadonly()"
|
||||
/** Set to true when a field in the config is changed. */
|
||||
private dirty = false;
|
||||
/** Set to true when a 'restart required' field in the config is changed. */
|
||||
@@ -244,6 +272,110 @@ export class Config<
|
||||
}
|
||||
}
|
||||
|
||||
/** A staging version of the config with changes tracked, invalided by revertStaging */
|
||||
private _staging?: { config: Config<Fields>, changes: Set<string>};
|
||||
|
||||
/** Get the latest version of staging */
|
||||
// TODO replace with "using config.startStating()"
|
||||
get staging(): Config<Fields> {
|
||||
if (this._staging) { return this._staging.config; }
|
||||
const config = new this.constructor(this.location, this.fields);
|
||||
const changes = new Set<string>();
|
||||
config.on("fieldChanged", name => changes.add(name));
|
||||
this._staging = { config, changes };
|
||||
return config;
|
||||
}
|
||||
|
||||
private static _fieldDependents: Map<string, {
|
||||
name: string,
|
||||
def: FieldDefinition & { validator: NonNullable<FieldDefinition["validator"]> }
|
||||
}[]>;
|
||||
|
||||
/** Map of all fields whose validators depend on the given field */
|
||||
static get fieldDependents(): typeof this._fieldDependents {
|
||||
if (this._fieldDependents) {
|
||||
return this._fieldDependents;
|
||||
}
|
||||
|
||||
const dependents = new Map<string, { name: string, def: FieldDefinition }[]>();
|
||||
|
||||
for (const [name, def] of Object.entries(this.fieldDefinitions)) {
|
||||
if (!def.validator || !def.dependsOn) { continue; }
|
||||
|
||||
for (const otherName of def.dependsOn) {
|
||||
const fieldDependents = dependents.get(otherName);
|
||||
if (fieldDependents) {
|
||||
fieldDependents.push({name, def});
|
||||
} else {
|
||||
dependents.set(otherName, [{name, def}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The type is correct because of "!def.validator", not clear why this doesn't infer
|
||||
this._fieldDependents = dependents as any;
|
||||
return this._fieldDependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce and validate a value according to a field definition.
|
||||
*
|
||||
* @param name - Name of the field being validated.
|
||||
* @param def - Definition describing the field type and constraints.
|
||||
* @param value - Value to validate and coerce.
|
||||
* @throws {InvalidValue} if the value cannot be coerced to the correct type
|
||||
* @returns The validated and coerced value.
|
||||
* @private
|
||||
*/
|
||||
static _coerceValue<T extends FieldDefinition>(name: string, def: T, value: unknown) {
|
||||
// Empty strings are treated as null
|
||||
if (value === "") {
|
||||
value = null;
|
||||
}
|
||||
|
||||
// If def is an enum then it must be a value within it
|
||||
if (def.enum && !def.enum.includes(value)) {
|
||||
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
||||
}
|
||||
|
||||
// If it is a string, then attempt to coerce it
|
||||
if (typeof value === "string") {
|
||||
if (def.type === "boolean") {
|
||||
if (value === "true") {
|
||||
value = true;
|
||||
} else if (value === "false") {
|
||||
value = false;
|
||||
}
|
||||
|
||||
} else if (def.type === "number") {
|
||||
const numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
|
||||
if (numberRegExp.test(value.trim())) {
|
||||
value = Number.parseFloat(value);
|
||||
}
|
||||
|
||||
} else if (def.type === "object") {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it is allowed to be null
|
||||
if (value === null) {
|
||||
if (!def.optional) {
|
||||
throw new InvalidValue(`Field ${name} cannot be null`);
|
||||
}
|
||||
|
||||
// Check if is the correct type
|
||||
} else if (basicType(value) !== def.type) {
|
||||
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
}
|
||||
|
||||
return value as FieldType<T["type"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check access for a field
|
||||
*
|
||||
@@ -288,8 +420,14 @@ export class Config<
|
||||
* @param notify - False to skip setting dirty flags and emitting events
|
||||
*/
|
||||
_set(name: keyof Fields & string, value: FieldValue, notify: boolean = true) {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
|
||||
const prev = this.fields[name];
|
||||
this.fields[name] = value as any;
|
||||
this.revertStaging();
|
||||
|
||||
if (notify && !isDeepStrictEqual(value, prev)) {
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (def.restartRequired) {
|
||||
@@ -508,55 +646,50 @@ export class Config<
|
||||
* @throws {InvalidValue} if value is not allowed for the field.
|
||||
*/
|
||||
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||
let value: FieldValue = newValue;
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
if (remote !== this.location) {
|
||||
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
||||
}
|
||||
|
||||
// Empty strings are treates as null
|
||||
if (value === "") {
|
||||
value = null;
|
||||
}
|
||||
const value = Config._coerceValue(name, def, newValue);
|
||||
|
||||
if (def.enum && !def.enum.includes(value)) {
|
||||
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (def.type === "boolean") {
|
||||
if (value === "true") {
|
||||
value = true;
|
||||
} else if (value === "false") {
|
||||
value = false;
|
||||
}
|
||||
|
||||
} else if (def.type === "number") {
|
||||
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
|
||||
if (numberRegExp.test(value.trim())) {
|
||||
value = Number.parseFloat(value);
|
||||
}
|
||||
|
||||
} else if (def.type === "object") {
|
||||
const _readonly = this._readonly;
|
||||
this.staging._readonly = true;
|
||||
this._readonly = true;
|
||||
try {
|
||||
if (def.validator) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
def.validator(value, this);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
|
||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._readonly = _readonly;
|
||||
this.staging._readonly = _readonly;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
if (!def.optional) {
|
||||
throw new InvalidValue(`Field ${name} cannot be null`);
|
||||
const dependents = this.constructor.fieldDependents.get(name);
|
||||
if (dependents) {
|
||||
// This mimics stage + commit but optimised for a single value
|
||||
this.staging._set(name, value);
|
||||
this.staging._readonly = true;
|
||||
try {
|
||||
for (const dependent of dependents) {
|
||||
try {
|
||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.revertStaging();
|
||||
}
|
||||
|
||||
} else if (basicType(value) !== def.type) {
|
||||
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
}
|
||||
|
||||
this._set(name, value);
|
||||
@@ -585,6 +718,7 @@ export class Config<
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
if (remote !== this.location) {
|
||||
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
||||
@@ -603,6 +737,164 @@ export class Config<
|
||||
delete updated[prop];
|
||||
}
|
||||
|
||||
const _readonly = this._readonly;
|
||||
this.staging._readonly = true;
|
||||
this._readonly = true;
|
||||
try {
|
||||
if (def.validator) {
|
||||
try {
|
||||
def.validator(updated, this);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._readonly = _readonly;
|
||||
this.staging._readonly = _readonly;
|
||||
}
|
||||
|
||||
const dependents = this.constructor.fieldDependents.get(name);
|
||||
if (dependents) {
|
||||
// This mimics stage + commit but optimised for a single value
|
||||
this.staging._set(name, updated);
|
||||
this.staging._readonly = true;
|
||||
try {
|
||||
for (const dependent of dependents) {
|
||||
try {
|
||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
this._set(name, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to set but only validates the field type and not other constraints.
|
||||
* The value is stored in staging and must be committed for the change to take effect.
|
||||
*
|
||||
* @param name - Name of field to set.
|
||||
* @param newValue - Value to set for field.
|
||||
* @param remote - Location this field is being set from.
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if value is not allowed for the field.
|
||||
*/
|
||||
stage<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
if (remote !== this.location) {
|
||||
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
||||
}
|
||||
|
||||
const value = Config._coerceValue(name, def, newValue);
|
||||
this.staging._set(name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to setProp but only validates the field type and not other constraints.
|
||||
* The value is stored in staging and must be committed for the change to take effect.
|
||||
*
|
||||
* @param name - Name of field to set property on.
|
||||
* @param prop - Name of property to set on field.
|
||||
* @param value - the value to set the property to.
|
||||
* @param remote - Location this property is being set from
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if field is not an object.
|
||||
*/
|
||||
stageProp<Field extends keyof Fields & string>(
|
||||
name: Field,
|
||||
prop: string,
|
||||
value?: unknown,
|
||||
remote = this.location,
|
||||
) {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
if (remote !== this.location) {
|
||||
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
||||
}
|
||||
|
||||
if (def.type !== "object") {
|
||||
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
|
||||
}
|
||||
|
||||
const prev = this.staging.get(name) as Record<string, unknown>;
|
||||
const updated = {...prev || {}};
|
||||
|
||||
if (value !== undefined) {
|
||||
updated[prop] = value;
|
||||
} else {
|
||||
delete updated[prop];
|
||||
}
|
||||
|
||||
this.staging._set(name, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit all changes in stating after they have been validated.
|
||||
*
|
||||
* @throws {InvalidValue} if a value is not allowed for its field.
|
||||
*/
|
||||
commitStaging() {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
|
||||
const staging = this._staging;
|
||||
if (!staging) {
|
||||
return; // Nothing to commit
|
||||
}
|
||||
|
||||
// Validate all fields using the current state of staging
|
||||
const config = staging.config;
|
||||
const _readonly = this._readonly;
|
||||
config._readonly = true;
|
||||
try {
|
||||
for (const [fieldName, fieldDef] of Object.entries(this.constructor.fieldDefinitions)) {
|
||||
if (fieldDef.validator) {
|
||||
try {
|
||||
fieldDef.validator(config.fields[fieldName as keyof Fields], config);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${fieldName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
config._readonly = _readonly;
|
||||
}
|
||||
|
||||
// Validation success, set all values which have changed
|
||||
for (const fieldName of staging.changes) {
|
||||
const field = fieldName as keyof Fields & string;
|
||||
this._set(field, config.fields[field]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revert all changes in staging */
|
||||
revertStaging() {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
this._staging = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Core definitions for the configuration system
|
||||
import * as classes from "./classes";
|
||||
import * as validators from "./validators";
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { isTargetVersion } from "../data/version";
|
||||
|
||||
type configFromJSON<T> = (...args: Parameters<typeof classes.Config.fromJSON>) => T;
|
||||
type configFromFile<T> = (...args: Parameters<typeof classes.Config.fromFile>) => Promise<T>;
|
||||
@@ -18,7 +20,7 @@ export interface ControllerConfigFields {
|
||||
"controller.public_url": string | null;
|
||||
"controller.tls_certificate": string | null;
|
||||
"controller.tls_private_key": string | null;
|
||||
"controller.auth_secret": string;
|
||||
"controller.auth_secret": string | null;
|
||||
"controller.heartbeat_interval": number;
|
||||
"controller.session_timeout": number;
|
||||
"controller.metrics_timeout": number;
|
||||
@@ -73,18 +75,21 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "mods",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"controller.database_directory": {
|
||||
title: "Database directory",
|
||||
description: "Directory where item and configuration data is stored.",
|
||||
type: "string",
|
||||
initialValue: "database",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"controller.autosave_interval": {
|
||||
title: "Autosave Interval",
|
||||
description: "Interval in seconds to autosave data in memory to disk.",
|
||||
type: "number",
|
||||
initialValue: 60,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.http_port": {
|
||||
title: "HTTP Port",
|
||||
@@ -93,6 +98,10 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
type: "number",
|
||||
optional: true,
|
||||
initialValue: 8080,
|
||||
validator: validators.optional(validators.all(
|
||||
validators.integer,
|
||||
validators.greaterThanZero
|
||||
)),
|
||||
},
|
||||
"controller.https_port": {
|
||||
title: "HTTPS Port",
|
||||
@@ -100,6 +109,10 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.all(
|
||||
validators.integer,
|
||||
validators.greaterThanZero
|
||||
)),
|
||||
},
|
||||
"controller.bind_address": {
|
||||
title: "Bind Address",
|
||||
@@ -107,6 +120,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
optional: true,
|
||||
// TODO extendedValidation, valid addresses including IPV4, IPV6, and localhost
|
||||
},
|
||||
"controller.trusted_proxies": {
|
||||
title: "Trusted Proxies",
|
||||
@@ -114,12 +128,14 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
"Comma separated list of IP addresses and/or CIDR blocks to trust the X-Forwarded-For header on",
|
||||
type: "string",
|
||||
optional: true,
|
||||
// TODO extendedValidation, see parseTrustedProxies
|
||||
},
|
||||
"controller.public_url": {
|
||||
title: "Public URL",
|
||||
description: "Public facing URL the controller is hosted on, including the protocol.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
// TODO extendedValidation, valid url including the protocol
|
||||
},
|
||||
"controller.tls_certificate": {
|
||||
title: "TLS Certificate",
|
||||
@@ -127,6 +143,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.filePath),
|
||||
},
|
||||
"controller.tls_private_key": {
|
||||
title: "TLS Private Key",
|
||||
@@ -134,6 +151,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.filePath),
|
||||
},
|
||||
"controller.auth_secret": {
|
||||
access: ["controller"],
|
||||
@@ -151,30 +169,35 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
description: "Interval heartbeats are sent out on WebSocket connections.",
|
||||
type: "number",
|
||||
initialValue: 15,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.session_timeout": {
|
||||
title: "Session Timeout",
|
||||
description: "Time in seconds before giving up resuming a dropped WebSocket session.",
|
||||
type: "number",
|
||||
initialValue: 60,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.metrics_timeout": {
|
||||
title: "Metrics Timeout",
|
||||
description: "Timeout in seconds for metrics gathering from hosts.",
|
||||
type: "number",
|
||||
initialValue: 8,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.system_metrics_interval": {
|
||||
title: "System Metrics Interval",
|
||||
description: "Interval in seconds to collect and update system metrics for the Web UI",
|
||||
type: "number",
|
||||
initialValue: 10,
|
||||
validator: validators.greaterThanEqualZero,
|
||||
},
|
||||
"controller.proxy_stream_timeout": {
|
||||
title: "Proxy Stream Timeout",
|
||||
description: "Timeout in seconds for proxy streams to start flowing.",
|
||||
type: "number",
|
||||
initialValue: 15,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.factorio_username": {
|
||||
title: "Factorio Username",
|
||||
@@ -219,12 +242,14 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
description: "Duration in minutes to cache mod portal API responses.",
|
||||
type: "number",
|
||||
initialValue: 30,
|
||||
validator: validators.greaterThanEqualZero, // Zero is allowed here to disable cache
|
||||
},
|
||||
"controller.mod_portal_page_size": {
|
||||
title: "Mod Portal Page Size",
|
||||
description: "Maximum number of results per page when querying the Factorio mod portal API.",
|
||||
type: "number",
|
||||
initialValue: 1000,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.allow_remote_updates": {
|
||||
description: "When true, allows a remote event to trigger a clusterio update via npm",
|
||||
@@ -299,6 +324,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
description: "Path to directory to look for factorio installs",
|
||||
type: "string",
|
||||
initialValue: "factorio",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"host.mods_directory": {
|
||||
title: "Mods Directory",
|
||||
@@ -306,18 +332,21 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "mods",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"host.instances_directory": {
|
||||
description: "Path to directory to store instances in.",
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "instances",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"host.controller_url": {
|
||||
description: "URL to connect to the controller at",
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "http://localhost:8080/",
|
||||
// TODO extendedValidation, valid url including the protocol
|
||||
},
|
||||
"host.controller_token": {
|
||||
access: ["host"],
|
||||
@@ -325,6 +354,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "enter token here",
|
||||
// TODO extendedValidation, valid JWT with matching host id
|
||||
},
|
||||
"host.public_address": {
|
||||
description: "Public facing address players should connect to in order to join instances on this host",
|
||||
@@ -338,6 +368,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
"ranges separated with a dash.",
|
||||
type: "string",
|
||||
initialValue: "34100-34199",
|
||||
// TODO extendedValidation, port range
|
||||
},
|
||||
"host.factorio_username": {
|
||||
title: "Factorio Username",
|
||||
@@ -363,6 +394,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initialValue: 60,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"host.allow_remote_updates": {
|
||||
description: "When true, allows a remote event to trigger a clusterio update via npm",
|
||||
@@ -456,6 +488,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "number",
|
||||
optional: true,
|
||||
hidden: true,
|
||||
// Internal value, extendedValidation not used
|
||||
},
|
||||
"instance.auto_start": {
|
||||
description: "Automatically start this instance when the host hosting it is started up",
|
||||
@@ -473,6 +506,11 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "string",
|
||||
initialValue: "latest",
|
||||
inputComponent: "target_version",
|
||||
validator: function(value) {
|
||||
if (!isTargetVersion(value)) {
|
||||
throw new Error("Value must be be 'latest', or match X.Y, or match X.Y.Z");
|
||||
}
|
||||
},
|
||||
},
|
||||
"factorio.executable_path": {
|
||||
description:
|
||||
@@ -481,6 +519,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.filePath),
|
||||
},
|
||||
"factorio.shutdown_timeout": {
|
||||
description:
|
||||
@@ -488,24 +527,27 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
"the process. Set to 0 to disable.",
|
||||
type: "number",
|
||||
initialValue: 300,
|
||||
optional: true,
|
||||
validator: validators.greaterThanEqualZero,
|
||||
},
|
||||
"factorio.game_port": {
|
||||
description: "UDP port to run game on, uses a port in host.factorio_port_range if null",
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.greaterThanEqualZero),
|
||||
},
|
||||
"factorio.host_assigned_game_port": {
|
||||
access: ["host"],
|
||||
type: "number",
|
||||
optional: true,
|
||||
// Internal value, extendedValidation not used
|
||||
},
|
||||
"factorio.rcon_port": {
|
||||
description: "TCP port to run RCON on, uses a random port if null",
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.greaterThanEqualZero),
|
||||
},
|
||||
"factorio.rcon_password": {
|
||||
credential: ["host", "controller"],
|
||||
@@ -520,6 +562,14 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
"autosave pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.",
|
||||
type: "number",
|
||||
initialValue: 5,
|
||||
dependsOn: ["factorio.settings", "factorio.settings.autosave_slots"],
|
||||
validator: function(value, config) {
|
||||
const factorioSettings = config.get("factorio.settings");
|
||||
const autosaveSlots = factorioSettings.autosave_slots;
|
||||
if (typeof autosaveSlots === "number" && value < autosaveSlots) {
|
||||
throw new Error("Value cannot be less than the number of autosave slots");
|
||||
}
|
||||
},
|
||||
},
|
||||
"factorio.mod_pack_id": {
|
||||
title: "Mod Pack",
|
||||
@@ -568,6 +618,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
],
|
||||
type: "object",
|
||||
initialValue: {}, // See create instance handler in controller.
|
||||
// TODO extendedValidation, we could check keys and types here if we wanted
|
||||
},
|
||||
"factorio.verbose_logging": {
|
||||
description: "Enable verbose logging on the Factorio server",
|
||||
@@ -610,6 +661,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
initialValue: 5,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -644,12 +696,13 @@ export class ControlConfig extends classes.Config<ControlConfigFields> {
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initialValue: 60,
|
||||
validator: validators.greaterThanEqualZero,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function validateFields(
|
||||
function validateFieldNames(
|
||||
pluginName: string,
|
||||
fields: Record<string, classes.FieldDefinition>,
|
||||
) {
|
||||
@@ -682,7 +735,7 @@ export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginW
|
||||
|
||||
const fields = pluginInfo[kind];
|
||||
if (fields) {
|
||||
validateFields(pluginInfo.name, fields);
|
||||
validateFieldNames(pluginInfo.name, fields);
|
||||
Object.assign(Config.fieldDefinitions, fields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from "path";
|
||||
|
||||
/** Combine multiple validators into a single one. */
|
||||
export function all<V, C> (
|
||||
...validators: ((value: V, config: C) => void)[]
|
||||
) {
|
||||
return function (value: V, config: C) {
|
||||
for (const validator of validators) {
|
||||
validator(value, config);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Skip validation of optional fields when null */
|
||||
export function optional<V, C> (
|
||||
validator: (value: V, config: C) => void
|
||||
) {
|
||||
return function (value: V | null, config: C) {
|
||||
if (value === null) { return; }
|
||||
validator(value, config);
|
||||
};
|
||||
}
|
||||
|
||||
/** Value must be greater than the minimum value */
|
||||
export function greaterThan(min: number) {
|
||||
return function (value: number) {
|
||||
if (value <= min) {
|
||||
throw new Error(`Value must be greater than ${min}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Value must be greater than 0 */
|
||||
export const greaterThanZero = greaterThan(0);
|
||||
|
||||
/** Value must be greater than or equal to the minimum value */
|
||||
export function greaterThanEqual(min: number) {
|
||||
return function (value: number) {
|
||||
if (value < min) {
|
||||
throw new Error(`Value must be greater than ${min}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Value must be greater than or equal to 0 */
|
||||
export const greaterThanEqualZero = greaterThanEqual(0);
|
||||
|
||||
/** Value must be an integer value */
|
||||
export function integer(value: number) {
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error("Value must be an integer");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value must be a valid file path, it does not need to exist
|
||||
* On posix this will only error for non string values, which isn't very useful
|
||||
* However, this could be expanded in the future to check system OS
|
||||
*/
|
||||
export function filePath(value: string) {
|
||||
try {
|
||||
path.resolve(value);
|
||||
} catch (err: any) {
|
||||
throw new Error(`Value must be a valid path: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,28 @@ export class ControllerConfigGetRequest {
|
||||
static Response = plainJson(ControllerConfig.jsonSchema);
|
||||
}
|
||||
|
||||
export class ControllerConfigSetRequest {
|
||||
declare ["constructor"]: typeof ControllerConfigSetRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.controller.update_config" as const;
|
||||
|
||||
constructor(
|
||||
public fields: Record<string, string | Record<string, unknown>>,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"fields": Type.Record(Type.String(), Type.Union([
|
||||
Type.String(), Type.Record(Type.String(), Type.Unknown()),
|
||||
])),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.fields);
|
||||
}
|
||||
}
|
||||
|
||||
export class ControllerConfigSetFieldRequest {
|
||||
declare ["constructor"]: typeof ControllerConfigSetFieldRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -37,6 +37,28 @@ export class HostConfigGetRequest {
|
||||
static Response = plainJson(HostConfig.jsonSchema);
|
||||
}
|
||||
|
||||
export class HostConfigSetRequest {
|
||||
declare ["constructor"]: typeof HostConfigSetRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "host" as const;
|
||||
static permission = "core.host.update_config" as const;
|
||||
|
||||
constructor(
|
||||
public fields: Record<string, string | Record<string, unknown>>,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"fields": Type.Record(Type.String(), Type.Union([
|
||||
Type.String(), Type.Record(Type.String(), Type.Unknown()),
|
||||
])),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.fields);
|
||||
}
|
||||
}
|
||||
|
||||
export class HostConfigSetFieldRequest {
|
||||
declare ["constructor"]: typeof HostConfigSetFieldRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -102,6 +102,30 @@ export class InstanceConfigGetRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceConfigSetRequest {
|
||||
declare ["constructor"]: typeof InstanceConfigSetRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.instance.update_config" as const;
|
||||
|
||||
constructor(
|
||||
public instanceId: number,
|
||||
public fields: Record<string, string | Record<string, unknown>>,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instanceId": Type.Integer(),
|
||||
"fields": Type.Record(Type.String(), Type.Union([
|
||||
Type.String(), Type.Record(Type.String(), Type.Unknown()),
|
||||
])),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.instanceId, json.fields);
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceConfigSetFieldRequest {
|
||||
declare ["constructor"]: typeof InstanceConfigSetFieldRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -23,6 +23,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.ControllerRestartRequest,
|
||||
controller.ControllerUpdateRequest,
|
||||
controller.ControllerConfigGetRequest,
|
||||
controller.ControllerConfigSetRequest,
|
||||
controller.ControllerConfigSetFieldRequest,
|
||||
controller.ControllerConfigSetPropRequest,
|
||||
controller.HostGenerateTokenRequest,
|
||||
@@ -43,6 +44,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
host.HostRestartRequest,
|
||||
host.HostUpdateRequest,
|
||||
host.HostConfigGetRequest,
|
||||
host.HostConfigSetRequest,
|
||||
host.HostConfigSetFieldRequest,
|
||||
host.HostConfigSetPropRequest,
|
||||
host.HostListRequest,
|
||||
@@ -59,6 +61,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceDetailsUpdatesEvent,
|
||||
instance.InstanceCreateRequest,
|
||||
instance.InstanceConfigGetRequest,
|
||||
instance.InstanceConfigSetRequest,
|
||||
instance.InstanceConfigSetFieldRequest,
|
||||
instance.InstanceConfigSetPropRequest,
|
||||
instance.InstanceAssignRequest,
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as lib from "@clusterio/lib";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { InputComponent, InputComponentProps } from "../BaseWebPlugin";
|
||||
import type { Control } from "../util/websocket";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -67,8 +68,7 @@ function renderInput(inputComponents: Record<string, InputComponent>, def: lib.F
|
||||
type BaseConfigTreeProps = {
|
||||
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
|
||||
retrieveConfig: () => Promise<lib.ConfigSchema>;
|
||||
setField: (field:string, value:any) => Promise<void>;
|
||||
setProp: (field:string, prop:string, value:any) => Promise<void>;
|
||||
setConfig: (fields: Record<string, string | Record<string, string>>) => Promise<void>;
|
||||
id?: number;
|
||||
onApply?: () => void;
|
||||
available?: boolean;
|
||||
@@ -157,32 +157,33 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
loading={applying}
|
||||
onClick={async () => {
|
||||
setApplying(true);
|
||||
for (let field of changedFields) {
|
||||
let value = form.getFieldValue(field);
|
||||
let request;
|
||||
const fields: Record<string, string | Record<string, string>> = {};
|
||||
for (const field of changedFields) {
|
||||
const value = form.getFieldValue(field);
|
||||
if (propsMap.has(field)) {
|
||||
let [fieldName, prop] = propsMap.get(field);
|
||||
request = props.setProp(fieldName, prop, value);
|
||||
const [fieldName, prop] = propsMap.get(field)!;
|
||||
const fieldValue = (fields[fieldName] ?? {}) as Record<string, string>;
|
||||
fields[fieldName] = fieldValue;
|
||||
try {
|
||||
fieldValue[prop] = value ? JSON.parse(value) : undefined;
|
||||
} catch (err: any) {
|
||||
setErrorFields(errors => {
|
||||
const newErrors = new Map(errors);
|
||||
newErrors.set(fieldName, err.message);
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
request = props.setField(field, value === null ? "" : String(value));
|
||||
fields[field] = value === null ? "" : String(value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await request;
|
||||
setChangedFields(changed => {
|
||||
let newChanged = new Set(changed);
|
||||
newChanged.delete(field);
|
||||
return newChanged;
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
setErrorFields(errors => {
|
||||
let newErrors = new Map(errors);
|
||||
newErrors.set(field, err.message);
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await props.setConfig(fields);
|
||||
setChangedFields(new Set());
|
||||
} catch (err: any) {
|
||||
notifyErrorHandler(err.message);
|
||||
}
|
||||
|
||||
await updateConfig();
|
||||
@@ -254,7 +255,7 @@ function computeTreeData(
|
||||
};
|
||||
|
||||
let treeData = [];
|
||||
let propsMap = new Map();
|
||||
const propsMap = new Map<string, [fieldName: string, prop: string]>();
|
||||
const groups = new Map<string, { [name: string]: lib.FieldDefinition }>();
|
||||
for (let [fieldName, def] of Object.entries(ConfigClass.fieldDefinitions)) {
|
||||
const [groupName, field] = lib.splitOn(".", fieldName);
|
||||
@@ -312,17 +313,37 @@ function computeTreeData(
|
||||
{`"${prop}"`}
|
||||
{restart && restartTip}
|
||||
</>}
|
||||
rules={[{ validator: (_, fieldValue) => {
|
||||
dependencies={def.dependsOn}
|
||||
rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => {
|
||||
if (!fieldValue.length) {
|
||||
return Promise.reject(new Error("Will be removed"));
|
||||
throw new Error("Will be removed");
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(fieldValue);
|
||||
fieldValue = JSON.parse(fieldValue);
|
||||
} catch (err) {
|
||||
return Promise.reject(new Error("Must be valid json"));
|
||||
throw new Error("Must be valid json");
|
||||
}
|
||||
return Promise.resolve();
|
||||
}}]}
|
||||
|
||||
if (!def.validator) { return; }
|
||||
config.stageProp(fieldName, prop, fieldValue);
|
||||
|
||||
for (const dependsOn of def.dependsOn ?? []) {
|
||||
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
|
||||
try {
|
||||
if (propsMap.has(dependsOn)) {
|
||||
const [name, otherProp] = propsMap.get(dependsOn)!;
|
||||
config.stageProp(name, otherProp, JSON.parse(getFieldValue(dependsOn)));
|
||||
} else if (dependsOnDef.type !== "object") {
|
||||
config.stage(dependsOn, getFieldValue(dependsOn));
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch field: ${dependsOn}`);
|
||||
}
|
||||
}
|
||||
|
||||
def.validator(fieldValue, config.staging);
|
||||
}})]}
|
||||
>
|
||||
<Input className="json-input" disabled={!canWrite} />
|
||||
</Form.Item>,
|
||||
@@ -390,7 +411,30 @@ function computeTreeData(
|
||||
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
|
||||
help={errorFields.get(fieldName)}
|
||||
tooltip={def.description}
|
||||
dependencies={def.dependsOn}
|
||||
valuePropName={def.type === "boolean" ? "checked" : "value"}
|
||||
rules={[({getFieldValue}) => ({
|
||||
validator: async (_, fieldValue) => {
|
||||
if (!def.validator) { return; }
|
||||
config.stage(fieldName, fieldValue);
|
||||
|
||||
for (const dependsOn of def.dependsOn ?? []) {
|
||||
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
|
||||
try {
|
||||
if (propsMap.has(dependsOn)) {
|
||||
const [name, prop] = propsMap.get(dependsOn)!;
|
||||
config.stageProp(name, prop, JSON.parse(getFieldValue(dependsOn)));
|
||||
} else if (dependsOnDef.type !== "object") {
|
||||
config.stage(dependsOn, getFieldValue(dependsOn));
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch field: ${dependsOn}`);
|
||||
}
|
||||
}
|
||||
|
||||
def.validator(fieldValue, config.staging);
|
||||
},
|
||||
})]}
|
||||
>
|
||||
{renderInput(control.inputComponents, def, !canWrite)}
|
||||
</Form.Item>;
|
||||
|
||||
@@ -13,30 +13,13 @@ export default function ControllerConfigTree() {
|
||||
return await control.send(new lib.ControllerConfigGetRequest());
|
||||
}
|
||||
|
||||
async function setField(field: string, value: any) {
|
||||
await control.send(new lib.ControllerConfigSetFieldRequest(
|
||||
field,
|
||||
String(value),
|
||||
));
|
||||
}
|
||||
|
||||
async function setProp(field: string, prop: string, value: any) {
|
||||
if (value) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
await control.send(new lib.ControllerConfigSetPropRequest(field, prop, value));
|
||||
async function setConfig(fields: Record<string, string | Record<string, string>>) {
|
||||
await control.send(new lib.ControllerConfigSetRequest(fields));
|
||||
}
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={lib.ControllerConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setField={setField}
|
||||
setProp={setProp}
|
||||
setConfig={setConfig}
|
||||
/>;
|
||||
}
|
||||
@@ -13,28 +13,14 @@ export default function HostConfigTree(props: { id: number, available: boolean }
|
||||
return await control.sendTo({ hostId: props.id }, new lib.HostConfigGetRequest());
|
||||
}
|
||||
|
||||
async function setField(field: string, value: any) {
|
||||
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetFieldRequest(field, value));
|
||||
}
|
||||
|
||||
async function setProp(field: string, prop: string, value: any) {
|
||||
if (value) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetPropRequest(field, prop, value));
|
||||
async function setConfig(fields: Record<string, string | Record<string, string>>) {
|
||||
await control.send(new lib.HostConfigSetRequest(fields));
|
||||
}
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={lib.HostConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setField={setField}
|
||||
setProp={setProp}
|
||||
setConfig={setConfig}
|
||||
id={props.id}
|
||||
available={props.available}
|
||||
/>;
|
||||
|
||||
@@ -17,28 +17,14 @@ export default function InstanceConfigTree(props: InstanceConfigTreeProps) {
|
||||
return await control.send(new lib.InstanceConfigGetRequest(props.id));
|
||||
}
|
||||
|
||||
async function setField(field: string, value: any) {
|
||||
await control.send(new lib.InstanceConfigSetFieldRequest(props.id, field, value));
|
||||
}
|
||||
|
||||
async function setProp(field: string, prop: string, value: any) {
|
||||
if (value) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
await control.send(new lib.InstanceConfigSetPropRequest(props.id, field, prop, value));
|
||||
async function setConfig(fields: Record<string, string | Record<string, string>>) {
|
||||
await control.send(new lib.InstanceConfigSetRequest(props.id, fields));
|
||||
}
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={lib.InstanceConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setField={setField}
|
||||
setProp={setProp}
|
||||
setConfig={setConfig}
|
||||
id={props.id}
|
||||
/>;
|
||||
}
|
||||
@@ -90,7 +90,6 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
for (let entry of this.players.values()) {
|
||||
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
|
||||
let verifyCode = await generateCode(this.controller.config.get("player_auth.code_length"));
|
||||
let secret = Buffer.from(this.controller.config.get("controller.auth_secret"), "base64");
|
||||
let verifyToken = jwt.sign(
|
||||
{
|
||||
aud: "player_auth.verify_code",
|
||||
@@ -98,7 +97,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
verify_code: verifyCode,
|
||||
player_code: playerCode,
|
||||
},
|
||||
secret
|
||||
this.controller.authSecret
|
||||
);
|
||||
|
||||
res.send({ verify_code: verifyCode, verify_token: verifyToken });
|
||||
@@ -133,9 +132,11 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
let secret = Buffer.from(this.controller.config.get("controller.auth_secret"), "base64");
|
||||
try {
|
||||
let payload = jwt.verify(verifyToken, secret, { audience: "player_auth.verify_code" }) as jwt.JwtPayload;
|
||||
let payload = jwt.verify(
|
||||
verifyToken, this.controller.authSecret, { audience: "player_auth.verify_code" }
|
||||
) as jwt.JwtPayload;
|
||||
|
||||
if (payload.verify_code !== verifyCode) {
|
||||
throw new Error("invalid verify_code");
|
||||
}
|
||||
|
||||
+471
-136
@@ -1,5 +1,6 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { optional } = require("@clusterio/lib/dist/node/src/config/validators");
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const assert = require("assert").strict;
|
||||
@@ -10,6 +11,7 @@ describe("lib/config/classes", function() {
|
||||
describe("Config", function() {
|
||||
class TestConfig extends lib.Config {
|
||||
static defaultAccess = ["local", "remote"];
|
||||
static validatorCalls = [];
|
||||
|
||||
static migrations(config) {
|
||||
if (config.hasOwnProperty("test.migration")) {
|
||||
@@ -41,9 +43,90 @@ describe("lib/config/classes", function() {
|
||||
"test.priv": { access: ["local"], type: "string", optional: true },
|
||||
"test.cred": { credential: ["local"] },
|
||||
"test.rdo": { readonly: ["local"] },
|
||||
"test.base": { type: "number", initialValue: 1 },
|
||||
"test.dependent": {
|
||||
type: "number",
|
||||
initialValue: 1,
|
||||
dependsOn: ["test.base", "test.objDependent"],
|
||||
validator(value, config) {
|
||||
TestConfig.validatorCalls.push(["test.dependent", value]);
|
||||
if (value < config.get("test.base")) {
|
||||
throw new Error("dependent must be >= base");
|
||||
}
|
||||
const dep = config.get("test.objDependent")?.base;
|
||||
if (dep === 0) {
|
||||
throw new Error("objDependent cannot be 0");
|
||||
}
|
||||
},
|
||||
},
|
||||
"test.objDependent": {
|
||||
type: "object",
|
||||
initialValue: {},
|
||||
dependsOn: ["test.base"],
|
||||
validator(value, config) {
|
||||
TestConfig.validatorCalls.push(["test.objDependent", value]);
|
||||
if (value.base && value.base !== config.get("test.base")) {
|
||||
throw new Error("base prop must match test.base");
|
||||
}
|
||||
},
|
||||
},
|
||||
"test.validated": {
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator(value) {
|
||||
TestConfig.validatorCalls.push(["test.validated", value]);
|
||||
if (value !== null && value < 0) {
|
||||
throw new Error("must be positive");
|
||||
}
|
||||
},
|
||||
},
|
||||
"test.objValidated": {
|
||||
type: "object",
|
||||
initialValue: {},
|
||||
validator(value) {
|
||||
TestConfig.validatorCalls.push(["test.objValidated", value]);
|
||||
if (value && value.bad !== undefined) {
|
||||
throw new Error("bad key");
|
||||
}
|
||||
},
|
||||
},
|
||||
"test.badValidator": {
|
||||
type: "string",
|
||||
optional: true,
|
||||
dependsOn: ["test.base"],
|
||||
validator(value, config) {
|
||||
if (config.get("test.base") < 1) {
|
||||
return;
|
||||
}
|
||||
if (value === "set") {
|
||||
config.set("alpha.foo", "BAD");
|
||||
} else if (value === "setProp") {
|
||||
config.setProp("beta.bar", "baz", "BAD");
|
||||
} else if (value === "stage") {
|
||||
config.stage("alpha.foo", "BAD");
|
||||
} else if (value === "stageProp") {
|
||||
config.stageProp("beta.bar", "baz", "BAD");
|
||||
} else if (value === "commitStaging") {
|
||||
config.commitStaging();
|
||||
} else if (value === "revertStaging") {
|
||||
config.revertStaging();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const testConfigDefault = Object.fromEntries(
|
||||
Object.entries(TestConfig.fieldDefinitions).map(
|
||||
([fieldName, fieldDef]) => ([
|
||||
fieldName,
|
||||
typeof fieldDef.initialValue === "function"
|
||||
? fieldDef.initialValue()
|
||||
: (fieldDef.initialValue ?? null),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
describe("constructor", function() {
|
||||
it("should construct an instance", function() {
|
||||
let config = new TestConfig("local");
|
||||
@@ -80,41 +163,37 @@ describe("lib/config/classes", function() {
|
||||
const testInstance = new TestConfig("local", undefined, "filepath");
|
||||
assert.equal(testInstance.filepath, "filepath");
|
||||
});
|
||||
it("should throw if a required field has no initialValue", function() {
|
||||
this.skip(); // TODO known bug that was discovered but of of scope for #856
|
||||
class BrokenConfig extends lib.Config {
|
||||
static fieldDefinitions = {
|
||||
"test.required": { type: "string" },
|
||||
};
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => new BrokenConfig("local"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toJSON()", function() {
|
||||
it("should serialize a basic config", function() {
|
||||
let testInstance = new TestConfig("local");
|
||||
assert.deepEqual(testInstance.toJSON(), {
|
||||
"alpha.foo": null,
|
||||
"beta.bar": {},
|
||||
"test.enum": "b",
|
||||
"test.test": null,
|
||||
"test.func": 42,
|
||||
"test.bool": false,
|
||||
"test.json": {},
|
||||
"test.rest": {},
|
||||
"test.priv": null,
|
||||
"test.cred": null,
|
||||
"test.rdo": null,
|
||||
});
|
||||
const testInstance = new TestConfig("local");
|
||||
assert.deepEqual(testInstance.toJSON(), testConfigDefault);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".toRemote()", function() {
|
||||
it("should leave out inaccessible fields", function() {
|
||||
let testInstance = new TestConfig("local");
|
||||
assert.deepEqual(testInstance.toRemote("remote"), {
|
||||
"alpha.foo": null,
|
||||
"beta.bar": {},
|
||||
"test.enum": "b",
|
||||
"test.test": null,
|
||||
"test.func": 42,
|
||||
"test.bool": false,
|
||||
"test.json": {},
|
||||
"test.rest": {},
|
||||
"test.rdo": null,
|
||||
});
|
||||
const testInstance = new TestConfig("local");
|
||||
const { "test.cred": _cred, "test.priv": _priv, ...expected } = testConfigDefault;
|
||||
assert.deepEqual(testInstance.toRemote("remote"), expected);
|
||||
});
|
||||
it("should filter fields when filter argument is provided", function() {
|
||||
const testInstance = new TestConfig("local");
|
||||
const filtered = testInstance.toRemote("local", ["alpha.foo"]);
|
||||
assert.deepEqual(filtered, { "alpha.foo": null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,6 +244,7 @@ describe("lib/config/classes", function() {
|
||||
|
||||
it("should preserve unknown fields when serialized back", function() {
|
||||
let testFields = {
|
||||
...testConfigDefault,
|
||||
"extra.blah": true,
|
||||
"extra.spam": "foobar",
|
||||
"alpha.foo": "true",
|
||||
@@ -175,10 +255,6 @@ describe("lib/config/classes", function() {
|
||||
"test.bool": false,
|
||||
"test.json": {},
|
||||
"test.rest": {},
|
||||
"test.priv": null,
|
||||
"test.cred": null,
|
||||
"test.rdo": null,
|
||||
"test.alpha": null,
|
||||
"test.beta": "decay",
|
||||
"test.gamma": 99,
|
||||
};
|
||||
@@ -245,6 +321,7 @@ describe("lib/config/classes", function() {
|
||||
});
|
||||
it("should preserve unknown fields", async function() {
|
||||
const testFields = {
|
||||
...testConfigDefault,
|
||||
"extra.blah": true,
|
||||
"extra.spam": "foobar",
|
||||
"alpha.foo": "true",
|
||||
@@ -255,10 +332,6 @@ describe("lib/config/classes", function() {
|
||||
"test.bool": false,
|
||||
"test.json": {},
|
||||
"test.rest": {},
|
||||
"test.priv": null,
|
||||
"test.cred": null,
|
||||
"test.rdo": null,
|
||||
"test.alpha": null,
|
||||
"test.beta": "decay",
|
||||
"test.gamma": 99,
|
||||
};
|
||||
@@ -347,19 +420,10 @@ describe("lib/config/classes", function() {
|
||||
"beta.bar": { value: 30 },
|
||||
}, false);
|
||||
assert.deepEqual(testInstance.toJSON(), {
|
||||
...testConfigDefault,
|
||||
"extra.blah": true,
|
||||
"extra.spam": "baz",
|
||||
"alpha.foo": null,
|
||||
"beta.bar": { value: 30 },
|
||||
"test.bool": false,
|
||||
"test.enum": "b",
|
||||
"test.func": 42,
|
||||
"test.json": {},
|
||||
"test.rest": {},
|
||||
"test.priv": null,
|
||||
"test.cred": null,
|
||||
"test.rdo": null,
|
||||
"test.test": null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -571,90 +635,76 @@ describe("lib/config/classes", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe(".set()", function() {
|
||||
let testInstance;
|
||||
beforeEach(function() {
|
||||
testInstance = new TestConfig("local", {
|
||||
"test.enum": "a",
|
||||
"test.test": "blah",
|
||||
"test.func": 27,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {typeof lib.Config.prototype.set} set
|
||||
* @param {typeof lib.Config.prototype.get} get
|
||||
* @param {(config: lib.Config) => void} replaceConfig
|
||||
*/
|
||||
function testAccessAndCoerce(set, get, replaceConfig) {
|
||||
it("should set the value", function() {
|
||||
testInstance.set("alpha.foo", "new value");
|
||||
assert.equal(testInstance.get("alpha.foo"), "new value");
|
||||
set("alpha.foo", "new value");
|
||||
assert.equal(get("alpha.foo"), "new value");
|
||||
});
|
||||
|
||||
it("should throw if field does not exist", function() {
|
||||
assert.throws(
|
||||
() => testInstance.set("test.bar", 1),
|
||||
() => set("test.bar", 1),
|
||||
new lib.InvalidField("No field named 'test.bar'")
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if field is remotely inaccessible", function() {
|
||||
assert.throws(
|
||||
() => testInstance.set("test.priv", "bad", "remote"),
|
||||
() => set("test.priv", "bad", "remote"),
|
||||
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
|
||||
);
|
||||
});
|
||||
it("should throw if field is locally inaccessible", function() {
|
||||
testInstance = new TestConfig("remote");
|
||||
replaceConfig(new TestConfig("remote"));
|
||||
assert.throws(
|
||||
() => testInstance.set("test.priv", "bad", "local"),
|
||||
() => set("test.priv", "bad", "local"),
|
||||
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if field is not in enum", function() {
|
||||
assert.throws(
|
||||
() => testInstance.set("test.enum", "bar"),
|
||||
() => set("test.enum", "bar"),
|
||||
new Error("Expected one of [a, b, c], not bar")
|
||||
);
|
||||
});
|
||||
|
||||
it("should work if field is in enum", function() {
|
||||
testInstance.set("test.enum", "c");
|
||||
assert.equal(testInstance.get("test.enum"), "c");
|
||||
set("test.enum", "c");
|
||||
assert.equal(get("test.enum"), "c");
|
||||
});
|
||||
|
||||
it("should throw if field is not optional", function() {
|
||||
assert.throws(
|
||||
() => testInstance.set("test.func", null),
|
||||
() => set("test.func", null),
|
||||
new Error("Field test.func cannot be null")
|
||||
);
|
||||
});
|
||||
|
||||
it("should work if field is optional", function() {
|
||||
testInstance.set("test.test", "spam");
|
||||
assert.equal(testInstance.get("test.test"), "spam");
|
||||
set("test.test", "spam");
|
||||
assert.equal(get("test.test"), "spam");
|
||||
});
|
||||
|
||||
it("should throw if field is of wrong type", function() {
|
||||
assert.throws(
|
||||
() => testInstance.set("test.test", 1),
|
||||
() => set("test.test", 1),
|
||||
new Error("Expected type of test.test to be string, not number")
|
||||
);
|
||||
});
|
||||
|
||||
it("should treat empty string as null", function() {
|
||||
testInstance.set("test.test", "");
|
||||
assert.equal(testInstance.get("test.test"), null);
|
||||
set("test.test", "");
|
||||
assert.equal(get("test.test"), null);
|
||||
});
|
||||
|
||||
it("should auto convert string to boolean if possible", function() {
|
||||
testInstance.set("test.bool", "true");
|
||||
assert.equal(testInstance.get("test.bool"), true);
|
||||
testInstance.set("test.bool", "false");
|
||||
assert.equal(testInstance.get("test.bool"), false);
|
||||
set("test.bool", "true");
|
||||
assert.equal(get("test.bool"), true);
|
||||
set("test.bool", "false");
|
||||
assert.equal(get("test.bool"), false);
|
||||
|
||||
assert.throws(
|
||||
() => testInstance.set("test.bool", "blah"),
|
||||
() => set("test.bool", "blah"),
|
||||
new lib.InvalidValue("Expected type of test.bool to be boolean, not string")
|
||||
);
|
||||
});
|
||||
|
||||
it("should auto convert string to number if possible", function() {
|
||||
for (let s of [
|
||||
"1",
|
||||
@@ -664,19 +714,18 @@ describe("lib/config/classes", function() {
|
||||
"100.0001",
|
||||
"Infinity",
|
||||
]) {
|
||||
testInstance.set("test.func", s);
|
||||
assert.equal(testInstance.get("test.func"), Number.parseFloat(s));
|
||||
set("test.func", s);
|
||||
assert.equal(get("test.func"), Number.parseFloat(s));
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => testInstance.set("test.func", "blah"),
|
||||
() => set("test.func", "blah"),
|
||||
new lib.InvalidValue("Expected type of test.func to be number, not string")
|
||||
);
|
||||
});
|
||||
|
||||
it("should auto convert string to object", function() {
|
||||
testInstance.set("test.json", '{"json": true}');
|
||||
assert.deepEqual(testInstance.get("test.json"), { json: true });
|
||||
set("test.json", '{"json": true}');
|
||||
assert.deepEqual(get("test.json"), { json: true });
|
||||
|
||||
let errMsg;
|
||||
try {
|
||||
@@ -685,10 +734,28 @@ describe("lib/config/classes", function() {
|
||||
errMsg = err.message;
|
||||
}
|
||||
assert.throws(
|
||||
() => testInstance.set("test.json", "blah"),
|
||||
() => set("test.json", "blah"),
|
||||
new lib.InvalidValue(`Error parsing value for test.json: ${errMsg}`)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe(".set()", function() {
|
||||
let testInstance;
|
||||
beforeEach(function() {
|
||||
TestConfig.validatorCalls = [];
|
||||
testInstance = new TestConfig("local", {
|
||||
"test.enum": "a",
|
||||
"test.test": "blah",
|
||||
"test.func": 27,
|
||||
});
|
||||
});
|
||||
|
||||
testAccessAndCoerce(
|
||||
(...args) => testInstance.set(...args),
|
||||
(...args) => testInstance.get(...args),
|
||||
config => { testInstance = config; },
|
||||
);
|
||||
it("should notify fieldChanged listeners", function() {
|
||||
const changes = [];
|
||||
testInstance.on("fieldChanged", (name, value, prev) => changes.push([name, value, prev]));
|
||||
@@ -709,11 +776,97 @@ describe("lib/config/classes", function() {
|
||||
testInstance.set("test.enum", "b");
|
||||
assert.deepEqual(testInstance.restartRequired, true, "restart required not set");
|
||||
});
|
||||
it("should run field validator successfully", function() {
|
||||
testInstance.set("test.validated", 5);
|
||||
assert.equal(testInstance.get("test.validated"), 5);
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
["test.validated", 5], // set test.validated
|
||||
]);
|
||||
});
|
||||
it("should throw if validator fails", function() {
|
||||
assert.throws(
|
||||
() => testInstance.set("test.validated", -1),
|
||||
new lib.InvalidValue("Failed validation of test.validated: must be positive")
|
||||
);
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
["test.validated", -1], // set test.validated
|
||||
]);
|
||||
});
|
||||
it("should validate dependent fields", function() {
|
||||
testInstance.set("test.dependent", 10);
|
||||
testInstance.set("test.base", 5);
|
||||
assert.equal(testInstance.get("test.dependent"), 10);
|
||||
assert.equal(testInstance.get("test.base"), 5);
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
["test.dependent", 10], // set test.dependent
|
||||
["test.dependent", 10], // set test.base
|
||||
["test.objDependent", {}], // set test.base
|
||||
]);
|
||||
});
|
||||
it("should throw if dependent validation fails", function() {
|
||||
assert.throws(
|
||||
() => testInstance.set("test.base", 5),
|
||||
new lib.InvalidValue("Failed validation of dependent test.dependent: dependent must be >= base")
|
||||
);
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
["test.dependent", 1], // set test.base
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {typeof lib.Config.prototype.set} set
|
||||
* @param {typeof lib.Config.prototype.setProp} setProp
|
||||
* @param {typeof lib.Config.prototype.get} get
|
||||
* @param {(config: lib.Config) => void} replaceConfig
|
||||
*/
|
||||
function testAccessAndCoerceProp(set, setProp, get, replaceConfig) {
|
||||
it("should throw if field does not exist", function() {
|
||||
assert.throws(
|
||||
() => setProp("test.bar", 1),
|
||||
new lib.InvalidField("No field named 'test.bar'")
|
||||
);
|
||||
});
|
||||
it("should throw if field is remotely inaccessible", function() {
|
||||
assert.throws(
|
||||
() => setProp("test.priv", "prop", "bad", "remote"),
|
||||
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
|
||||
);
|
||||
});
|
||||
it("should throw if field is locally inaccessible", function() {
|
||||
replaceConfig(new TestConfig("remote"));
|
||||
assert.throws(
|
||||
() => setProp("test.priv", "prop", "bad", "local"),
|
||||
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
|
||||
);
|
||||
});
|
||||
it("should throw if field is not an object", function() {
|
||||
assert.throws(
|
||||
() => setProp("test.enum", "prop", "a"),
|
||||
new lib.InvalidField("Cannot set property on non-object field 'test.enum'")
|
||||
);
|
||||
});
|
||||
it("should work if field is an object", function() {
|
||||
set("test.json", { prev: 32, test: false });
|
||||
setProp("test.json", "test", true);
|
||||
assert.deepEqual(get("test.json"), { prev: 32, test: true });
|
||||
});
|
||||
it("should handle field being null", function() {
|
||||
set("test.json", null);
|
||||
setProp("test.json", "test", true);
|
||||
assert.deepEqual(get("test.json"), { test: true });
|
||||
});
|
||||
it("should unset field if passed undefined", function() {
|
||||
set("test.json", { test: true, extra: "yes" });
|
||||
setProp("test.json", "extra", undefined);
|
||||
assert.deepEqual(get("test.json"), { test: true });
|
||||
});
|
||||
}
|
||||
|
||||
describe(".setProp()", function() {
|
||||
let testInstance;
|
||||
beforeEach(function() {
|
||||
TestConfig.validatorCalls = [];
|
||||
testInstance = new TestConfig("local", {
|
||||
"test.enum": "a",
|
||||
"test.test": "blah",
|
||||
@@ -721,51 +874,12 @@ describe("lib/config/classes", function() {
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if field does not exist", function() {
|
||||
assert.throws(
|
||||
() => testInstance.setProp("test.bar", 1),
|
||||
new lib.InvalidField("No field named 'test.bar'")
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if field is remotely inaccessible", function() {
|
||||
assert.throws(
|
||||
() => testInstance.setProp("test.priv", "prop", "bad", "remote"),
|
||||
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
|
||||
);
|
||||
});
|
||||
it("should throw if field is locally inaccessible", function() {
|
||||
testInstance = new TestConfig("remote");
|
||||
assert.throws(
|
||||
() => testInstance.setProp("test.priv", "prop", "bad", "local"),
|
||||
new lib.InvalidAccess("Field 'test.priv' is not accessible from remote")
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if field is not an object", function() {
|
||||
assert.throws(
|
||||
() => testInstance.setProp("test.enum", "prop", "a"),
|
||||
new lib.InvalidField("Cannot set property on non-object field 'test.enum'")
|
||||
);
|
||||
});
|
||||
|
||||
it("should work if field is an object", function() {
|
||||
testInstance.set("test.json", { prev: 32, test: false });
|
||||
testInstance.setProp("test.json", "test", true);
|
||||
assert.deepEqual(testInstance.get("test.json"), { prev: 32, test: true });
|
||||
});
|
||||
|
||||
it("should handle field being null", function() {
|
||||
testInstance.set("test.json", null);
|
||||
testInstance.setProp("test.json", "test", true);
|
||||
assert.deepEqual(testInstance.get("test.json"), { test: true });
|
||||
});
|
||||
|
||||
it("should unset field if passed undefined", function() {
|
||||
testInstance.set("test.json", { test: true, extra: "yes" });
|
||||
testInstance.setProp("test.json", "extra", undefined);
|
||||
assert.deepEqual(testInstance.get("test.json"), { test: true });
|
||||
});
|
||||
testAccessAndCoerceProp(
|
||||
(...args) => testInstance.set(...args),
|
||||
(...args) => testInstance.setProp(...args),
|
||||
(...args) => testInstance.get(...args),
|
||||
config => { testInstance = config; },
|
||||
);
|
||||
it("should notify fieldChanged listeners", function() {
|
||||
const changes = [];
|
||||
testInstance.on("fieldChanged", (name, value, prev) => changes.push([name, value, prev]));
|
||||
@@ -786,6 +900,227 @@ describe("lib/config/classes", function() {
|
||||
testInstance.setProp("test.rest", "test", true);
|
||||
assert.deepEqual(testInstance.restartRequired, true, "restart required not set");
|
||||
});
|
||||
it("should run validator when setting object property", function() {
|
||||
testInstance.setProp("test.objValidated", "good", true);
|
||||
assert.deepEqual(testInstance.get("test.objValidated"), { good: true });
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
["test.objValidated", { good: true }], // setProp test.objValidated good
|
||||
]);
|
||||
});
|
||||
it("should throw if object validator fails", function() {
|
||||
assert.throws(
|
||||
() => testInstance.setProp("test.objValidated", "bad", true),
|
||||
new lib.InvalidValue("Failed validation of test.objValidated: bad key")
|
||||
);
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
["test.objValidated", { bad: true }], // setProp test.objValidated bad
|
||||
]);
|
||||
});
|
||||
it("should validate dependent fields", function() {
|
||||
testInstance.setProp("test.objDependent", "base", 1);
|
||||
assert.deepEqual(testInstance.get("test.objDependent"), { base: 1 });
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
["test.objDependent", { base: 1 }], // setProp test.objDependent base
|
||||
["test.dependent", 1], // setProp test.objDependent base
|
||||
]);
|
||||
});
|
||||
it("should throw if dependent validation fails", function() {
|
||||
testInstance.set("test.base", 0);
|
||||
assert.throws(
|
||||
() => testInstance.setProp("test.objDependent", "base", 0),
|
||||
new lib.InvalidValue("Failed validation of dependent test.dependent: objDependent cannot be 0")
|
||||
);
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
["test.dependent", 1], // set test.base
|
||||
["test.objDependent", {}], // set test.base
|
||||
["test.objDependent", { base: 0 }], // setProp test.objDependent base
|
||||
["test.dependent", 1], // setProp test.objDependent base
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get .staging", function() {
|
||||
it("should create staging config and reuse it", function() {
|
||||
const testInstance = new TestConfig("local");
|
||||
|
||||
const first = testInstance.staging;
|
||||
const second = testInstance.staging;
|
||||
|
||||
assert.equal(first, second);
|
||||
});
|
||||
it("should return new instance if invalided", function() {
|
||||
const testInstance = new TestConfig("local");
|
||||
|
||||
const first = testInstance.staging;
|
||||
testInstance.set("alpha.foo", "value");
|
||||
const second = testInstance.staging;
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".stage()", function() {
|
||||
let testInstance;
|
||||
beforeEach(function() {
|
||||
testInstance = new TestConfig("local");
|
||||
});
|
||||
|
||||
it("should stage a value without committing", function() {
|
||||
testInstance.stage("alpha.foo", "value");
|
||||
|
||||
assert.equal(testInstance.get("alpha.foo"), null);
|
||||
assert.equal(testInstance.staging.get("alpha.foo"), "value");
|
||||
});
|
||||
testAccessAndCoerce(
|
||||
(...args) => testInstance.stage(...args),
|
||||
(...args) => testInstance.staging.get(...args),
|
||||
config => { testInstance = config; },
|
||||
);
|
||||
});
|
||||
|
||||
describe(".stageProp()", function() {
|
||||
let testInstance;
|
||||
beforeEach(function() {
|
||||
testInstance = new TestConfig("local");
|
||||
});
|
||||
|
||||
testAccessAndCoerceProp(
|
||||
(...args) => testInstance.stage(...args),
|
||||
(...args) => testInstance.stageProp(...args),
|
||||
(...args) => testInstance.staging.get(...args),
|
||||
config => { testInstance = config; },
|
||||
);
|
||||
it("should stage object property change", function() {
|
||||
testInstance.stageProp("test.json", "prop", true);
|
||||
|
||||
assert.deepEqual(testInstance.get("test.json"), {});
|
||||
assert.deepEqual(testInstance.staging.get("test.json"), { prop: true });
|
||||
});
|
||||
it("should delete property when value undefined", function() {
|
||||
testInstance.set("test.json", { a: 1 });
|
||||
|
||||
testInstance.stageProp("test.json", "a", undefined);
|
||||
|
||||
assert.deepEqual(testInstance.staging.get("test.json"), {});
|
||||
assert.deepEqual(testInstance.get("test.json"), { a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe(".commitStaging()", function() {
|
||||
let testInstance;
|
||||
beforeEach(function() {
|
||||
TestConfig.validatorCalls = [];
|
||||
testInstance = new TestConfig("local");
|
||||
});
|
||||
|
||||
it("should do nothing if staging does not exist", function() {
|
||||
testInstance.commitStaging();
|
||||
assert.equal(testInstance.get("alpha.foo"), null);
|
||||
});
|
||||
it("should commit staged values", function() {
|
||||
testInstance.stage("alpha.foo", "value");
|
||||
|
||||
assert.equal(testInstance.get("alpha.foo"), null);
|
||||
testInstance.commitStaging();
|
||||
assert.equal(testInstance.get("alpha.foo"), "value");
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
// commitStaging, all values are checked once
|
||||
["test.dependent", 1],
|
||||
["test.objDependent", {}],
|
||||
["test.validated", null],
|
||||
["test.objValidated", {}],
|
||||
]);
|
||||
});
|
||||
it("should throw if staged validation fails", function() {
|
||||
testInstance.stage("test.validated", -1);
|
||||
|
||||
assert.throws(
|
||||
() => testInstance.commitStaging(),
|
||||
new lib.InvalidValue("Failed validation of test.validated: must be positive")
|
||||
);
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
// commitStaging, all values are checked once
|
||||
["test.dependent", 1],
|
||||
["test.objDependent", {}],
|
||||
["test.validated", -1], // throws, so no fourth item
|
||||
]);
|
||||
});
|
||||
it("should commit stages values with dependent validation", function() {
|
||||
assert.throws(() => testInstance.set("test.base", 10));
|
||||
testInstance.stage("test.base", 10);
|
||||
testInstance.stage("test.dependent", 15);
|
||||
// The above ordering would error if set was used
|
||||
testInstance.commitStaging();
|
||||
assert.equal(testInstance.get("test.base"), 10);
|
||||
assert.equal(testInstance.get("test.dependent"), 15);
|
||||
assert.deepEqual(TestConfig.validatorCalls, [
|
||||
["test.dependent", 1], // set test.base
|
||||
// commitStaging, all values are checked once
|
||||
["test.dependent", 15],
|
||||
["test.objDependent", {}],
|
||||
["test.validated", null],
|
||||
["test.objValidated", {}],
|
||||
]);
|
||||
});
|
||||
it("should notify fieldChanged listeners", function() {
|
||||
const changes = [];
|
||||
testInstance.on("fieldChanged", (name, value, prev) => changes.push([name, value, prev]));
|
||||
|
||||
testInstance.stage("alpha.foo", "foo");
|
||||
testInstance.stage("test.test", "bar");
|
||||
testInstance.commitStaging();
|
||||
|
||||
assert.deepEqual(changes, [
|
||||
["alpha.foo", "foo", null],
|
||||
["test.test", "bar", null],
|
||||
], "fieldChanged emitted incorrectly");
|
||||
});
|
||||
it("should set the dirty flag", function() {
|
||||
testInstance.stage("test.test", "foo");
|
||||
testInstance.commitStaging();
|
||||
assert.deepEqual(testInstance.dirty, true, "dirty flag not set");
|
||||
});
|
||||
it("should set the requires restart flag", function() {
|
||||
testInstance.stage("test.test", "foo");
|
||||
testInstance.commitStaging();
|
||||
assert.deepEqual(testInstance.restartRequired, false, "restart required when no restart field changed");
|
||||
testInstance.stage("test.enum", "a");
|
||||
testInstance.commitStaging();
|
||||
assert.deepEqual(testInstance.restartRequired, true, "restart required not set");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".revertStaging()", function() {
|
||||
it("should discard staged changes", function() {
|
||||
const testInstance = new TestConfig("local");
|
||||
testInstance.stage("alpha.foo", "value");
|
||||
testInstance.revertStaging();
|
||||
assert.equal(testInstance.staging.get("alpha.foo"), null);
|
||||
assert.equal(testInstance.get("alpha.foo"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("is readonly in validators", function() {
|
||||
for (const method of [
|
||||
"set", "setProp", "stage", "stageProp", "commitStaging", "revertStaging",
|
||||
]) {
|
||||
it(`${method} should throw if called inside a validator`, function() {
|
||||
const testInstance = new TestConfig("local");
|
||||
assert.throws(
|
||||
() => testInstance.set("test.badValidator", method),
|
||||
/config is readonly and cannot have values changed/
|
||||
);
|
||||
});
|
||||
it(`${method} should throw if called inside a dependent validator`, function() {
|
||||
const testInstance = new TestConfig("local");
|
||||
testInstance.set("test.base", 0);
|
||||
testInstance.set("test.badValidator", method);
|
||||
assert.throws(
|
||||
() => testInstance.set("test.base", 1),
|
||||
/config is readonly and cannot have values changed/
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ const assert = require("assert").strict;
|
||||
|
||||
// Each migration test should indicate which version introduced the migration
|
||||
|
||||
describe("lib/config/migrations", function() {
|
||||
describe("lib/config/definitions/migrations", function() {
|
||||
describe("Controller Config", function() {
|
||||
it("should migrate 'external_address' to 'public_url'", function() { // Alpha 19
|
||||
const config = lib.ControllerConfig.fromJSON({
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const lib = require("@clusterio/lib");
|
||||
const validators = lib.Config.validators;
|
||||
const { ControllerConfig, HostConfig, InstanceConfig, ControlConfig } = lib;
|
||||
|
||||
describe("lib/config/validators", function() {
|
||||
describe("all", function() {
|
||||
it("should call all validators in order", function() {
|
||||
let calls = [];
|
||||
|
||||
function v1(value, config) {
|
||||
calls.push("v1");
|
||||
}
|
||||
function v2(value, config) {
|
||||
calls.push("v2");
|
||||
}
|
||||
|
||||
const combined = validators.all(v1, v2);
|
||||
combined("value", {});
|
||||
|
||||
assert.deepEqual(calls, ["v1", "v2"], "validators were not called in order");
|
||||
});
|
||||
|
||||
it("should propagate errors from validators", function() {
|
||||
function v1() {}
|
||||
function v2() {
|
||||
throw new Error("failure");
|
||||
}
|
||||
|
||||
const combined = validators.all(v1, v2);
|
||||
|
||||
assert.throws(
|
||||
() => combined("value", {}),
|
||||
/failure/,
|
||||
"error from validator was not propagated"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional", function() {
|
||||
it("should skip validation when value is null", function() {
|
||||
let called = false;
|
||||
|
||||
function validator() {
|
||||
called = true;
|
||||
}
|
||||
|
||||
const wrapped = validators.optional(validator);
|
||||
wrapped(null, {});
|
||||
|
||||
assert.equal(called, false, "validator should not have been called");
|
||||
});
|
||||
|
||||
it("should run validator when value is not null", function() {
|
||||
let called = false;
|
||||
|
||||
function validator() {
|
||||
called = true;
|
||||
}
|
||||
|
||||
const wrapped = validators.optional(validator);
|
||||
wrapped("value", {});
|
||||
|
||||
assert.equal(called, true, "validator was not called");
|
||||
});
|
||||
});
|
||||
|
||||
describe("greaterThan", function() {
|
||||
it("should allow values greater than min", function() {
|
||||
const validator = validators.greaterThan(5);
|
||||
assert.doesNotThrow(() => validator(6));
|
||||
});
|
||||
|
||||
it("should throw if value equals min", function() {
|
||||
const validator = validators.greaterThan(5);
|
||||
|
||||
assert.throws(
|
||||
() => validator(5),
|
||||
/Value must be greater than 5/,
|
||||
"did not throw on equal value"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if value is less than min", function() {
|
||||
const validator = validators.greaterThan(5);
|
||||
|
||||
assert.throws(
|
||||
() => validator(4),
|
||||
/Value must be greater than 5/,
|
||||
"did not throw on smaller value"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("greaterThanZero", function() {
|
||||
it("should allow values greater than zero", function() {
|
||||
assert.doesNotThrow(() => validators.greaterThanZero(1));
|
||||
});
|
||||
|
||||
it("should throw on zero", function() {
|
||||
assert.throws(
|
||||
() => validators.greaterThanZero(0),
|
||||
/Value must be greater than 0/
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw on negative values", function() {
|
||||
assert.throws(
|
||||
() => validators.greaterThanZero(-1),
|
||||
/Value must be greater than 0/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("greaterThanEqual", function() {
|
||||
it("should allow values greater than min", function() {
|
||||
const validator = validators.greaterThanEqual(5);
|
||||
assert.doesNotThrow(() => validator(6));
|
||||
});
|
||||
|
||||
it("should allow values equal to min", function() {
|
||||
const validator = validators.greaterThanEqual(5);
|
||||
assert.doesNotThrow(() => validator(5));
|
||||
});
|
||||
|
||||
it("should throw if value is less than min", function() {
|
||||
const validator = validators.greaterThanEqual(5);
|
||||
|
||||
assert.throws(
|
||||
() => validator(4),
|
||||
/Value must be greater than 5/,
|
||||
"did not throw on smaller value"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("greaterThanEqualZero", function() {
|
||||
it("should allow zero", function() {
|
||||
assert.doesNotThrow(() => validators.greaterThanEqualZero(0));
|
||||
});
|
||||
|
||||
it("should allow positive values", function() {
|
||||
assert.doesNotThrow(() => validators.greaterThanEqualZero(1));
|
||||
});
|
||||
|
||||
it("should throw on negative values", function() {
|
||||
assert.throws(
|
||||
() => validators.greaterThanEqualZero(-1),
|
||||
/Value must be greater than 0/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integer", function() {
|
||||
it("should allow integer values", function() {
|
||||
assert.doesNotThrow(() => validators.integer(10));
|
||||
});
|
||||
|
||||
it("should throw on non-integer values", function() {
|
||||
assert.throws(
|
||||
() => validators.integer(1.5),
|
||||
/Value must be an integer/,
|
||||
"did not throw on float"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filePath", function() {
|
||||
it("should accept valid paths", function() {
|
||||
assert.doesNotThrow(() => validators.filePath("test/file.txt"));
|
||||
assert.doesNotThrow(() => validators.filePath("/absolute/path"));
|
||||
});
|
||||
|
||||
it("should throw if value is not a string", function() {
|
||||
// This is the only case that will cause path.resolve to throw on posix
|
||||
assert.throws(
|
||||
() => validators.filePath(null),
|
||||
/Value must be a valid path/
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("lib/config/definitions/validators", function() {
|
||||
describe("Controller Config", function() {
|
||||
|
||||
});
|
||||
|
||||
describe("Host Config", function() {
|
||||
|
||||
});
|
||||
|
||||
describe("Instance Config", function() {
|
||||
it("should validate factorio.version", function() {
|
||||
const config = new InstanceConfig("controller");
|
||||
|
||||
// valid cases
|
||||
assert.doesNotThrow(() => config.set("factorio.version", "latest"));
|
||||
assert.doesNotThrow(() => config.set("factorio.version", "1.1"));
|
||||
assert.doesNotThrow(() => config.set("factorio.version", "1.1.87"));
|
||||
|
||||
// invalid case
|
||||
assert.throws(
|
||||
() => config.set("factorio.version", "invalidValue"),
|
||||
/Value must be be 'latest', or match X.Y, or match X.Y.Z/
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate factorio.player_online_autosave_slots against autosave slots", function() {
|
||||
const config = new InstanceConfig("controller", {
|
||||
"factorio.settings": {
|
||||
autosave_slots: 5,
|
||||
},
|
||||
});
|
||||
|
||||
// valid: equal
|
||||
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 5));
|
||||
|
||||
// valid: greater
|
||||
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 6));
|
||||
|
||||
// invalid: less than autosave_slots
|
||||
assert.throws(
|
||||
() => config.set("factorio.player_online_autosave_slots", 4),
|
||||
/Value cannot be less than the number of autosave slots/
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow player_online_autosave_slots when autosave_slots is not a number", function() {
|
||||
const config = new InstanceConfig("controller", {
|
||||
"factorio.settings": {
|
||||
autosave_slots: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 0));
|
||||
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Control Config", function() {
|
||||
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,15 @@ const assert = require("assert").strict;
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
const { Controller, ControlConnection } = require("@clusterio/controller");
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../common");
|
||||
|
||||
describe("messages/controller", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {ControlConnection} */
|
||||
let controlConnection;
|
||||
/** @type {lib.InstanceConfig} */
|
||||
let instanceConfig;
|
||||
|
||||
beforeEach(function() {
|
||||
const controllerConfig = new lib.ControllerConfig("controller");
|
||||
@@ -17,6 +20,8 @@ describe("messages/controller", function() {
|
||||
lib.Address.fromShorthand({ controlId: 1 }),
|
||||
);
|
||||
controller = new Controller(lib.logger, [], controllerConfig);
|
||||
instanceConfig = new lib.InstanceConfig("controller");
|
||||
controller.instances.createInstance(instanceConfig);
|
||||
const user = controller.users.getOrCreateUser("test");
|
||||
controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1);
|
||||
});
|
||||
@@ -35,6 +40,344 @@ describe("messages/controller", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerConfigGetRequest", function() {
|
||||
it("runs", async function() {
|
||||
const config = await controlConnection.handleControllerConfigGetRequest(
|
||||
new lib.ControllerConfigGetRequest()
|
||||
);
|
||||
assert.deepEqual(config, controller.config.toRemote("control"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerConfigSetFieldRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.ControllerConfigSetFieldRequest, testMatrix(
|
||||
["controller.name", "invalid"], // field
|
||||
["Foo Bar", "true", "5", "{}"], // value
|
||||
));
|
||||
});
|
||||
it("runs", async function() {
|
||||
await controlConnection.handleControllerConfigSetFieldRequest(
|
||||
new lib.ControllerConfigSetFieldRequest("controller.name", "Foo Bar")
|
||||
);
|
||||
assert.equal(controller.config.get("controller.name"), "Foo Bar");
|
||||
});
|
||||
it("rejects if the field does not exist", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleControllerConfigSetFieldRequest(
|
||||
new lib.ControllerConfigSetFieldRequest("invalid", "Foo Bar")
|
||||
),
|
||||
/No field named 'invalid'/
|
||||
);
|
||||
});
|
||||
it("rejects if the field is inaccessible", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleControllerConfigSetFieldRequest(
|
||||
new lib.ControllerConfigSetFieldRequest("controller.version", "2.0.0")
|
||||
),
|
||||
/Field 'controller.version' is not accessible from control/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerConfigSetPropRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.ControllerConfigSetPropRequest, testMatrix(
|
||||
["controller.name", "invalid"], // field
|
||||
["foo", "bar"], // prop
|
||||
["Foo Bar", true, 5, {}], // value
|
||||
));
|
||||
});
|
||||
it("runs", async function() {
|
||||
this.skip(); // No object fields on controller
|
||||
});
|
||||
it("rejects if the field does not exist", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleControllerConfigSetPropRequest(
|
||||
new lib.ControllerConfigSetPropRequest("invalid", "foo", "bar")
|
||||
),
|
||||
/No field named 'invalid'/
|
||||
);
|
||||
});
|
||||
it("rejects if the field is not an object", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleControllerConfigSetPropRequest(
|
||||
new lib.ControllerConfigSetPropRequest("controller.name", "foo", "bar")
|
||||
),
|
||||
/Cannot set property on non-object field 'controller\.name'/
|
||||
);
|
||||
});
|
||||
it("rejects if the field is inaccessible", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleControllerConfigSetPropRequest(
|
||||
new lib.ControllerConfigSetPropRequest("controller.version", "foo", "bar")
|
||||
),
|
||||
/Field 'controller\.version' is not accessible from control/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerConfigSetRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.ControllerConfigSetRequest, testMatrix(
|
||||
[ // fields
|
||||
{ "foo": "bar", "baz": { "cat": "dog" } },
|
||||
{ "foo": "5", "baz": { "cat": "5" } },
|
||||
{ "foo": "true", "baz": { "cat": "true" } },
|
||||
{ "foo": "{}", "baz": { "cat": "{}" } },
|
||||
],
|
||||
));
|
||||
});
|
||||
it("runs", async function() {
|
||||
await controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
|
||||
"controller.name": "Foo Bar",
|
||||
"controller.mods_directory": "Bar Baz",
|
||||
}));
|
||||
assert.equal(controller.config.get("controller.name"), "Foo Bar");
|
||||
assert.equal(controller.config.get("controller.mods_directory"), "Bar Baz");
|
||||
});
|
||||
it("rejects if the field does not exist", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
|
||||
"controller.name": "Foo Bar",
|
||||
"invalid": "Bar Baz",
|
||||
})),
|
||||
/No field named 'invalid'/
|
||||
);
|
||||
assert.equal(controller.config.get("controller.name"), "Your Cluster");
|
||||
});
|
||||
it("rejects if the field is not an object", async function() {
|
||||
const prevDir = controller.config.get("controller.mods_directory");
|
||||
await assert.rejects(
|
||||
controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
|
||||
"controller.name": "Foo Bar",
|
||||
"controller.mods_directory": {
|
||||
"foo": "bar",
|
||||
},
|
||||
})),
|
||||
/Cannot set property on non-object field 'controller\.mods_directory'/
|
||||
);
|
||||
assert.equal(controller.config.get("controller.name"), "Your Cluster");
|
||||
assert.equal(controller.config.get("controller.mods_directory"), prevDir);
|
||||
});
|
||||
it("rejects if the field is inaccessible", async function() {
|
||||
const prevVersion = controller.config.get("controller.version");
|
||||
await assert.rejects(
|
||||
controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
|
||||
"controller.name": "Foo Bar",
|
||||
"controller.version": "2.0.0",
|
||||
})),
|
||||
/Field 'controller\.version' is not accessible from control/
|
||||
);
|
||||
assert.equal(controller.config.get("controller.name"), "Your Cluster");
|
||||
assert.equal(controller.config.get("controller.version"), prevVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InstanceConfigGetRequest", function() {
|
||||
it("runs", async function() {
|
||||
const config = await controlConnection.handleInstanceConfigGetRequest(
|
||||
new lib.InstanceConfigGetRequest(instanceConfig.get("instance.id"))
|
||||
);
|
||||
assert.deepEqual(config, instanceConfig.toRemote("control"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("InstanceConfigSetFieldRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.InstanceConfigSetFieldRequest, testMatrix(
|
||||
[1], // instance id
|
||||
["controller.name", "invalid"], // field
|
||||
["Foo Bar", "true", "5", "{}"], // value
|
||||
));
|
||||
});
|
||||
it("runs", async function() {
|
||||
await controlConnection.handleInstanceConfigSetFieldRequest(
|
||||
new lib.InstanceConfigSetFieldRequest(instanceConfig.get("instance.id"), "instance.name", "Foo Bar")
|
||||
);
|
||||
assert.equal(instanceConfig.get("instance.name"), "Foo Bar");
|
||||
});
|
||||
it("rejects if the field does not exist", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetFieldRequest(
|
||||
new lib.InstanceConfigSetFieldRequest(instanceConfig.get("instance.id"), "invalid", "Foo Bar")
|
||||
),
|
||||
/No field named 'invalid'/
|
||||
);
|
||||
});
|
||||
it("rejects if the field is inaccessible", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetFieldRequest(
|
||||
new lib.InstanceConfigSetFieldRequest(
|
||||
instanceConfig.get("instance.id"), "factorio.host_assigned_game_port", "3000"
|
||||
)
|
||||
),
|
||||
/Field 'factorio\.host_assigned_game_port' is not accessible from control/
|
||||
);
|
||||
});
|
||||
it("rejects setting 'instance.assigned_host'", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetFieldRequest(
|
||||
new lib.InstanceConfigSetFieldRequest(
|
||||
instanceConfig.get("instance.id"), "instance.assigned_host", 5
|
||||
)
|
||||
),
|
||||
/instance\.assigned_host must be set through the assign-host interface/
|
||||
);
|
||||
});
|
||||
it("rejects setting 'instance.id'", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetFieldRequest(
|
||||
new lib.InstanceConfigSetFieldRequest(
|
||||
instanceConfig.get("instance.id"), "instance.id", 5
|
||||
)
|
||||
),
|
||||
/Setting instance\.id is not supported/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InstanceConfigSetPropRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.InstanceConfigSetPropRequest, testMatrix(
|
||||
[1], // instance id
|
||||
["controller.name", "invalid"], // field
|
||||
["foo", "bar"], // prop
|
||||
["Foo Bar", true, 5, {}], // value
|
||||
));
|
||||
});
|
||||
it("runs", async function() {
|
||||
controlConnection.handleInstanceConfigSetPropRequest(
|
||||
new lib.InstanceConfigSetPropRequest(
|
||||
instanceConfig.get("instance.id"), "factorio.settings", "name", "Foo Bar"
|
||||
)
|
||||
);
|
||||
const { name, ...rest } = instanceConfig.get("factorio.settings");
|
||||
assert.equal(name, "Foo Bar");
|
||||
});
|
||||
it("rejects if the field does not exist", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetPropRequest(
|
||||
new lib.InstanceConfigSetPropRequest(instanceConfig.get("instance.id"), "invalid", "foo", "bar")
|
||||
),
|
||||
/No field named 'invalid'/
|
||||
);
|
||||
});
|
||||
it("rejects if the field is not an object", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetPropRequest(
|
||||
new lib.InstanceConfigSetPropRequest(
|
||||
instanceConfig.get("instance.id"), "instance.name", "foo", "bar"
|
||||
)
|
||||
),
|
||||
/Cannot set property on non-object field 'instance\.name'/
|
||||
);
|
||||
});
|
||||
it("rejects if the field is inaccessible", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetPropRequest(
|
||||
new lib.InstanceConfigSetPropRequest(
|
||||
instanceConfig.get("instance.id"), "factorio.host_assigned_game_port", "foo", "bar"
|
||||
)
|
||||
),
|
||||
/Field 'factorio\.host_assigned_game_port' is not accessible from control/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InstanceConfigSetRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.InstanceConfigSetRequest, testMatrix(
|
||||
[1], // instance id
|
||||
[ // fields
|
||||
{ "foo": "bar", "baz": { "cat": "dog" } },
|
||||
{ "foo": "5", "baz": { "cat": "5" } },
|
||||
{ "foo": "true", "baz": { "cat": "true" } },
|
||||
{ "foo": "{}", "baz": { "cat": "{}" } },
|
||||
],
|
||||
));
|
||||
});
|
||||
it("runs", async function() {
|
||||
await controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
||||
instanceConfig.get("instance.id"), {
|
||||
"instance.name": "Foo Bar",
|
||||
"factorio.executable_path": "Bar Baz",
|
||||
})
|
||||
);
|
||||
assert.equal(instanceConfig.get("instance.name"), "Foo Bar");
|
||||
assert.equal(instanceConfig.get("factorio.executable_path"), "Bar Baz");
|
||||
});
|
||||
it("rejects if the field does not exist", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
||||
instanceConfig.get("instance.id"), {
|
||||
"instance.name": "Foo Bar",
|
||||
"invalid": "Bar Baz",
|
||||
})
|
||||
),
|
||||
/No field named 'invalid'/
|
||||
);
|
||||
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
||||
});
|
||||
it("rejects if the field is not an object", async function() {
|
||||
const prevPath = instanceConfig.get("factorio.executable_path");
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
||||
instanceConfig.get("instance.id"), {
|
||||
"instance.name": "Foo Bar",
|
||||
"factorio.executable_path": {
|
||||
"foo": "bar",
|
||||
},
|
||||
})
|
||||
),
|
||||
/Cannot set property on non-object field 'factorio\.executable_path'/
|
||||
);
|
||||
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
||||
assert.equal(instanceConfig.get("factorio.executable_path"), prevPath);
|
||||
});
|
||||
it("rejects if the field is inaccessible", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
||||
instanceConfig.get("instance.id"), {
|
||||
"instance.name": "Foo Bar",
|
||||
"factorio.host_assigned_game_port": "Bar Baz",
|
||||
})
|
||||
),
|
||||
/Field 'factorio\.host_assigned_game_port' is not accessible from control/
|
||||
);
|
||||
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
||||
// Can't check value of host_assigned_game_port as it is not accessible on controller
|
||||
});
|
||||
it("rejects setting 'instance.assigned_host'", async function() {
|
||||
const prevHost = instanceConfig.get("instance.assigned_host");
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
||||
instanceConfig.get("instance.id"), {
|
||||
"instance.name": "Foo Bar",
|
||||
"instance.assigned_host": 5,
|
||||
})
|
||||
),
|
||||
/instance\.assigned_host must be set through the assign-host interface/
|
||||
);
|
||||
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
||||
assert.equal(instanceConfig.get("instance.assigned_host"), prevHost);
|
||||
});
|
||||
it("rejects setting 'instance.id'", async function() {
|
||||
const prevId = instanceConfig.get("instance.id");
|
||||
await assert.rejects(
|
||||
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
||||
instanceConfig.get("instance.id"), {
|
||||
"instance.name": "Foo Bar",
|
||||
"instance.id": 5,
|
||||
})
|
||||
),
|
||||
/Setting instance\.id is not supported/
|
||||
);
|
||||
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
||||
assert.equal(instanceConfig.get("instance.id"), prevId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("External", function() {
|
||||
it("runs", async function() {
|
||||
let callCount = 0;
|
||||
|
||||
@@ -3,6 +3,7 @@ const assert = require("assert").strict;
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
const { Host } = require("@clusterio/host");
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../common");
|
||||
|
||||
describe("messages/host", function() {
|
||||
/** @type {Host} */
|
||||
@@ -30,4 +31,140 @@ describe("messages/host", function() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostConfigGetRequest", function() {
|
||||
it("runs", async function() {
|
||||
const config = await host.handleHostConfigGetRequest(new lib.HostConfigGetRequest());
|
||||
assert.deepEqual(config, host.config.toRemote("control"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostConfigSetFieldRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.HostConfigSetFieldRequest, testMatrix(
|
||||
["controller.name", "invalid"], // field
|
||||
["Foo Bar", "true", "5", "{}"], // value
|
||||
));
|
||||
});
|
||||
it("runs", async function() {
|
||||
await host.handleHostConfigSetFieldRequest(new lib.HostConfigSetFieldRequest("host.name", "Foo Bar"));
|
||||
assert.equal(host.config.get("host.name"), "Foo Bar");
|
||||
});
|
||||
it("rejects if the field does not exist", async function() {
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetFieldRequest(new lib.HostConfigSetFieldRequest("invalid", "Foo Bar")),
|
||||
/No field named 'invalid'/
|
||||
);
|
||||
});
|
||||
it("rejects if the field is inaccessible", async function() {
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetFieldRequest(new lib.HostConfigSetFieldRequest("host.version", "2.0.0")),
|
||||
/Field 'host.version' is not accessible from control/
|
||||
);
|
||||
});
|
||||
it("rejects setting 'host.id'", async function() {
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetFieldRequest(new lib.HostConfigSetFieldRequest("host.id", 5)),
|
||||
/Setting 'host.id' while host is running is not supported/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostConfigSetPropRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.HostConfigSetPropRequest, testMatrix(
|
||||
["controller.name", "invalid"], // field
|
||||
["foo", "bar"], // prop
|
||||
["Foo Bar", true, 5, {}], // value
|
||||
));
|
||||
});
|
||||
it("runs", async function() {
|
||||
this.skip(); // No object fields on host
|
||||
});
|
||||
it("rejects if the field does not exist", async function() {
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetPropRequest(new lib.HostConfigSetPropRequest("invalid", "foo", "bar")),
|
||||
/No field named 'invalid'/
|
||||
);
|
||||
});
|
||||
it("rejects if the field is not an object", async function() {
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetPropRequest(new lib.HostConfigSetPropRequest("host.name", "foo", "bar")),
|
||||
/Cannot set property on non-object field 'host\.name'/
|
||||
);
|
||||
});
|
||||
it("rejects if the field is inaccessible", async function() {
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetPropRequest(new lib.HostConfigSetPropRequest("host.version", "foo", "bar")),
|
||||
/Field 'host\.version' is not accessible from control/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostConfigSetRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.HostConfigSetRequest, testMatrix(
|
||||
[ // fields
|
||||
{ "foo": "bar", "baz": { "cat": "dog" } },
|
||||
{ "foo": "5", "baz": { "cat": "5" } },
|
||||
{ "foo": "true", "baz": { "cat": "true" } },
|
||||
{ "foo": "{}", "baz": { "cat": "{}" } },
|
||||
],
|
||||
));
|
||||
});
|
||||
it("runs", async function() {
|
||||
await host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
|
||||
"host.name": "Foo Bar",
|
||||
"host.factorio_directory": "Bar Baz",
|
||||
}));
|
||||
assert.equal(host.config.get("host.name"), "Foo Bar");
|
||||
assert.equal(host.config.get("host.factorio_directory"), "Bar Baz");
|
||||
});
|
||||
it("rejects if the field does not exist", async function() {
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
|
||||
"host.name": "Foo Bar",
|
||||
"invalid": "Bar Baz",
|
||||
})),
|
||||
/No field named 'invalid'/
|
||||
);
|
||||
assert.equal(host.config.get("host.name"), "New Host");
|
||||
});
|
||||
it("rejects if the field is not an object", async function() {
|
||||
const prevDir = host.config.get("host.factorio_directory");
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
|
||||
"host.name": "Foo Bar",
|
||||
"host.factorio_directory": {
|
||||
"foo": "bar",
|
||||
},
|
||||
})),
|
||||
/Cannot set property on non-object field 'host\.factorio_directory'/
|
||||
);
|
||||
assert.equal(host.config.get("host.name"), "New Host");
|
||||
assert.equal(host.config.get("host.factorio_directory"), prevDir);
|
||||
});
|
||||
it("rejects if the field is inaccessible", async function() {
|
||||
const prevVersion = host.config.get("host.version");
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
|
||||
"host.name": "Foo Bar",
|
||||
"host.version": "2.0.0",
|
||||
})),
|
||||
/Field 'host\.version' is not accessible from control/
|
||||
);
|
||||
assert.equal(host.config.get("host.name"), "New Host");
|
||||
assert.equal(host.config.get("host.version"), prevVersion);
|
||||
});
|
||||
it("rejects setting 'host.id'", async function() {
|
||||
await assert.rejects(
|
||||
host.handleHostConfigSetRequest(new lib.HostConfigSetRequest({
|
||||
"host.name": "Foo Bar",
|
||||
"host.id": 5,
|
||||
})),
|
||||
/Setting 'host.id' while host is running is not supported/
|
||||
);
|
||||
assert.equal(host.config.get("host.name"), "New Host");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -195,6 +195,10 @@ class MockController {
|
||||
this.handles = new Map();
|
||||
}
|
||||
|
||||
get authSecret() {
|
||||
return Buffer.from(this.config.get("controller.auth_secret"), "base64");
|
||||
}
|
||||
|
||||
handle(eventClass, handler) {
|
||||
this.handles.set(eventClass, handler);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user