249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
featureConfigOtelPayload,
|
|
runFeatureConfigSchemaValidation,
|
|
validateFeatureConfigSchema,
|
|
} from "./feature-config-schema-warning.mjs";
|
|
|
|
const roots: string[] = [];
|
|
const validatorScript = resolve(import.meta.dir, "feature-config-schema-warning.mjs");
|
|
const vendorBundle = resolve(import.meta.dir, "../../vendor/ajv-dist/8.17.1/ajv2020.min.js");
|
|
|
|
afterEach(() => {
|
|
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
|
|
});
|
|
|
|
test("valid nested Draft 2020-12 candidate remains warning-free", () => {
|
|
const root = fixture(schema({
|
|
tracePanel: feature("trace-panel", {
|
|
type: "object",
|
|
required: ["enabled", "views"],
|
|
properties: {
|
|
enabled: { type: "boolean" },
|
|
views: { type: "array", minItems: 1, items: { type: "string", pattern: "^[a-z-]+$" } },
|
|
},
|
|
additionalProperties: false,
|
|
}),
|
|
}), { tracePanel: '{"enabled":true,"views":["timeline"]}' });
|
|
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
|
warning: false,
|
|
blocking: false,
|
|
code: "feature-config-schema-valid",
|
|
errorCount: 0,
|
|
mutation: false,
|
|
candidate: { source: "rendered-workload-env", matchedValueCount: 1 },
|
|
});
|
|
});
|
|
|
|
test("repo-owned Ajv 2020 bundle performs real validation without workspace dependencies", () => {
|
|
const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "boolean" }) }), { tracePanel: "true" });
|
|
const run = runValidator(root, vendorBundle);
|
|
expect(run.status).toBe(0);
|
|
expect(run.stderr).toContain('"code":"feature-config-schema-valid"');
|
|
expect(run.stderr).not.toContain("feature-config-validator-unavailable");
|
|
});
|
|
|
|
test("missing or corrupt Ajv bundle is a typed warning and exits zero", () => {
|
|
const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "boolean" }) }), { tracePanel: "true" });
|
|
const missing = runValidator(root, join(root, "missing-ajv2020.min.js"));
|
|
expect(missing.status).toBe(0);
|
|
expect(missing.stderr).toContain('"code":"feature-config-validator-unavailable"');
|
|
|
|
const corruptBundle = join(root, "corrupt-ajv2020.min.js");
|
|
writeFileSync(corruptBundle, "this is not JavaScript");
|
|
const corrupt = runValidator(root, corruptBundle);
|
|
expect(corrupt.status).toBe(0);
|
|
expect(corrupt.stderr).toContain('"code":"feature-config-validator-unavailable"');
|
|
});
|
|
|
|
test("missing schema is a typed non-blocking warning", () => {
|
|
const root = fixture();
|
|
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
|
warning: true,
|
|
blocking: false,
|
|
code: "feature-config-schema-missing",
|
|
});
|
|
});
|
|
|
|
test("invalid JSON and unsupported Draft 2020-12 schema are typed warnings", () => {
|
|
const invalidJsonRoot = fixture("{");
|
|
expect(validateFeatureConfigSchema({ repoDir: invalidJsonRoot, renderedRoot: join(invalidJsonRoot, "rendered") }).code).toBe("feature-config-schema-invalid");
|
|
|
|
const invalidSchemaRoot = fixture(JSON.stringify({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { tracePanel: { type: "not-a-json-schema-type", "x-unidesk-feature": "trace-panel" } } }));
|
|
expect(validateFeatureConfigSchema({ repoDir: invalidSchemaRoot, renderedRoot: join(invalidSchemaRoot, "rendered") }).code).toBe("feature-config-schema-invalid");
|
|
});
|
|
|
|
test("missing rendered candidate is distinct from schema failure", () => {
|
|
const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "boolean" }) }));
|
|
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "missing") })).toMatchObject({
|
|
warning: true,
|
|
blocking: false,
|
|
code: "feature-config-input-missing",
|
|
});
|
|
});
|
|
|
|
test("nested value mismatch is bounded and does not disclose the value", () => {
|
|
const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "object", required: ["enabled"], properties: { enabled: { const: true } } }) }), {
|
|
tracePanel: '{"enabled":false,"secret":"do-not-print"}',
|
|
});
|
|
const result = validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") });
|
|
expect(result).toMatchObject({ warning: true, blocking: false, code: "feature-config-value-mismatch", valuesPrinted: false });
|
|
expect(result.firstError).not.toContain("do-not-print");
|
|
});
|
|
|
|
test("duplicate feature properties are reported without blocking", () => {
|
|
const root = fixture(schema({
|
|
tracePanel: feature("trace-panel", { type: "boolean" }),
|
|
tracePanelAlias: feature("trace-panel", { type: "boolean" }),
|
|
}));
|
|
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
|
warning: true,
|
|
blocking: false,
|
|
code: "feature-config-variable-duplicate",
|
|
errorCount: 1,
|
|
});
|
|
});
|
|
|
|
test("actual OTLP span and event carry redacted validation attributes", () => {
|
|
const root = fixture();
|
|
const validation = validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") });
|
|
const payload = featureConfigOtelPayload(validation, {
|
|
serviceName: "unidesk-cicd",
|
|
consumer: "hwlab-nc01-v03",
|
|
samplingRatio: 1,
|
|
nowMs: 1,
|
|
traceId: "1".repeat(32),
|
|
spanId: "2".repeat(16),
|
|
});
|
|
const span = payload.resourceSpans[0].scopeSpans[0].spans[0];
|
|
expect(span.name).toBe("cicd.feature_config.schema");
|
|
expect(span.events[0].name).toBe("feature-config-schema-validation");
|
|
expect(JSON.stringify(payload)).not.toContain("do-not-print");
|
|
expect(JSON.stringify(payload)).toContain("unidesk.otel.sampling_ratio");
|
|
});
|
|
|
|
test("missing endpoint remains a non-blocking OTel warning", async () => {
|
|
const root = fixture();
|
|
const result = await runFeatureConfigSchemaValidation({
|
|
repoDir: root,
|
|
renderedRoot: join(root, "rendered"),
|
|
otelEndpoint: "",
|
|
otelTimeoutMs: 300,
|
|
});
|
|
expect(result.otelWarning).toMatchObject({
|
|
event: "cicd.otel.export",
|
|
warning: true,
|
|
blocking: false,
|
|
causeCode: "ENDPOINT_MISSING",
|
|
timeoutMs: 300,
|
|
});
|
|
});
|
|
|
|
test("export failure uses configured timeout and remains non-blocking", async () => {
|
|
const root = fixture();
|
|
let requestSignal: AbortSignal | null = null;
|
|
const result = await runFeatureConfigSchemaValidation({
|
|
repoDir: root,
|
|
renderedRoot: join(root, "rendered"),
|
|
otelEndpoint: "http://otel.example.test/v1/traces",
|
|
otelServiceName: "unidesk-cicd",
|
|
otelSamplingRatio: 1,
|
|
otelTimeoutMs: 300,
|
|
fetchImpl: async (_input: URL | RequestInfo, init?: RequestInit) => {
|
|
requestSignal = init?.signal ?? null;
|
|
throw new Error("collector unavailable");
|
|
},
|
|
});
|
|
expect(requestSignal).not.toBeNull();
|
|
expect(result.otelWarning).toMatchObject({
|
|
event: "cicd.otel.export",
|
|
warning: true,
|
|
blocking: false,
|
|
causeCode: "EXPORT_FAILED",
|
|
timeoutMs: 300,
|
|
});
|
|
});
|
|
|
|
test("unexpected validator failure is typed and never rejects", async () => {
|
|
const options: Record<string, unknown> = {
|
|
repoDir: fixture(schema({ webProbeSentinel: feature("web-probe-sentinel", { type: "object" }) })),
|
|
renderedRoot: "unused",
|
|
otelEndpoint: "",
|
|
otelTimeoutMs: 300,
|
|
};
|
|
Object.defineProperty(options, "candidate", { get: () => { throw new Error("unexpected candidate failure"); } });
|
|
const result = await runFeatureConfigSchemaValidation(options);
|
|
expect(result.validation).toMatchObject({
|
|
warning: true,
|
|
blocking: false,
|
|
code: "feature-config-validator-failure",
|
|
mutation: false,
|
|
});
|
|
expect(result.otelWarning).toMatchObject({ warning: true, blocking: false, causeCode: "ENDPOINT_MISSING" });
|
|
});
|
|
|
|
test("candidate scan ignores directory symlinks and bounds directory traversal", () => {
|
|
const root = fixture(schema({ webProbeSentinel: feature("web-probe-sentinel", { type: "boolean" }) }), {
|
|
webProbeSentinel: "true",
|
|
});
|
|
symlinkSync(join(root, "rendered"), join(root, "rendered", "loop"), "dir");
|
|
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
|
warning: false,
|
|
code: "feature-config-schema-valid",
|
|
});
|
|
for (let index = 0; index <= 2_000; index += 1) mkdirSync(join(root, "rendered", `directory-${index}`));
|
|
expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({
|
|
warning: true,
|
|
blocking: false,
|
|
code: "feature-config-validator-failure",
|
|
firstError: "rendered workload directory limit exceeded: 2000",
|
|
});
|
|
});
|
|
|
|
function fixture(content?: string, env: Record<string, string> = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), "feature-config-schema-"));
|
|
roots.push(root);
|
|
mkdirSync(join(root, "rendered"));
|
|
if (content !== undefined) {
|
|
mkdirSync(join(root, "config"));
|
|
writeFileSync(join(root, "config", "feature-config.schema.json"), content);
|
|
}
|
|
writeFileSync(join(root, "rendered", "deployment.yaml"), deployment(env));
|
|
return root;
|
|
}
|
|
|
|
function runValidator(root: string, bundlePath: string) {
|
|
return spawnSync(process.execPath, [validatorScript, "--rendered-root", join(root, "rendered")], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
UNIDESK_AJV2020_BUNDLE: bundlePath,
|
|
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "",
|
|
OTEL_EXPORTER_TIMEOUT_MS: "300",
|
|
},
|
|
});
|
|
}
|
|
|
|
function schema(properties: Record<string, unknown>) {
|
|
return JSON.stringify({
|
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
type: "object",
|
|
properties,
|
|
additionalProperties: false,
|
|
});
|
|
}
|
|
|
|
function feature(name: string, value: Record<string, unknown>) {
|
|
return { ...value, "x-unidesk-feature": name };
|
|
}
|
|
|
|
function deployment(env: Record<string, string>) {
|
|
const rows = Object.entries(env).map(([name, value]) => ` - name: ${name}\n value: ${JSON.stringify(value)}`).join("\n");
|
|
return `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: fixture\nspec:\n template:\n spec:\n containers:\n - name: fixture\n env:\n${rows || " []"}\n`;
|
|
}
|