Core: reject U+FEFF in emoji icon payloads to match the JS resolver
Python's validate_icon accepted emoji: (not str.isspace(), category Cf) while the generated JS isEmojiPayload rejects it via \s, so the backend could store a key the admin and panel silently fall back on. An exhaustive code-point sweep shows U+FEFF is the only divergence; add it to the forbidden set. ZWJ U+200D stays valid. Add tools/icons/emoji-payload-cases.json, a shared verdict table asserted by both tests/test_core_icons.py (validate_icon) and the admin Vitest suite (resolveIcon), so any future divergence fails one of the suites. Task: 8286a894-133f-4582-90fb-88f47eef6628 (review finding NQ-ICON-01) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,18 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
// The offline generator is the only writer of both registry artefacts.
|
||||
import { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
|
||||
import { FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
|
||||
import { EMOJI_MAX_CODE_POINTS, FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
|
||||
|
||||
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
|
||||
|
||||
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8")) as Curated;
|
||||
|
||||
type EmojiPayloadCase = { payload: string; valid: boolean; why: string };
|
||||
|
||||
const emojiPayloadCases = JSON.parse(
|
||||
readFileSync(join(REPO_ROOT, "tools", "icons", "emoji-payload-cases.json"), "utf8"),
|
||||
) as { cases: EmojiPayloadCase[] };
|
||||
|
||||
describe("shared icon registry — one source of truth", () => {
|
||||
it("both committed artefacts are byte-identical to a fresh generation", async () => {
|
||||
const fresh = await renderRegistry();
|
||||
@@ -86,6 +92,24 @@ describe("resolveIcon", () => {
|
||||
}
|
||||
expect(FALLBACK_ICON).toEqual({ kind: "fallback", key: null });
|
||||
});
|
||||
|
||||
// tests/test_core_icons.py asserts the same table against the backend's
|
||||
// validate_icon, so the two emoji rules cannot drift apart silently.
|
||||
it.each(emojiPayloadCases.cases)("emoji verdict matches the shared table: $why", ({ payload, valid }) => {
|
||||
const value = `emoji:${payload}`;
|
||||
expect(resolveIcon(value)).toEqual(valid ? { kind: "emoji", key: value, text: payload } : FALLBACK_ICON);
|
||||
});
|
||||
|
||||
it("the shared emoji table covers the contract edges", () => {
|
||||
const verdicts = new Map(emojiPayloadCases.cases.map(({ payload, valid }) => [payload, valid]));
|
||||
expect(verdicts.get("\ufeff")).toBe(false);
|
||||
expect(verdicts.get("👨\u200d👩\u200d👧\u200d👦")).toBe(true);
|
||||
expect(verdicts.get("🐕 ")).toBe(false);
|
||||
expect(verdicts.get("🐕\u0000")).toBe(false);
|
||||
for (const markup of ["<b>", "a>b", "a&b"]) expect(verdicts.get(markup)).toBe(false);
|
||||
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS))).toBe(true);
|
||||
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS + 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function filesUnder(dir: string): string[] {
|
||||
|
||||
Reference in New Issue
Block a user