fix: harden dashboard model validation
This commit is contained in:
parent
7cfd5b4069
commit
74651a49de
4 changed files with 288 additions and 13 deletions
|
|
@ -5,10 +5,20 @@ import {
|
|||
type DashboardDocument,
|
||||
} from "./schema";
|
||||
|
||||
type DashboardValidationIssue = ErrorObject | SemanticValidationIssue;
|
||||
|
||||
interface SemanticValidationIssue {
|
||||
instancePath: string;
|
||||
schemaPath: string;
|
||||
keyword: "semantic";
|
||||
params: Record<string, unknown>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DashboardValidationFailure {
|
||||
valid: false;
|
||||
errors: string[];
|
||||
details: ErrorObject[];
|
||||
details: DashboardValidationIssue[];
|
||||
}
|
||||
|
||||
export interface DashboardValidationSuccess {
|
||||
|
|
@ -24,27 +34,46 @@ const ajv = addFormats(
|
|||
new Ajv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
strictNumbers: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);
|
||||
|
||||
export function formatValidationErrors(errors: ErrorObject[] = []): string[] {
|
||||
export function formatValidationErrors(
|
||||
errors: DashboardValidationIssue[] = [],
|
||||
): string[] {
|
||||
return errors.map((error) => {
|
||||
const path = error.instancePath || "/";
|
||||
const suffix = formatErrorParams(error);
|
||||
const message = error.message || "is invalid";
|
||||
return `${path} ${message}`;
|
||||
return `${path} ${message}${suffix}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDashboardDocument(
|
||||
value: unknown,
|
||||
): DashboardValidationResult {
|
||||
const finiteNumberIssues: SemanticValidationIssue[] = [];
|
||||
collectFiniteNumberIssues(value, "", finiteNumberIssues);
|
||||
|
||||
if (validateDashboard(value)) {
|
||||
return { valid: true, data: value };
|
||||
const semanticIssues = [
|
||||
...finiteNumberIssues,
|
||||
...validateSemanticRules(value),
|
||||
];
|
||||
if (semanticIssues.length === 0) {
|
||||
return { valid: true, data: value };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: formatValidationErrors(semanticIssues),
|
||||
details: semanticIssues,
|
||||
};
|
||||
}
|
||||
|
||||
const details = [...(validateDashboard.errors || [])];
|
||||
const details = [...(validateDashboard.errors || []), ...finiteNumberIssues];
|
||||
return {
|
||||
valid: false,
|
||||
errors: formatValidationErrors(details),
|
||||
|
|
@ -64,3 +93,165 @@ export function assertDashboardDocument(
|
|||
export function isDashboardDocument(value: unknown): value is DashboardDocument {
|
||||
return validateDashboardDocument(value).valid;
|
||||
}
|
||||
|
||||
function formatErrorParams(error: DashboardValidationIssue): string {
|
||||
if (error.keyword === "additionalProperties") {
|
||||
const additionalProperty = error.params.additionalProperty;
|
||||
return typeof additionalProperty === "string"
|
||||
? `: ${additionalProperty}`
|
||||
: "";
|
||||
}
|
||||
|
||||
if (error.keyword === "semantic") {
|
||||
const id = error.params.id;
|
||||
const ref = error.params.ref;
|
||||
if (typeof id === "string") return `: ${id}`;
|
||||
if (typeof ref === "string") return `: ${ref}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateSemanticRules(document: DashboardDocument): SemanticValidationIssue[] {
|
||||
const issues: SemanticValidationIssue[] = [];
|
||||
|
||||
collectDuplicateIdIssues("telemetry", document.telemetry, issues);
|
||||
collectDuplicateIdIssues("serviceGroups", document.serviceGroups, issues);
|
||||
collectDuplicateIdIssues("statusStrips", document.statusStrips, issues);
|
||||
collectDuplicateIdIssues("modules", document.modules || [], issues);
|
||||
|
||||
document.serviceGroups.forEach((group, groupIndex) => {
|
||||
collectDuplicateIdIssues(
|
||||
`serviceGroups/${groupIndex}/services`,
|
||||
group.services,
|
||||
issues,
|
||||
);
|
||||
});
|
||||
|
||||
collectMissingReferenceIssues(
|
||||
"layout/telemetry",
|
||||
document.layout.telemetry,
|
||||
new Set(document.telemetry.map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
collectMissingReferenceIssues(
|
||||
"layout/serviceGroups",
|
||||
document.layout.serviceGroups,
|
||||
new Set(document.serviceGroups.map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
collectMissingReferenceIssues(
|
||||
"layout/statusStrips",
|
||||
document.layout.statusStrips,
|
||||
new Set(document.statusStrips.map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
collectMissingReferenceIssues(
|
||||
"layout/modules",
|
||||
document.layout.modules || [],
|
||||
new Set((document.modules || []).map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
|
||||
document.telemetry.forEach((card, index) => {
|
||||
const warning = card.thresholds?.warning;
|
||||
const danger = card.thresholds?.danger;
|
||||
if (warning !== undefined && danger !== undefined && warning > danger) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/telemetry/${index}/thresholds`,
|
||||
"warning threshold must be less than or equal to danger threshold",
|
||||
{ id: card.id },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function collectFiniteNumberIssues(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
issues.push(
|
||||
semanticIssue(path || "/", "must be a finite JSON number", {}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
collectFiniteNumberIssues(item, `${path}/${index}`, issues);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
Object.entries(value).forEach(([key, item]) => {
|
||||
collectFiniteNumberIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectDuplicateIdIssues(
|
||||
collectionPath: string,
|
||||
items: Array<{ id: string }>,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
items.forEach((item, index) => {
|
||||
if (seen.has(item.id)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/${collectionPath}/${index}/id`,
|
||||
"must be unique within its collection",
|
||||
{ id: item.id },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
seen.add(item.id);
|
||||
});
|
||||
}
|
||||
|
||||
function collectMissingReferenceIssues(
|
||||
layoutPath: string,
|
||||
refs: string[],
|
||||
validIds: Set<string>,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
refs.forEach((ref, index) => {
|
||||
if (!validIds.has(ref)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/${layoutPath}/${index}`,
|
||||
"must reference an existing item",
|
||||
{ ref },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function semanticIssue(
|
||||
instancePath: string,
|
||||
message: string,
|
||||
params: Record<string, unknown>,
|
||||
): SemanticValidationIssue {
|
||||
return {
|
||||
instancePath,
|
||||
schemaPath: "",
|
||||
keyword: "semantic",
|
||||
params,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeJsonPointer(value: string): string {
|
||||
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue