import { describe, expect, it } from "vitest"; import type { AnimationDefinition } from "../../src/animation/animation.types"; import { AnimationValidationError, buildAnimationCss, validateAnimationDefinitions, } from "../../src/animation/validation"; const animation: AnimationDefinition = { id: "pulse", name: "Pulse", targetNodeKey: "id:shape", property: "opacity", kind: "style", enabled: true, keyframes: [ { offset: 0, value: "0.25", easing: "cubic-bezier(0.2, 0, 0.8, 1)" }, { offset: 1, value: "calc(1 - 0.1)" }, ], timing: { durationMs: 500, delayMs: -20, iterations: 2.5, direction: "alternate", fillMode: "both", easing: "steps(4, jump-end)", }, }; function invalid(mutator: (value: AnimationDefinition) => void): () => void { const value = structuredClone(animation); mutator(value); return () => validateAnimationDefinitions([value]); } describe("application-owned animation validation", () => { it("builds source-safe CSS through the same validated serializer", () => { const css = buildAnimationCss([animation], { keyframeNamePrefix: "svg-tools", selectorFor: () => "#shape", }); expect(css).toContain("@keyframes svg-tools-0"); expect(css).toContain("#shape { animation: svg-tools-0 500ms"); expect(css).toContain( "animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1)", ); }); it.each([ [ "NaN offset", invalid((value) => (value.keyframes[0]!.offset = Number.NaN)), ], ["large offset", invalid((value) => (value.keyframes[0]!.offset = 1.1))], [ "infinite duration", invalid((value) => (value.timing.durationMs = Number.POSITIVE_INFINITY)), ], [ "infinite delay", invalid((value) => (value.timing.delayMs = Number.NEGATIVE_INFINITY)), ], [ "infinite iterations", invalid((value) => (value.timing.iterations = Number.POSITIVE_INFINITY)), ], ["negative iterations", invalid((value) => (value.timing.iterations = -1))], ])("rejects non-finite or out-of-range timing: %s", (_label, action) => { expect(action).toThrow(AnimationValidationError); }); it.each([ ["property", invalid((value) => (value.property = "opacity; stroke: red"))], [ "declaration", invalid((value) => (value.keyframes[0]!.value = "0; stroke: red")), ], [ "closing style element", invalid( (value) => (value.keyframes[0]!.value = "0"), ), ], [ "external URL", invalid( (value) => (value.keyframes[0]!.value = "url(https://attacker.invalid/a.svg)"), ), ], [ "escaped external URL", invalid( (value) => (value.keyframes[0]!.value = String.raw`u\72 l("https://attacker.invalid/a.svg")`), ), ], [ "timing easing", invalid((value) => (value.timing.easing = "linear; stroke: red")), ], [ "keyframe easing", invalid( (value) => (value.keyframes[0]!.easing = "linear } body { color:red"), ), ], ])("rejects CSS injection through %s", (_label, action) => { expect(action).toThrow(AnimationValidationError); }); it("validates disabled definitions instead of retaining dormant payloads", () => { const value = structuredClone(animation); value.enabled = false; value.keyframes[0]!.value = "url(https://attacker.invalid/payload)"; expect(() => buildAnimationCss([value], { keyframeNamePrefix: "svg-tools", selectorFor: () => "#shape", }), ).toThrow(AnimationValidationError); }); });