mirror of
https://github.com/unraid/api.git
synced 2026-02-18 14:08:29 -06:00
chore: add organizer data structure for docker folders (#1540)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a comprehensive validation system for organizer data, including structural and referential integrity checks for views and organizers. * Added new data models for resources, folders, references, and views, with strong typing and validation. * Implemented a sequential validation processor with configurable fail-fast behavior and detailed error reporting. * Added a dedicated service for managing and validating Docker organizer configuration files. * **Bug Fixes** * Corrected spelling of error-related properties from "errorOccured" to "errorOccurred" in multiple services to ensure consistent error handling. * **Tests** * Added extensive unit tests for organizer validation logic, view structure validation, and the validation processor to ensure correctness across various edge cases and scenarios. * Added comprehensive tests verifying validation processor behavior under diverse conditions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
437
api/src/core/utils/validation/validation-processor.test.ts
Normal file
437
api/src/core/utils/validation/validation-processor.test.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ValidationResult } from '@app/core/utils/validation/validation-processor.js';
|
||||
import {
|
||||
createValidationProcessor,
|
||||
ResultInterpreters,
|
||||
} from '@app/core/utils/validation/validation-processor.js';
|
||||
|
||||
describe('ValidationProcessor', () => {
|
||||
type TestInput = { value: number; text: string };
|
||||
|
||||
it('should process all validation steps when no errors occur', () => {
|
||||
const steps = [
|
||||
{
|
||||
name: 'positiveValue',
|
||||
validator: (input: TestInput) => input.value > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
{
|
||||
name: 'nonEmptyText',
|
||||
validator: (input: TestInput) => input.text.length > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({
|
||||
steps,
|
||||
});
|
||||
|
||||
const result = processor({ value: 5, text: 'hello' }, { failFast: false });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should collect all errors when failFast is disabled', () => {
|
||||
const steps = [
|
||||
{
|
||||
name: 'positiveValue',
|
||||
validator: (input: TestInput) => input.value > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
{
|
||||
name: 'nonEmptyText',
|
||||
validator: (input: TestInput) => input.text.length > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({
|
||||
steps,
|
||||
});
|
||||
|
||||
const result = processor({ value: -1, text: '' }, { failFast: false });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.positiveValue).toBe(false);
|
||||
expect(result.errors.nonEmptyText).toBe(false);
|
||||
});
|
||||
|
||||
it('should stop at first error when failFast is enabled', () => {
|
||||
const steps = [
|
||||
{
|
||||
name: 'positiveValue',
|
||||
validator: (input: TestInput) => input.value > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
{
|
||||
name: 'nonEmptyText',
|
||||
validator: (input: TestInput) => input.text.length > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({
|
||||
steps,
|
||||
});
|
||||
|
||||
const result = processor({ value: -1, text: '' }, { failFast: true });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.positiveValue).toBe(false);
|
||||
expect(result.errors.nonEmptyText).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should always fail fast on steps marked with alwaysFailFast', () => {
|
||||
const steps = [
|
||||
{
|
||||
name: 'criticalCheck',
|
||||
validator: (input: TestInput) => input.value !== 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
alwaysFailFast: true,
|
||||
},
|
||||
{
|
||||
name: 'nonEmptyText',
|
||||
validator: (input: TestInput) => input.text.length > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({
|
||||
steps,
|
||||
});
|
||||
|
||||
const result = processor({ value: 0, text: '' }, { failFast: false });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.criticalCheck).toBe(false);
|
||||
expect(result.errors.nonEmptyText).toBeUndefined(); // Should not be executed
|
||||
});
|
||||
|
||||
it('should work with different result interpreters', () => {
|
||||
const steps = [
|
||||
{
|
||||
name: 'arrayResult',
|
||||
validator: (input: TestInput) => [1, 2, 3],
|
||||
isError: ResultInterpreters.errorList,
|
||||
},
|
||||
{
|
||||
name: 'nullableResult',
|
||||
validator: (input: TestInput) => (input.value > 0 ? null : 'error'),
|
||||
isError: ResultInterpreters.nullableIsSuccess,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({
|
||||
steps,
|
||||
});
|
||||
|
||||
const result = processor({ value: -1, text: 'test' }, { failFast: false });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.arrayResult).toEqual([1, 2, 3]);
|
||||
expect(result.errors.nullableResult).toBe('error');
|
||||
});
|
||||
|
||||
it('should handle 0-arity validators', () => {
|
||||
const processor = createValidationProcessor({
|
||||
steps: [
|
||||
{
|
||||
name: 'zeroArityValidator',
|
||||
validator: () => true,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
{
|
||||
name: 'zeroArityValidator2',
|
||||
validator: () => false,
|
||||
isError: ResultInterpreters.booleanMeansFailure,
|
||||
},
|
||||
] as const,
|
||||
});
|
||||
|
||||
const result = processor(null);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with custom result interpreter', () => {
|
||||
const steps = [
|
||||
{
|
||||
name: 'customCheck',
|
||||
validator: (input: TestInput) => ({ isOk: input.value > 0, code: 'VALUE_CHECK' }),
|
||||
isError: ResultInterpreters.custom((result: { isOk: boolean }) => !result.isOk),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({ steps });
|
||||
|
||||
const validResult = processor({ value: 5, text: 'test' });
|
||||
expect(validResult.isValid).toBe(true);
|
||||
expect(validResult.errors).toEqual({});
|
||||
|
||||
const invalidResult = processor({ value: -1, text: 'test' });
|
||||
expect(invalidResult.isValid).toBe(false);
|
||||
expect(invalidResult.errors.customCheck).toEqual({ isOk: false, code: 'VALUE_CHECK' });
|
||||
});
|
||||
|
||||
it('should work with validationProcessor result interpreter', () => {
|
||||
const innerProcessor = createValidationProcessor({
|
||||
steps: [
|
||||
{
|
||||
name: 'innerCheck',
|
||||
validator: (val: number) => val > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
] as const,
|
||||
});
|
||||
|
||||
const outerProcessor = createValidationProcessor({
|
||||
steps: [
|
||||
{
|
||||
name: 'nestedValidation',
|
||||
validator: (input: TestInput) => innerProcessor(input.value),
|
||||
isError: ResultInterpreters.validationProcessor,
|
||||
},
|
||||
] as const,
|
||||
});
|
||||
|
||||
const validResult = outerProcessor({ value: 5, text: 'test' });
|
||||
expect(validResult.isValid).toBe(true);
|
||||
|
||||
const invalidResult = outerProcessor({ value: -1, text: 'test' });
|
||||
expect(invalidResult.isValid).toBe(false);
|
||||
expect(invalidResult.errors.nestedValidation).toMatchObject({ isValid: false });
|
||||
});
|
||||
|
||||
it('should handle empty steps array', () => {
|
||||
const processor = createValidationProcessor<readonly []>({
|
||||
steps: [],
|
||||
});
|
||||
|
||||
const result = processor('any input' as never);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw when validators throw errors', () => {
|
||||
const steps = [
|
||||
{
|
||||
name: 'throwingValidator',
|
||||
validator: (input: TestInput) => {
|
||||
if (input.value === 0) {
|
||||
throw new Error('Division by zero');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({ steps });
|
||||
|
||||
expect(() => processor({ value: 0, text: 'test' })).toThrow('Division by zero');
|
||||
});
|
||||
|
||||
describe('complex validation scenarios', () => {
|
||||
it('should handle multi-type validation results', () => {
|
||||
type ComplexInput = {
|
||||
email: string;
|
||||
age: number;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
name: 'emailFormat',
|
||||
validator: (input: ComplexInput) =>
|
||||
/\S+@\S+\.\S+/.test(input.email) ? null : 'Invalid email format',
|
||||
isError: ResultInterpreters.nullableIsSuccess,
|
||||
},
|
||||
{
|
||||
name: 'ageRange',
|
||||
validator: (input: ComplexInput) => input.age >= 18 && input.age <= 120,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
{
|
||||
name: 'tagValidation',
|
||||
validator: (input: ComplexInput) => {
|
||||
const invalidTags = input.tags.filter((tag) => tag.length < 2);
|
||||
return invalidTags;
|
||||
},
|
||||
isError: ResultInterpreters.errorList,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({ steps });
|
||||
|
||||
const validInput: ComplexInput = {
|
||||
email: 'user@example.com',
|
||||
age: 25,
|
||||
tags: ['valid', 'tags', 'here'],
|
||||
};
|
||||
const validResult = processor(validInput);
|
||||
expect(validResult.isValid).toBe(true);
|
||||
|
||||
const invalidInput: ComplexInput = {
|
||||
email: 'invalid-email',
|
||||
age: 150,
|
||||
tags: ['ok', 'a', 'b', 'valid'],
|
||||
};
|
||||
const invalidResult = processor(invalidInput, { failFast: false });
|
||||
expect(invalidResult.isValid).toBe(false);
|
||||
expect(invalidResult.errors.emailFormat).toBe('Invalid email format');
|
||||
expect(invalidResult.errors.ageRange).toBe(false);
|
||||
expect(invalidResult.errors.tagValidation).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should preserve type safety with heterogeneous result types', () => {
|
||||
const steps = [
|
||||
{
|
||||
name: 'stringResult',
|
||||
validator: () => 'error message',
|
||||
isError: (result: string) => result.length > 0,
|
||||
},
|
||||
{
|
||||
name: 'numberResult',
|
||||
validator: () => 42,
|
||||
isError: (result: number) => result !== 0,
|
||||
},
|
||||
{
|
||||
name: 'objectResult',
|
||||
validator: () => ({ code: 'ERR_001', severity: 'high' }),
|
||||
isError: (result: { code: string; severity: string }) => true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({ steps });
|
||||
const result = processor(null, { failFast: false });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.stringResult).toBe('error message');
|
||||
expect(result.errors.numberResult).toBe(42);
|
||||
expect(result.errors.objectResult).toEqual({ code: 'ERR_001', severity: 'high' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined vs null in nullable interpreter', () => {
|
||||
const steps = [
|
||||
{
|
||||
name: 'nullCheck',
|
||||
validator: () => null,
|
||||
isError: ResultInterpreters.nullableIsSuccess,
|
||||
},
|
||||
{
|
||||
name: 'undefinedCheck',
|
||||
validator: () => undefined,
|
||||
isError: ResultInterpreters.nullableIsSuccess,
|
||||
},
|
||||
{
|
||||
name: 'zeroCheck',
|
||||
validator: () => 0,
|
||||
isError: ResultInterpreters.nullableIsSuccess,
|
||||
},
|
||||
{
|
||||
name: 'falseCheck',
|
||||
validator: () => false,
|
||||
isError: ResultInterpreters.nullableIsSuccess,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const processor = createValidationProcessor({ steps });
|
||||
const result = processor(null, { failFast: false });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.nullCheck).toBeUndefined();
|
||||
expect(result.errors.undefinedCheck).toBeUndefined();
|
||||
expect(result.errors.zeroCheck).toBe(0);
|
||||
expect(result.errors.falseCheck).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle very long validation chains', () => {
|
||||
// Test the real-world scenario of dynamically generated validation steps
|
||||
// Note: This demonstrates a limitation of the current type system -
|
||||
// dynamic step generation loses strict typing but still works at runtime
|
||||
type StepInput = { value: number };
|
||||
|
||||
const steps = Array.from({ length: 50 }, (_, i) => ({
|
||||
name: `step${i}`,
|
||||
validator: (input: StepInput) => input.value > i,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
}));
|
||||
|
||||
// For dynamic steps, we need to use a type assertion since TypeScript
|
||||
// can't infer the literal string union from Array.from()
|
||||
const processor = createValidationProcessor({
|
||||
steps,
|
||||
});
|
||||
|
||||
const result = processor({ value: 25 }, { failFast: false });
|
||||
expect(result.isValid).toBe(false);
|
||||
|
||||
const errorCount = Object.keys(result.errors).length;
|
||||
expect(errorCount).toBe(25);
|
||||
});
|
||||
|
||||
it('should handle validation by sum typing their inputs', () => {
|
||||
const processor = createValidationProcessor({
|
||||
steps: [
|
||||
{
|
||||
name: 'step1',
|
||||
validator: ({ age }: { age: number }) => age > 18,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
{
|
||||
name: 'step2',
|
||||
validator: ({ name }: { name: string }) => name.length > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = processor({ age: 25, name: 'John' });
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
const result2 = processor({ age: 15, name: '' });
|
||||
expect(result2.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow wider types as processor inputs', () => {
|
||||
const sumProcessor = createValidationProcessor({
|
||||
steps: [
|
||||
{
|
||||
name: 'step1',
|
||||
validator: ({ age }: { age: number }) => age > 18,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
{
|
||||
name: 'step2',
|
||||
validator: ({ name }: { name: string }) => name.length > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
],
|
||||
});
|
||||
type Person = { age: number; name: string };
|
||||
const groupProcessor = createValidationProcessor({
|
||||
steps: [
|
||||
{
|
||||
name: 'step1',
|
||||
validator: ({ age }: Person) => age > 18,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
{
|
||||
name: 'step2',
|
||||
validator: ({ name }: Person) => name.length > 0,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = sumProcessor({ age: 25, name: 'John', favoriteColor: 'red' });
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
const result2 = groupProcessor({ name: '', favoriteColor: 'red', age: 15 });
|
||||
expect(result2.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
230
api/src/core/utils/validation/validation-processor.ts
Normal file
230
api/src/core/utils/validation/validation-processor.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* @fileoverview Type-safe sequential validation processor
|
||||
*
|
||||
* This module provides a flexible validation system that allows you to chain multiple
|
||||
* validation steps together in a type-safe manner. It supports both fail-fast and
|
||||
* continue-on-error modes, with comprehensive error collection and reporting.
|
||||
*
|
||||
* Key features:
|
||||
* - Type-safe validation pipeline creation
|
||||
* - Sequential validation step execution
|
||||
* - Configurable fail-fast behavior (global or per-step)
|
||||
* - Comprehensive error collection with typed results
|
||||
* - Helper functions for common validation result interpretations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const validator = createValidationProcessor({
|
||||
* steps: [
|
||||
* {
|
||||
* name: 'required',
|
||||
* validator: (input: string) => input.length > 0,
|
||||
* isError: ResultInterpreters.booleanMeansSuccess
|
||||
* },
|
||||
* {
|
||||
* name: 'email',
|
||||
* validator: (input: string) => /\S+@\S+\.\S+/.test(input),
|
||||
* isError: ResultInterpreters.booleanMeansSuccess
|
||||
* }
|
||||
* ]
|
||||
* });
|
||||
*
|
||||
* const result = validator('user@example.com');
|
||||
* if (!result.isValid) {
|
||||
* console.log('Validation errors:', result.errors);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export type ValidationStepConfig<TInput, TResult, TName extends string = string> = {
|
||||
name: TName;
|
||||
validator: (input: TInput) => TResult;
|
||||
isError: (result: TResult) => boolean;
|
||||
alwaysFailFast?: boolean;
|
||||
};
|
||||
|
||||
export interface ValidationPipelineConfig {
|
||||
failFast?: boolean;
|
||||
}
|
||||
|
||||
export type ValidationPipelineDefinition<
|
||||
TInput,
|
||||
TSteps extends readonly ValidationStepConfig<TInput, any, string>[],
|
||||
> = {
|
||||
steps: TSteps;
|
||||
};
|
||||
|
||||
export type ExtractStepResults<TSteps extends readonly ValidationStepConfig<any, any, string>[]> = {
|
||||
[K in TSteps[number]['name']]: Extract<TSteps[number], { name: K }> extends ValidationStepConfig<
|
||||
any,
|
||||
infer R,
|
||||
K
|
||||
>
|
||||
? R
|
||||
: never;
|
||||
};
|
||||
|
||||
export type ValidationResult<TSteps extends readonly ValidationStepConfig<any, any, string>[]> = {
|
||||
isValid: boolean;
|
||||
errors: Partial<ExtractStepResults<TSteps>>;
|
||||
};
|
||||
|
||||
// Util: convert a union to an intersection
|
||||
type UnionToIntersection<U> = (U extends any ? (arg: U) => void : never) extends (arg: infer I) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
// Extract the *intersection* of all input types required by the steps. This guarantees that
|
||||
// the resulting processor knows about every property that any individual step relies on.
|
||||
// We purposely compute an intersection (not a union) so that all required fields are present.
|
||||
type ExtractInputType<TSteps extends readonly ValidationStepConfig<any, any, string>[]> =
|
||||
UnionToIntersection<
|
||||
TSteps[number] extends ValidationStepConfig<infer TInput, any, string> ? TInput : never
|
||||
>;
|
||||
|
||||
/**
|
||||
* Creates a type-safe validation processor that executes a series of validation steps
|
||||
* sequentially and collects errors from failed validations.
|
||||
*
|
||||
* This function returns a validation processor that can be called with input data
|
||||
* and an optional configuration object. The processor will run each validation step
|
||||
* in order, collecting any errors that occur.
|
||||
*
|
||||
* @template TSteps - A readonly array of validation step configurations that defines
|
||||
* the validation pipeline. The type is constrained to ensure type safety
|
||||
* across all steps and their results.
|
||||
*
|
||||
* @param definition - The validation pipeline definition
|
||||
* @param definition.steps - An array of validation step configurations. Each step must have:
|
||||
* - `name`: A unique string identifier for the step
|
||||
* - `validator`: A function that takes input and returns a validation result
|
||||
* - `isError`: A function that determines if the validation result represents an error
|
||||
* - `alwaysFailFast`: Optional flag to always stop execution on this step's failure
|
||||
*
|
||||
* @returns A validation processor function that accepts:
|
||||
* - `input`: The data to validate (type inferred from the first validation step)
|
||||
* - `config`: Optional configuration object with:
|
||||
* - `failFast`: If true, stops execution on first error (unless overridden by step config)
|
||||
*
|
||||
* @example Basic usage with string validation
|
||||
* ```typescript
|
||||
* const nameValidator = createValidationProcessor({
|
||||
* steps: [
|
||||
* {
|
||||
* name: 'required',
|
||||
* validator: (input: string) => input.trim().length > 0,
|
||||
* isError: ResultInterpreters.booleanMeansSuccess
|
||||
* },
|
||||
* {
|
||||
* name: 'minLength',
|
||||
* validator: (input: string) => input.length >= 2,
|
||||
* isError: ResultInterpreters.booleanMeansSuccess
|
||||
* },
|
||||
* {
|
||||
* name: 'maxLength',
|
||||
* validator: (input: string) => input.length <= 50,
|
||||
* isError: ResultInterpreters.booleanMeansSuccess
|
||||
* }
|
||||
* ]
|
||||
* });
|
||||
*
|
||||
* const result = nameValidator('John');
|
||||
* // result.isValid: boolean
|
||||
* // result.errors: { required?: boolean, minLength?: boolean, maxLength?: boolean }
|
||||
* ```
|
||||
*
|
||||
* @example Complex validation with custom error types
|
||||
* ```typescript
|
||||
* type ValidationError = { message: string; code: string };
|
||||
*
|
||||
* const userValidator = createValidationProcessor({
|
||||
* steps: [
|
||||
* {
|
||||
* name: 'email',
|
||||
* validator: (user: { email: string }) =>
|
||||
* /\S+@\S+\.\S+/.test(user.email)
|
||||
* ? null
|
||||
* : { message: 'Invalid email format', code: 'INVALID_EMAIL' },
|
||||
* isError: (result): result is ValidationError => result !== null
|
||||
* },
|
||||
* {
|
||||
* name: 'age',
|
||||
* validator: (user: { age: number }) =>
|
||||
* user.age >= 18
|
||||
* ? null
|
||||
* : { message: 'Must be 18 or older', code: 'UNDERAGE' },
|
||||
* isError: (result): result is ValidationError => result !== null,
|
||||
* alwaysFailFast: true // Stop immediately if age validation fails
|
||||
* }
|
||||
* ]
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Using fail-fast mode
|
||||
* ```typescript
|
||||
* const result = validator(input, { failFast: true });
|
||||
* // Stops on first error, even if subsequent steps would also fail
|
||||
* ```
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
export function createValidationProcessor<
|
||||
const TSteps extends readonly ValidationStepConfig<any, any, string>[],
|
||||
>(definition: { steps: TSteps }) {
|
||||
// Determine the base input type required by all steps (intersection).
|
||||
type BaseInput = ExtractInputType<TSteps>;
|
||||
|
||||
// Helper: widen input type for object literals while keeping regular objects assignable.
|
||||
type InputWithExtras = BaseInput extends object
|
||||
? BaseInput | (BaseInput & Record<string, unknown>)
|
||||
: BaseInput;
|
||||
|
||||
return function processValidation(
|
||||
input: InputWithExtras,
|
||||
config: ValidationPipelineConfig = {}
|
||||
): ValidationResult<TSteps> {
|
||||
const errors: Partial<ExtractStepResults<TSteps>> = {};
|
||||
let hasErrors = false;
|
||||
|
||||
for (const step of definition.steps) {
|
||||
const result = step.validator(input as BaseInput);
|
||||
const isError = step.isError(result);
|
||||
|
||||
if (isError) {
|
||||
hasErrors = true;
|
||||
(errors as any)[step.name] = result;
|
||||
|
||||
// Always fail fast for steps marked as such, or when global failFast is enabled
|
||||
if (step.alwaysFailFast || config.failFast) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: !hasErrors,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Helper functions for common result interpretations */
|
||||
export const ResultInterpreters = {
|
||||
/** For boolean results: true = success, false = error */
|
||||
booleanMeansSuccess: (result: boolean): boolean => !result,
|
||||
|
||||
/** For boolean results: false = success, true = error */
|
||||
booleanMeansFailure: (result: boolean): boolean => result,
|
||||
|
||||
/** For nullable results: null/undefined = success, anything else = error */
|
||||
nullableIsSuccess: <T>(result: T | null | undefined): boolean => result != null,
|
||||
|
||||
/** For array results: empty = success, non-empty = error */
|
||||
errorList: <T>(result: T[]): boolean => result.length > 0,
|
||||
|
||||
/** For custom predicate */
|
||||
custom: <T>(predicate: (result: T) => boolean) => predicate,
|
||||
|
||||
/** Interpreting the result of a validation processor */
|
||||
validationProcessor: (result: { isValid: boolean }) => !result.isValid,
|
||||
} as const;
|
||||
@@ -118,7 +118,7 @@ export class AuthService {
|
||||
}))
|
||||
);
|
||||
|
||||
const { errors, errorOccured } = await batchProcess(
|
||||
const { errors, errorOccurred: errorOccured } = await batchProcess(
|
||||
permissionActions,
|
||||
({ resource, action }) =>
|
||||
this.authzService.addPermissionForUser(apiKeyId, resource, action)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
|
||||
import { ValidationError } from 'class-validator';
|
||||
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.dto.js';
|
||||
import { validateOrganizerIntegrity } from '@app/unraid-api/organizer/organizer.validation.js';
|
||||
|
||||
@Injectable()
|
||||
export class DockerConfigService extends ConfigFilePersister<OrganizerV1> {
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
configKey(): string {
|
||||
return 'dockerOrganizer';
|
||||
}
|
||||
|
||||
fileName(): string {
|
||||
return 'docker.organizer.json';
|
||||
}
|
||||
|
||||
defaultConfig(): OrganizerV1 {
|
||||
return {
|
||||
version: 1,
|
||||
resources: {},
|
||||
views: {},
|
||||
};
|
||||
}
|
||||
|
||||
async validate(config: object): Promise<OrganizerV1> {
|
||||
const organizer = await validateObject(OrganizerV1, config);
|
||||
const { isValid, errors } = await validateOrganizerIntegrity(organizer);
|
||||
if (!isValid) {
|
||||
const error = new ValidationError();
|
||||
error.target = organizer;
|
||||
error.contexts = errors;
|
||||
throw error;
|
||||
}
|
||||
return organizer;
|
||||
}
|
||||
}
|
||||
@@ -182,13 +182,13 @@ export class NotificationsService {
|
||||
recalculate
|
||||
);
|
||||
|
||||
if (results.errorOccured) {
|
||||
if (results.errorOccurred) {
|
||||
results.errors.forEach((e) => this.logger.error('[recalculateOverview] ' + e));
|
||||
}
|
||||
|
||||
NotificationsService.overview = overview;
|
||||
void this.publishOverview();
|
||||
return { error: results.errorOccured, overview: this.getOverview() };
|
||||
return { error: results.errorOccurred, overview: this.getOverview() };
|
||||
}
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
|
||||
529
api/src/unraid-api/organizer/organizer-view.validation.test.ts
Normal file
529
api/src/unraid-api/organizer/organizer-view.validation.test.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findCycleInView,
|
||||
findDuplicateChildren,
|
||||
findMissingChildEntries,
|
||||
findOrphanedEntries,
|
||||
validateNestingDepth,
|
||||
validateViewStructure,
|
||||
viewRootExists,
|
||||
} from '@app/unraid-api/organizer/organizer-view.validation.js';
|
||||
import {
|
||||
OrganizerFolder,
|
||||
OrganizerResourceRef,
|
||||
OrganizerView,
|
||||
} from '@app/unraid-api/organizer/organizer.dto.js';
|
||||
|
||||
// Helper functions to create test data
|
||||
function createFolder(id: string, name: string, children: string[] = []): OrganizerFolder {
|
||||
return {
|
||||
id,
|
||||
type: 'folder',
|
||||
name,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function createResourceRef(id: string, target: string): OrganizerResourceRef {
|
||||
return {
|
||||
id,
|
||||
type: 'ref',
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
function createView(
|
||||
id: string,
|
||||
name: string,
|
||||
root: string,
|
||||
entries: Record<string, OrganizerFolder | OrganizerResourceRef>
|
||||
): OrganizerView {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
root,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
describe('viewRootExists', () => {
|
||||
it('should return true when root entry exists', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root Folder'),
|
||||
});
|
||||
|
||||
expect(viewRootExists(view)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when root entry does not exist', () => {
|
||||
const view = createView('view1', 'Test View', 'nonexistent', {
|
||||
root1: createFolder('root1', 'Root Folder'),
|
||||
});
|
||||
|
||||
expect(viewRootExists(view)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when entries is empty', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {});
|
||||
|
||||
expect(viewRootExists(view)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMissingChildEntries', () => {
|
||||
it('should return empty array when all children exist', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'child2']),
|
||||
child1: createFolder('child1', 'Child 1'),
|
||||
child2: createResourceRef('child2', 'resource1'),
|
||||
});
|
||||
|
||||
const result = findMissingChildEntries(view);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should find missing child entries', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'missing1']),
|
||||
child1: createFolder('child1', 'Child 1', ['missing2']),
|
||||
});
|
||||
|
||||
const result = findMissingChildEntries(view);
|
||||
expect(result).toEqual([
|
||||
{ parentId: 'root1', missingChildId: 'missing1' },
|
||||
{ parentId: 'child1', missingChildId: 'missing2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty children array', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', []),
|
||||
});
|
||||
|
||||
const result = findMissingChildEntries(view);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle resource refs (which have no children)', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createResourceRef('root1', 'resource1'),
|
||||
});
|
||||
|
||||
const result = findMissingChildEntries(view);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle folder with invalid children property', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: { ...createFolder('root1', 'Root'), children: null as any },
|
||||
});
|
||||
|
||||
const result = findMissingChildEntries(view);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDuplicateChildren', () => {
|
||||
it('should return empty array when no duplicates exist', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'child2', 'child3']),
|
||||
child1: createFolder('child1', 'Child 1'),
|
||||
child2: createFolder('child2', 'Child 2'),
|
||||
child3: createResourceRef('child3', 'resource1'),
|
||||
});
|
||||
|
||||
const result = findDuplicateChildren(view);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should find duplicate children in folders', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'child2', 'child1']),
|
||||
folder2: createFolder('folder2', 'Folder 2', ['child3', 'child3', 'child4', 'child3']),
|
||||
child1: createFolder('child1', 'Child 1'),
|
||||
child2: createFolder('child2', 'Child 2'),
|
||||
child3: createFolder('child3', 'Child 3'),
|
||||
child4: createFolder('child4', 'Child 4'),
|
||||
});
|
||||
|
||||
const result = findDuplicateChildren(view);
|
||||
expect(result).toEqual([
|
||||
{ folderId: 'root1', duplicateId: 'child1' },
|
||||
{ folderId: 'folder2', duplicateId: 'child3' },
|
||||
{ folderId: 'folder2', duplicateId: 'child3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple duplicates of same child', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'child1', 'child1']),
|
||||
child1: createFolder('child1', 'Child 1'),
|
||||
});
|
||||
|
||||
const result = findDuplicateChildren(view);
|
||||
expect(result).toEqual([
|
||||
{ folderId: 'root1', duplicateId: 'child1' },
|
||||
{ folderId: 'root1', duplicateId: 'child1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore resource refs', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createResourceRef('root1', 'resource1'),
|
||||
});
|
||||
|
||||
const result = findDuplicateChildren(view);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty children array', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', []),
|
||||
});
|
||||
|
||||
const result = findDuplicateChildren(view);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCycleInView', () => {
|
||||
it('should return null when no cycle exists', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'child2']),
|
||||
child1: createFolder('child1', 'Child 1', ['grandchild1']),
|
||||
child2: createResourceRef('child2', 'resource1'),
|
||||
grandchild1: createResourceRef('grandchild1', 'resource2'),
|
||||
});
|
||||
|
||||
const result = findCycleInView(view);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect direct self-reference cycle', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['root1']),
|
||||
});
|
||||
|
||||
const result = findCycleInView(view);
|
||||
expect(result).toEqual(['root1', 'root1']);
|
||||
});
|
||||
|
||||
it('should detect simple two-node cycle', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createFolder('child1', 'Child 1', ['root1']),
|
||||
});
|
||||
|
||||
const result = findCycleInView(view);
|
||||
expect(result).toEqual(['root1', 'child1', 'root1']);
|
||||
});
|
||||
|
||||
it('should handle missing root entry', () => {
|
||||
const view = createView('view1', 'Test View', 'nonexistent', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createFolder('child1', 'Child 1'),
|
||||
});
|
||||
|
||||
const result = findCycleInView(view);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle resource refs (no children)', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createResourceRef('root1', 'resource1'),
|
||||
});
|
||||
|
||||
const result = findCycleInView(view);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle folder with invalid children property', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: { ...createFolder('root1', 'Root'), children: null as any },
|
||||
});
|
||||
|
||||
const result = findCycleInView(view);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect cycle in tree structure', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createFolder('child1', 'Child 1', ['child2']),
|
||||
child2: createFolder('child2', 'Child 2', ['child3']),
|
||||
child3: createFolder('child3', 'Child 3', ['child1']),
|
||||
});
|
||||
|
||||
const result = findCycleInView(view);
|
||||
expect(result).toEqual(['child1', 'child2', 'child3', 'child1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateNestingDepth', () => {
|
||||
it('should return true for shallow nesting', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createFolder('child1', 'Child 1', ['grandchild1']),
|
||||
grandchild1: createResourceRef('grandchild1', 'resource1'),
|
||||
});
|
||||
|
||||
expect(validateNestingDepth(view)).toBe(true);
|
||||
expect(validateNestingDepth(view, 10)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when exceeding max depth', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createFolder('child1', 'Child 1', ['child2']),
|
||||
child2: createFolder('child2', 'Child 2', ['child3']),
|
||||
child3: createResourceRef('child3', 'resource1'),
|
||||
});
|
||||
|
||||
expect(validateNestingDepth(view, 2)).toBe(false);
|
||||
expect(validateNestingDepth(view, 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle single node at root', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createResourceRef('root1', 'resource1'),
|
||||
});
|
||||
|
||||
expect(validateNestingDepth(view, 0)).toBe(true);
|
||||
expect(validateNestingDepth(view, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing root entry', () => {
|
||||
const view = createView('view1', 'Test View', 'nonexistent', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createFolder('child1', 'Child 1'),
|
||||
});
|
||||
|
||||
expect(validateNestingDepth(view)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle folder with invalid children property', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: { ...createFolder('root1', 'Root'), children: null as any },
|
||||
});
|
||||
|
||||
expect(validateNestingDepth(view)).toBe(true);
|
||||
});
|
||||
|
||||
it('should use default max depth of 100', () => {
|
||||
// Create a deep structure that's within reasonable limits
|
||||
const entries: Record<string, OrganizerFolder | OrganizerResourceRef> = {};
|
||||
entries.root1 = createFolder('root1', 'Root', ['child1']);
|
||||
|
||||
for (let i = 1; i < 50; i++) {
|
||||
entries[`child${i}`] = createFolder(`child${i}`, `Child ${i}`, [`child${i + 1}`]);
|
||||
}
|
||||
entries.child50 = createResourceRef('child50', 'resource1');
|
||||
|
||||
const view = createView('view1', 'Test View', 'root1', entries);
|
||||
expect(validateNestingDepth(view)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOrphanedEntries', () => {
|
||||
it('should return empty array when all entries are reachable', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'child2']),
|
||||
child1: createFolder('child1', 'Child 1', ['grandchild1']),
|
||||
child2: createResourceRef('child2', 'resource1'),
|
||||
grandchild1: createResourceRef('grandchild1', 'resource2'),
|
||||
});
|
||||
|
||||
const result = findOrphanedEntries(view);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should find orphaned entries', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createResourceRef('child1', 'resource1'),
|
||||
orphan1: createFolder('orphan1', 'Orphan 1'),
|
||||
orphan2: createResourceRef('orphan2', 'resource2'),
|
||||
});
|
||||
|
||||
const result = findOrphanedEntries(view);
|
||||
expect(result.sort()).toEqual(['orphan1', 'orphan2']);
|
||||
});
|
||||
|
||||
it('should handle disconnected subgraphs', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createResourceRef('child1', 'resource1'),
|
||||
orphanParent: createFolder('orphanParent', 'Orphan Parent', ['orphanChild']),
|
||||
orphanChild: createResourceRef('orphanChild', 'resource2'),
|
||||
});
|
||||
|
||||
const result = findOrphanedEntries(view);
|
||||
expect(result.sort()).toEqual(['orphanChild', 'orphanParent']);
|
||||
});
|
||||
|
||||
it('should handle missing root entry', () => {
|
||||
const view = createView('view1', 'Test View', 'nonexistent', {
|
||||
entry1: createFolder('entry1', 'Entry 1'),
|
||||
entry2: createResourceRef('entry2', 'resource1'),
|
||||
});
|
||||
|
||||
const result = findOrphanedEntries(view);
|
||||
expect(result.sort()).toEqual(['entry1', 'entry2']);
|
||||
});
|
||||
|
||||
it('should handle single root entry', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createResourceRef('root1', 'resource1'),
|
||||
});
|
||||
|
||||
const result = findOrphanedEntries(view);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle cycles correctly', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createFolder('child1', 'Child 1', ['child2']),
|
||||
child2: createFolder('child2', 'Child 2', ['child1']),
|
||||
orphan: createResourceRef('orphan', 'resource1'),
|
||||
});
|
||||
|
||||
const result = findOrphanedEntries(view);
|
||||
expect(result).toEqual(['orphan']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateViewStructure', () => {
|
||||
it('should pass validation for a well-formed view', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'child2']),
|
||||
child1: createFolder('child1', 'Child 1', ['grandchild1']),
|
||||
child2: createResourceRef('child2', 'resource1'),
|
||||
grandchild1: createResourceRef('grandchild1', 'resource2'),
|
||||
});
|
||||
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should fail validation when root is missing', () => {
|
||||
const view = createView('view1', 'Test View', 'nonexistent', {
|
||||
child1: createFolder('child1', 'Child 1'),
|
||||
});
|
||||
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.rootMissing).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect missing children', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'missing']),
|
||||
child1: createResourceRef('child1', 'resource1'),
|
||||
});
|
||||
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.missingChildren).toEqual([
|
||||
{ parentId: 'root1', missingChildId: 'missing' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect duplicate children', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'child1']),
|
||||
child1: createResourceRef('child1', 'resource1'),
|
||||
});
|
||||
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.duplicateChildren).toEqual([{ folderId: 'root1', duplicateId: 'child1' }]);
|
||||
});
|
||||
|
||||
it('should detect cycles', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createFolder('child1', 'Child 1', ['root1']),
|
||||
});
|
||||
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cycles).toEqual(['root1', 'child1', 'root1']);
|
||||
});
|
||||
|
||||
it('should detect excessive nesting depth', () => {
|
||||
const entries: Record<string, OrganizerFolder | OrganizerResourceRef> = {};
|
||||
entries.root1 = createFolder('root1', 'Root', ['child1']);
|
||||
|
||||
// Create a structure that exceeds default depth of 100
|
||||
for (let i = 1; i <= 101; i++) {
|
||||
if (i < 101) {
|
||||
entries[`child${i}`] = createFolder(`child${i}`, `Child ${i}`, [`child${i + 1}`]);
|
||||
} else {
|
||||
entries[`child${i}`] = createResourceRef(`child${i}`, 'resource1');
|
||||
}
|
||||
}
|
||||
|
||||
const view = createView('view1', 'Test View', 'root1', entries);
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.exceedsMaxDepth).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect orphaned entries', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createResourceRef('child1', 'resource1'),
|
||||
orphan: createResourceRef('orphan', 'resource2'),
|
||||
});
|
||||
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.orphanedEntries).toEqual(['orphan']);
|
||||
});
|
||||
|
||||
it('should collect multiple errors when not using fail-fast', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1', 'child1', 'missing']),
|
||||
child1: createResourceRef('child1', 'resource1'),
|
||||
orphan: createResourceRef('orphan', 'resource2'),
|
||||
});
|
||||
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(Object.keys(result.errors)).toContain('missingChildren');
|
||||
expect(Object.keys(result.errors)).toContain('duplicateChildren');
|
||||
// Note: orphanedEntries will be detected since there are no cycles or depth issues
|
||||
expect(Object.keys(result.errors)).toContain('orphanedEntries');
|
||||
});
|
||||
|
||||
it('should fail fast on missing root with alwaysFailFast', () => {
|
||||
const view = createView('view1', 'Test View', 'nonexistent', {
|
||||
child1: createFolder('child1', 'Child 1', ['child1']), // This would create a cycle
|
||||
orphan: createResourceRef('orphan', 'resource1'), // This would be orphaned
|
||||
});
|
||||
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.rootMissing).toBe(false);
|
||||
// Other errors should not be present due to fail-fast
|
||||
expect(result.errors.cycles).toBeUndefined();
|
||||
expect(result.errors.orphanedEntries).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail fast on cycles with alwaysFailFast', () => {
|
||||
const view = createView('view1', 'Test View', 'root1', {
|
||||
root1: createFolder('root1', 'Root', ['child1']),
|
||||
child1: createFolder('child1', 'Child 1', ['root1']),
|
||||
orphan: createResourceRef('orphan', 'resource1'), // This would be orphaned
|
||||
});
|
||||
|
||||
const result = validateViewStructure(view);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cycles).toEqual(['root1', 'child1', 'root1']);
|
||||
// Orphaned entries should not be checked due to fail-fast
|
||||
expect(result.errors.orphanedEntries).toBeUndefined();
|
||||
});
|
||||
});
|
||||
201
api/src/unraid-api/organizer/organizer-view.validation.ts
Normal file
201
api/src/unraid-api/organizer/organizer-view.validation.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
createValidationProcessor,
|
||||
ResultInterpreters,
|
||||
} from '@app/core/utils/validation/validation-processor.js';
|
||||
import { OrganizerView } from '@app/unraid-api/organizer/organizer.dto.js';
|
||||
|
||||
/**========================================================================
|
||||
* Health of individual entries (graph-nodes)
|
||||
*========================================================================**/
|
||||
|
||||
/**
|
||||
* Checks if value of view.root is a key in view.entries.
|
||||
*
|
||||
* The root entry defines the first layer of the view.
|
||||
* It is the view's entrypoint, so it must exist for the view to be useful.
|
||||
*
|
||||
* @param view - The view to check.
|
||||
* @returns True if the view has a root entry, false otherwise.
|
||||
*/
|
||||
export function viewRootExists(view: OrganizerView): boolean {
|
||||
return Boolean(view.entries[view.root]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds missing child entries (folders referencing non-existent children).
|
||||
*/
|
||||
export function findMissingChildEntries(
|
||||
view: OrganizerView
|
||||
): Array<{ parentId: string; missingChildId: string }> {
|
||||
const missing: Array<{ parentId: string; missingChildId: string }> = [];
|
||||
|
||||
Object.entries(view.entries).forEach(([parentId, entry]) => {
|
||||
if (entry.type === 'folder' && Array.isArray(entry.children)) {
|
||||
entry.children.forEach((childId) => {
|
||||
if (!view.entries[childId]) {
|
||||
missing.push({ parentId, missingChildId: childId });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds duplicate children in folder entries.
|
||||
*/
|
||||
export function findDuplicateChildren(
|
||||
view: OrganizerView
|
||||
): Array<{ folderId: string; duplicateId: string }> {
|
||||
const duplicates: Array<{ folderId: string; duplicateId: string }> = [];
|
||||
|
||||
Object.entries(view.entries).forEach(([folderId, entry]) => {
|
||||
if (entry.type === 'folder' && Array.isArray(entry.children)) {
|
||||
const seen = new Set<string>();
|
||||
entry.children.forEach((childId) => {
|
||||
if (seen.has(childId)) {
|
||||
duplicates.push({ folderId, duplicateId: childId });
|
||||
} else {
|
||||
seen.add(childId);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
/**========================================================================
|
||||
* Entries Graph Health
|
||||
*========================================================================**/
|
||||
|
||||
/**
|
||||
* Detects cycles in a view's entries tree.
|
||||
* Returns: null if no cycle, or an array of entry IDs showing the cycle path.
|
||||
*/
|
||||
export function findCycleInView(view: OrganizerView): string[] | null {
|
||||
const entries = view.entries;
|
||||
|
||||
function dfs(currentId: string, path: string[], inProgress: Set<string>): string[] | null {
|
||||
if (inProgress.has(currentId)) {
|
||||
// Cycle found! Return path + the entry where cycle starts.
|
||||
const cycleStart = path.indexOf(currentId);
|
||||
return path.slice(cycleStart).concat([currentId]);
|
||||
}
|
||||
|
||||
const entry = entries[currentId];
|
||||
if (!entry) return null; // orphaned/missing node
|
||||
|
||||
// Only folders can have children that create cycles
|
||||
if (entry.type !== 'folder') return null;
|
||||
|
||||
// Defensive check for children array
|
||||
if (!Array.isArray(entry.children)) return null;
|
||||
|
||||
inProgress.add(currentId);
|
||||
path.push(currentId);
|
||||
|
||||
for (const childId of entry.children) {
|
||||
const cycle = dfs(childId, path, inProgress);
|
||||
if (cycle) return cycle;
|
||||
}
|
||||
|
||||
path.pop();
|
||||
inProgress.delete(currentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return dfs(view.root, [], new Set());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates nesting depth doesn't exceed maximum (prevents stack overflow)
|
||||
*/
|
||||
export function validateNestingDepth(view: OrganizerView, maxDepth: number = 100): boolean {
|
||||
function checkDepth(entryId: string, currentDepth: number): boolean {
|
||||
if (currentDepth > maxDepth) return false;
|
||||
|
||||
const entry = view.entries[entryId];
|
||||
if (!entry || entry.type !== 'folder') return true;
|
||||
|
||||
if (!Array.isArray(entry.children)) return true;
|
||||
|
||||
return entry.children.every((childId) => checkDepth(childId, currentDepth + 1));
|
||||
}
|
||||
|
||||
return checkDepth(view.root, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds orphaned entries that exist in the entries map but aren't reachable from view.root.
|
||||
*/
|
||||
export function findOrphanedEntries(view: OrganizerView): string[] {
|
||||
const reachable = new Set<string>();
|
||||
|
||||
function traverse(entryId: string): void {
|
||||
if (reachable.has(entryId)) return; // Already visited
|
||||
reachable.add(entryId);
|
||||
|
||||
const entry = view.entries[entryId];
|
||||
if (entry?.type === 'folder' && Array.isArray(entry.children)) {
|
||||
entry.children.forEach(traverse);
|
||||
}
|
||||
}
|
||||
|
||||
traverse(view.root);
|
||||
return Object.keys(view.entries).filter((id) => !reachable.has(id));
|
||||
}
|
||||
|
||||
/**========================================================================
|
||||
* Combined Validation
|
||||
*========================================================================**/
|
||||
|
||||
/**
|
||||
* Validates the structure of a view.
|
||||
*
|
||||
* This includes:
|
||||
* - Root entry exists
|
||||
* - No missing children
|
||||
* - No duplicate children
|
||||
* - No cycles
|
||||
* - Reasonable nesting depth
|
||||
* - No orphaned entries
|
||||
*/
|
||||
export const validateViewStructure = createValidationProcessor({
|
||||
steps: [
|
||||
{
|
||||
name: 'rootMissing',
|
||||
validator: viewRootExists,
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
alwaysFailFast: true, // If no root, other validations are meaningless
|
||||
},
|
||||
{
|
||||
name: 'missingChildren',
|
||||
validator: findMissingChildEntries,
|
||||
isError: ResultInterpreters.errorList,
|
||||
},
|
||||
{
|
||||
name: 'duplicateChildren',
|
||||
validator: findDuplicateChildren,
|
||||
isError: ResultInterpreters.errorList,
|
||||
},
|
||||
{
|
||||
name: 'cycles',
|
||||
validator: findCycleInView,
|
||||
isError: ResultInterpreters.nullableIsSuccess,
|
||||
alwaysFailFast: true, // Cycles break tree traversals
|
||||
},
|
||||
{
|
||||
name: 'exceedsMaxDepth',
|
||||
validator: (view: OrganizerView) => !validateNestingDepth(view),
|
||||
isError: ResultInterpreters.booleanMeansFailure,
|
||||
alwaysFailFast: true, // Max depth exceeded breaks tree traversals
|
||||
},
|
||||
{
|
||||
name: 'orphanedEntries',
|
||||
validator: findOrphanedEntries,
|
||||
isError: ResultInterpreters.errorList,
|
||||
},
|
||||
] as const,
|
||||
});
|
||||
106
api/src/unraid-api/organizer/organizer.dto.ts
Normal file
106
api/src/unraid-api/organizer/organizer.dto.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
Equals,
|
||||
IsArray,
|
||||
IsDefined,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
// Resource definition (global)
|
||||
@ObjectType()
|
||||
export class OrganizerResource {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
type!: string; // e.g., "container", "vm", "file", "bookmark"
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Folder or ref inside a view
|
||||
@ObjectType()
|
||||
export class OrganizerFolder {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsIn(['folder'])
|
||||
type!: 'folder';
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
children!: string[]; // array of entry IDs
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class OrganizerResourceRef {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsIn(['ref'])
|
||||
type!: 'ref';
|
||||
|
||||
@IsString()
|
||||
target!: string; // resource id
|
||||
}
|
||||
|
||||
// Union type for an entry (for strong typing, not directly used in class-validator)
|
||||
export type OrganizerEntry = OrganizerFolder | OrganizerResourceRef;
|
||||
|
||||
// Each view (user-definable Organizer)
|
||||
@ObjectType()
|
||||
export class OrganizerView {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
root!: string; // id of the root entry
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Object) // we'll validate the values below
|
||||
entries!: { [id: string]: OrganizerFolder | OrganizerResourceRef };
|
||||
|
||||
// todo: evolve as needed
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
prefs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// The whole root structure
|
||||
@ObjectType()
|
||||
export class OrganizerV1 {
|
||||
@IsNumber()
|
||||
@Equals(1, { message: 'Version must be 1' })
|
||||
version!: 1;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrganizerResource)
|
||||
resources!: { [id: string]: OrganizerResource };
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrganizerView)
|
||||
views!: { [id: string]: OrganizerView };
|
||||
}
|
||||
582
api/src/unraid-api/organizer/organizer.validation.test.ts
Normal file
582
api/src/unraid-api/organizer/organizer.validation.test.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import * as viewValidation from '@app/unraid-api/organizer/organizer-view.validation.js';
|
||||
import {
|
||||
OrganizerFolder,
|
||||
OrganizerResourceRef,
|
||||
OrganizerV1,
|
||||
OrganizerView,
|
||||
} from '@app/unraid-api/organizer/organizer.dto.js';
|
||||
import {
|
||||
getRefsFromViewEntries,
|
||||
validateOrganizerIntegrity,
|
||||
validateViewIntegrity,
|
||||
validateViewResourceRefs,
|
||||
} from '@app/unraid-api/organizer/organizer.validation.js';
|
||||
|
||||
// Test data factories for better maintainability
|
||||
const createRef = (id: string, target: string): OrganizerResourceRef => ({
|
||||
id,
|
||||
type: 'ref' as const,
|
||||
target,
|
||||
});
|
||||
|
||||
const createFolder = (id: string, name: string, children: string[]): OrganizerFolder => ({
|
||||
id,
|
||||
type: 'folder' as const,
|
||||
name,
|
||||
children,
|
||||
});
|
||||
|
||||
const createView = (
|
||||
partial: Partial<OrganizerView> & Pick<OrganizerView, 'root' | 'entries'>
|
||||
): OrganizerView => ({
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
...partial,
|
||||
});
|
||||
|
||||
describe('organizer.validation', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
describe('getRefsFromViewEntries', () => {
|
||||
it('should return empty set for empty entries', () => {
|
||||
const entries = {};
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should collect refs from top-level ref entries', () => {
|
||||
const entries = {
|
||||
ref1: { id: 'ref1', type: 'ref' as const, target: 'resource1' },
|
||||
ref2: { id: 'ref2', type: 'ref' as const, target: 'resource2' },
|
||||
};
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(2);
|
||||
expect(refs.has('resource1')).toBe(true);
|
||||
expect(refs.has('resource2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should collect refs from nested folder structure', () => {
|
||||
const entries = {
|
||||
folder1: {
|
||||
id: 'folder1',
|
||||
type: 'folder' as const,
|
||||
name: 'Folder 1',
|
||||
children: ['ref1', 'folder2'],
|
||||
},
|
||||
folder2: {
|
||||
id: 'folder2',
|
||||
type: 'folder' as const,
|
||||
name: 'Folder 2',
|
||||
children: ['ref2', 'ref3'],
|
||||
},
|
||||
ref1: { id: 'ref1', type: 'ref' as const, target: 'resource1' },
|
||||
ref2: { id: 'ref2', type: 'ref' as const, target: 'resource2' },
|
||||
ref3: { id: 'ref3', type: 'ref' as const, target: 'resource3' },
|
||||
};
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(3);
|
||||
expect(refs.has('resource1')).toBe(true);
|
||||
expect(refs.has('resource2')).toBe(true);
|
||||
expect(refs.has('resource3')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing/orphaned child entries', () => {
|
||||
const entries = {
|
||||
folder1: {
|
||||
id: 'folder1',
|
||||
type: 'folder' as const,
|
||||
name: 'Folder 1',
|
||||
children: ['ref1', 'missing-child'],
|
||||
},
|
||||
ref1: { id: 'ref1', type: 'ref' as const, target: 'resource1' },
|
||||
};
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(1);
|
||||
expect(refs.has('resource1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent infinite loops with circular references', () => {
|
||||
const entries = {
|
||||
folder1: {
|
||||
id: 'folder1',
|
||||
type: 'folder' as const,
|
||||
name: 'Folder 1',
|
||||
children: ['folder2'],
|
||||
},
|
||||
folder2: {
|
||||
id: 'folder2',
|
||||
type: 'folder' as const,
|
||||
name: 'Folder 2',
|
||||
children: ['folder1', 'ref1'],
|
||||
},
|
||||
ref1: { id: 'ref1', type: 'ref' as const, target: 'resource1' },
|
||||
};
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(1);
|
||||
expect(refs.has('resource1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle folders with non-array children gracefully', () => {
|
||||
const entries = {
|
||||
folder1: {
|
||||
id: 'folder1',
|
||||
type: 'folder' as const,
|
||||
name: 'Folder 1',
|
||||
children: 'not-an-array' as any,
|
||||
},
|
||||
ref1: { id: 'ref1', type: 'ref' as const, target: 'resource1' },
|
||||
};
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(1);
|
||||
expect(refs.has('resource1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle duplicate refs correctly', () => {
|
||||
const entries = {
|
||||
ref1: { id: 'ref1', type: 'ref' as const, target: 'resource1' },
|
||||
ref2: { id: 'ref2', type: 'ref' as const, target: 'resource1' },
|
||||
};
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(1);
|
||||
expect(refs.has('resource1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle self-referencing folders', () => {
|
||||
const entries = {
|
||||
folder1: createFolder('folder1', 'Self Ref', ['folder1', 'ref1']),
|
||||
ref1: createRef('ref1', 'resource1'),
|
||||
};
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(1);
|
||||
expect(refs.has('resource1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle deeply nested structures', () => {
|
||||
// Create a deeply nested structure
|
||||
const entries: any = {};
|
||||
let currentId = 'folder0';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const nextId = `folder${i + 1}`;
|
||||
entries[currentId] = createFolder(currentId, `Folder ${i}`, [nextId]);
|
||||
currentId = nextId;
|
||||
}
|
||||
entries[currentId] = createFolder(currentId, 'Last Folder', ['ref1']);
|
||||
entries.ref1 = createRef('ref1', 'resource1');
|
||||
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(1);
|
||||
expect(refs.has('resource1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle entries with null or undefined values', () => {
|
||||
const entries: any = {
|
||||
folder1: createFolder('folder1', 'Folder', ['ref1', 'null-entry']),
|
||||
ref1: createRef('ref1', 'resource1'),
|
||||
'null-entry': null,
|
||||
'undefined-entry': undefined,
|
||||
};
|
||||
const refs = getRefsFromViewEntries(entries);
|
||||
expect(refs.size).toBe(1);
|
||||
expect(refs.has('resource1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateViewResourceRefs', () => {
|
||||
it('should return true when all refs exist in resources', () => {
|
||||
const view: OrganizerView = {
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
root: 'folder1',
|
||||
entries: {
|
||||
folder1: {
|
||||
id: 'folder1',
|
||||
type: 'folder',
|
||||
name: 'Folder 1',
|
||||
children: ['ref1', 'ref2'],
|
||||
},
|
||||
ref1: { id: 'ref1', type: 'ref', target: 'resource1' },
|
||||
ref2: { id: 'ref2', type: 'ref', target: 'resource2' },
|
||||
},
|
||||
};
|
||||
const resources = new Set(['resource1', 'resource2', 'resource3']);
|
||||
expect(validateViewResourceRefs(view, resources)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when some refs do not exist in resources', () => {
|
||||
const view: OrganizerView = {
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
root: 'ref1',
|
||||
entries: {
|
||||
ref1: { id: 'ref1', type: 'ref', target: 'resource1' },
|
||||
ref2: { id: 'ref2', type: 'ref', target: 'missing-resource' },
|
||||
},
|
||||
};
|
||||
const resources = new Set(['resource1']);
|
||||
expect(validateViewResourceRefs(view, resources)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for empty view entries', () => {
|
||||
const view: OrganizerView = {
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
root: 'root',
|
||||
entries: {},
|
||||
};
|
||||
const resources = new Set(['resource1']);
|
||||
expect(validateViewResourceRefs(view, resources)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when resources is empty but view has no refs', () => {
|
||||
const view: OrganizerView = {
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
root: 'folder1',
|
||||
entries: {
|
||||
folder1: {
|
||||
id: 'folder1',
|
||||
type: 'folder',
|
||||
name: 'Folder 1',
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const resources = new Set<string>();
|
||||
expect(validateViewResourceRefs(view, resources)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateViewIntegrity', () => {
|
||||
it('should validate a valid view successfully without mocks', () => {
|
||||
const view = createView({
|
||||
root: 'folder1',
|
||||
entries: {
|
||||
folder1: createFolder('folder1', 'Folder 1', ['ref1']),
|
||||
ref1: createRef('ref1', 'resource1'),
|
||||
},
|
||||
});
|
||||
const resources = new Set(['resource1']);
|
||||
|
||||
const result = validateViewIntegrity({ view, resources });
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should validate a valid view successfully with mocked structure validation', async () => {
|
||||
const view: OrganizerView = {
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
root: 'folder1',
|
||||
entries: {
|
||||
folder1: {
|
||||
id: 'folder1',
|
||||
type: 'folder',
|
||||
name: 'Folder 1',
|
||||
children: ['ref1'],
|
||||
},
|
||||
ref1: { id: 'ref1', type: 'ref', target: 'resource1' },
|
||||
},
|
||||
};
|
||||
const resources = new Set(['resource1']);
|
||||
|
||||
vi.spyOn(viewValidation, 'validateViewStructure').mockReturnValue({
|
||||
isValid: true,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
const result = validateViewIntegrity({ view, resources });
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should fail fast on structure validation failure', async () => {
|
||||
const view: OrganizerView = {
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
root: 'missing-root',
|
||||
entries: {},
|
||||
};
|
||||
const resources = new Set(['resource1']);
|
||||
|
||||
vi.spyOn(viewValidation, 'validateViewStructure').mockReturnValue({
|
||||
isValid: false,
|
||||
errors: { rootMissing: false },
|
||||
} as any);
|
||||
|
||||
const result = validateViewIntegrity({ view, resources });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.structureValidation).toEqual({
|
||||
isValid: false,
|
||||
errors: { rootMissing: false },
|
||||
});
|
||||
expect(result.errors.allRefsPresent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate view with complex structure without mocks', () => {
|
||||
const view = createView({
|
||||
root: 'folder1',
|
||||
entries: {
|
||||
folder1: createFolder('folder1', 'Root', ['folder2', 'ref1']),
|
||||
folder2: createFolder('folder2', 'Sub', ['ref2', 'ref3']),
|
||||
ref1: createRef('ref1', 'resource1'),
|
||||
ref2: createRef('ref2', 'resource2'),
|
||||
ref3: createRef('ref3', 'missing-resource'),
|
||||
},
|
||||
});
|
||||
const resources = new Set(['resource1', 'resource2']);
|
||||
|
||||
const result = validateViewIntegrity({ view, resources });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.allRefsPresent).toBe(false);
|
||||
});
|
||||
|
||||
it('should report missing resource refs', async () => {
|
||||
const view: OrganizerView = {
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
root: 'ref1',
|
||||
entries: {
|
||||
ref1: { id: 'ref1', type: 'ref', target: 'missing-resource' },
|
||||
},
|
||||
};
|
||||
const resources = new Set<string>();
|
||||
|
||||
vi.spyOn(viewValidation, 'validateViewStructure').mockReturnValue({
|
||||
isValid: true,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
const result = validateViewIntegrity({ view, resources });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.allRefsPresent).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOrganizerIntegrity', () => {
|
||||
it('should validate a valid organizer', async () => {
|
||||
const organizer: OrganizerV1 = {
|
||||
version: 1,
|
||||
resources: {
|
||||
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
|
||||
resource2: { id: 'resource2', type: 'vm', name: 'VM 1' },
|
||||
},
|
||||
views: {
|
||||
view1: {
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
root: 'folder1',
|
||||
entries: {
|
||||
folder1: {
|
||||
id: 'folder1',
|
||||
type: 'folder',
|
||||
name: 'Folder 1',
|
||||
children: ['ref1'],
|
||||
},
|
||||
ref1: { id: 'ref1', type: 'ref', target: 'resource1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(viewValidation, 'validateViewStructure').mockReturnValue({
|
||||
isValid: true,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
const result = await validateOrganizerIntegrity(organizer);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveProperty('view1');
|
||||
});
|
||||
|
||||
it('should validate multiple views independently', async () => {
|
||||
const organizer: OrganizerV1 = {
|
||||
version: 1,
|
||||
resources: {
|
||||
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
|
||||
},
|
||||
views: {
|
||||
view1: {
|
||||
id: 'view1',
|
||||
name: 'Valid View',
|
||||
root: 'ref1',
|
||||
entries: {
|
||||
ref1: { id: 'ref1', type: 'ref', target: 'resource1' },
|
||||
},
|
||||
},
|
||||
view2: {
|
||||
id: 'view2',
|
||||
name: 'Invalid View',
|
||||
root: 'ref2',
|
||||
entries: {
|
||||
ref2: { id: 'ref2', type: 'ref', target: 'missing-resource' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(viewValidation, 'validateViewStructure').mockReturnValue({
|
||||
isValid: true,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
const result = await validateOrganizerIntegrity(organizer);
|
||||
expect(result.isValid).toBe(false);
|
||||
|
||||
// The errors object has view names as keys and ValidationResult objects as values
|
||||
// Check that both views were processed
|
||||
expect(result.errors).toHaveProperty('view1');
|
||||
expect(result.errors).toHaveProperty('view2');
|
||||
|
||||
// Verify validation results
|
||||
const view1Result = result.errors.view1 as any;
|
||||
const view2Result = result.errors.view2 as any;
|
||||
expect(view1Result.isValid).toBe(true);
|
||||
expect(view2Result.isValid).toBe(false);
|
||||
expect(view2Result.errors.allRefsPresent).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty views', async () => {
|
||||
const organizer: OrganizerV1 = {
|
||||
version: 1,
|
||||
resources: {
|
||||
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
|
||||
},
|
||||
views: {},
|
||||
};
|
||||
|
||||
const result = await validateOrganizerIntegrity(organizer);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle validation errors gracefully', async () => {
|
||||
const organizer: OrganizerV1 = {
|
||||
version: 1,
|
||||
resources: {},
|
||||
views: {
|
||||
view1: {
|
||||
id: 'view1',
|
||||
name: 'Test View',
|
||||
root: 'ref1',
|
||||
entries: {
|
||||
ref1: { id: 'ref1', type: 'ref', target: 'resource1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(viewValidation, 'validateViewStructure').mockImplementation(() => {
|
||||
throw new Error('Validation error');
|
||||
});
|
||||
|
||||
const result = await validateOrganizerIntegrity(organizer);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
|
||||
it('should process all views even if some fail', async () => {
|
||||
const organizer: OrganizerV1 = {
|
||||
version: 1,
|
||||
resources: {
|
||||
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
|
||||
},
|
||||
views: {
|
||||
view1: {
|
||||
id: 'view1',
|
||||
name: 'View 1',
|
||||
root: 'ref1',
|
||||
entries: {
|
||||
ref1: { id: 'ref1', type: 'ref', target: 'resource1' },
|
||||
},
|
||||
},
|
||||
view2: {
|
||||
id: 'view2',
|
||||
name: 'View 2',
|
||||
root: 'ref2',
|
||||
entries: {
|
||||
ref2: { id: 'ref2', type: 'ref', target: 'resource1' },
|
||||
},
|
||||
},
|
||||
view3: {
|
||||
id: 'view3',
|
||||
name: 'View 3',
|
||||
root: 'ref3',
|
||||
entries: {
|
||||
ref3: { id: 'ref3', type: 'ref', target: 'missing' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let callCount = 0;
|
||||
vi.spyOn(viewValidation, 'validateViewStructure').mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 2) {
|
||||
throw new Error('Validation error');
|
||||
}
|
||||
return { isValid: true, errors: {} } as any;
|
||||
});
|
||||
|
||||
const result = await validateOrganizerIntegrity(organizer);
|
||||
expect(result.isValid).toBe(false);
|
||||
// Should have processed view1 and view3, but not view2 due to error
|
||||
const processedViews = Object.keys(result.errors);
|
||||
expect(processedViews.length).toBe(2);
|
||||
expect(processedViews).toContain('view1');
|
||||
expect(processedViews).toContain('view3');
|
||||
});
|
||||
|
||||
it('should handle organizer with no resources', async () => {
|
||||
const organizer: OrganizerV1 = {
|
||||
version: 1,
|
||||
resources: {},
|
||||
views: {
|
||||
view1: createView({
|
||||
root: 'folder1',
|
||||
entries: {
|
||||
folder1: createFolder('folder1', 'Empty Folder', []),
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await validateOrganizerIntegrity(organizer);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate organizer without mocks for integration test', async () => {
|
||||
const organizer: OrganizerV1 = {
|
||||
version: 1,
|
||||
resources: {
|
||||
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
|
||||
},
|
||||
views: {
|
||||
validView: createView({
|
||||
id: 'validView',
|
||||
root: 'ref1',
|
||||
entries: {
|
||||
ref1: createRef('ref1', 'resource1'),
|
||||
},
|
||||
}),
|
||||
invalidView: createView({
|
||||
id: 'invalidView',
|
||||
root: 'missing-root',
|
||||
entries: {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await validateOrganizerIntegrity(organizer);
|
||||
expect(result.isValid).toBe(false);
|
||||
|
||||
const validViewResult = result.errors.validView as any;
|
||||
const invalidViewResult = result.errors.invalidView as any;
|
||||
|
||||
expect(validViewResult.isValid).toBe(true);
|
||||
expect(invalidViewResult.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
102
api/src/unraid-api/organizer/organizer.validation.ts
Normal file
102
api/src/unraid-api/organizer/organizer.validation.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
createValidationProcessor,
|
||||
ResultInterpreters,
|
||||
} from '@app/core/utils/validation/validation-processor.js';
|
||||
import { validateViewStructure } from '@app/unraid-api/organizer/organizer-view.validation.js';
|
||||
import {
|
||||
OrganizerResource,
|
||||
OrganizerV1,
|
||||
OrganizerView,
|
||||
} from '@app/unraid-api/organizer/organizer.dto.js';
|
||||
import { batchProcess } from '@app/utils.js';
|
||||
|
||||
/**
|
||||
* Finds all refs from a view, including those nested in folders.
|
||||
* Returns a set of target resource IDs that the refs point to.
|
||||
*/
|
||||
export function getRefsFromViewEntries(entries: OrganizerView['entries']): Set<string> {
|
||||
const refs: Set<string> = new Set();
|
||||
const visited: Set<string> = new Set();
|
||||
|
||||
function collectRefs(entryId: string): void {
|
||||
if (visited.has(entryId)) return; // prevent infinite loops
|
||||
visited.add(entryId);
|
||||
|
||||
const entry = entries[entryId];
|
||||
if (!entry) return; // orphaned/missing node
|
||||
|
||||
if (entry.type === 'ref') {
|
||||
refs.add(entry.target);
|
||||
} else if (entry.type === 'folder' && Array.isArray(entry.children)) {
|
||||
for (const childId of entry.children) {
|
||||
collectRefs(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(entries).forEach((entryId) => {
|
||||
collectRefs(entryId);
|
||||
});
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all resources referenced by the view are present in the resources set.
|
||||
*
|
||||
* @param view - The view to check.
|
||||
* @param resources - The set of resources to check against.
|
||||
* @returns True if all resources referenced by the view are present in the resources set, false otherwise.
|
||||
*/
|
||||
export function validateViewResourceRefs(view: OrganizerView, resources: Set<string>): boolean {
|
||||
const refs = getRefsFromViewEntries(view.entries);
|
||||
return resources.isSupersetOf(refs);
|
||||
}
|
||||
|
||||
type ViewAndResources = {
|
||||
view: OrganizerView;
|
||||
resources: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the structural and referential integrity of a view.
|
||||
*
|
||||
* @param view - The view to check.
|
||||
* @param resources - The set of resources for the view.
|
||||
* @returns True if the view is valid, false otherwise.
|
||||
*/
|
||||
export const validateViewIntegrity = createValidationProcessor({
|
||||
steps: [
|
||||
{
|
||||
name: 'structureValidation',
|
||||
validator: ({ view }: ViewAndResources) => validateViewStructure(view),
|
||||
isError: ResultInterpreters.validationProcessor,
|
||||
alwaysFailFast: true,
|
||||
},
|
||||
{
|
||||
name: 'allRefsPresent',
|
||||
isError: ResultInterpreters.booleanMeansSuccess,
|
||||
validator: ({ view, resources }: ViewAndResources) =>
|
||||
validateViewResourceRefs(view, resources),
|
||||
},
|
||||
] as const,
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates the structural and referential integrity of an organizer.
|
||||
*
|
||||
* @param organizer - The organizer to check.
|
||||
* @returns True if the organizer is valid, false otherwise.
|
||||
*/
|
||||
export async function validateOrganizerIntegrity(organizer: OrganizerV1) {
|
||||
const resources = new Set(Object.keys(organizer.resources));
|
||||
const views = Object.entries(organizer.views);
|
||||
const validateView = async (view: OrganizerView) => validateViewIntegrity({ view, resources });
|
||||
|
||||
const { errorOccurred, data } = await batchProcess(views, async ([viewName, view]) => {
|
||||
return [viewName, await validateView(view)] as const;
|
||||
});
|
||||
return {
|
||||
isValid: !errorOccurred && data.every(([, result]) => result.isValid),
|
||||
errors: Object.fromEntries(data),
|
||||
};
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export class PluginService {
|
||||
}
|
||||
});
|
||||
|
||||
if (plugins.errorOccured) {
|
||||
if (plugins.errorOccurred) {
|
||||
PluginService.logger.warn(`Failed to load ${plugins.errors.length} plugins. Ignoring them.`);
|
||||
}
|
||||
PluginService.logger.log(`Loaded ${plugins.data.length} plugins.`);
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function batchProcess<Input, T>(items: Input[], action: (id: Input)
|
||||
data: successes,
|
||||
successes: successes.length,
|
||||
errors: errors,
|
||||
errorOccured: errors.length > 0,
|
||||
errorOccurred: errors.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user