From 2e6c1519023ad005085bfc9f01d8fd54566fcec8 Mon Sep 17 00:00:00 2001 From: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:22:59 +0800 Subject: [PATCH] fix(providers): support data URL icons for compatible nodes (#9555) Co-authored-by: xz-dev Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../modals/EditCompatibleNodeModal.tsx | 10 +- .../components/AddCompatibleProviderModal.tsx | 10 +- src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/shared/validation/iconUrl.ts | 221 +++++++++++++++++ src/shared/validation/schemas/provider.ts | 17 +- .../unit/provider-icon-url-validator.test.ts | 222 ++++++++++++++++++ .../add-compatible-provider-icon-url.test.tsx | 140 +++++++++++ .../ui/edit-compatible-node-icon-url.test.tsx | 131 +++++++++++ 9 files changed, 742 insertions(+), 11 deletions(-) create mode 100644 src/shared/validation/iconUrl.ts create mode 100644 tests/unit/provider-icon-url-validator.test.ts create mode 100644 tests/unit/ui/add-compatible-provider-icon-url.test.tsx create mode 100644 tests/unit/ui/edit-compatible-node-icon-url.test.tsx diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx index 2c545471e1..e7c67838f4 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import { Button, Badge, Input, Modal, Select, Toggle } from "@/shared/components"; +import { isValidProviderIconUrl } from "@/shared/validation/iconUrl"; import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "../../providerDetailConstants"; import NewApiAggregatorFields from "./NewApiAggregatorFields"; interface EditCompatibleNodeModalNode { @@ -57,6 +58,7 @@ export default function EditCompatibleNodeModal({ method?: string | null; }>(null); const [showAdvanced, setShowAdvanced] = useState(false); + const [iconUrlError, setIconUrlError] = useState(null); useEffect(() => { if (node) { @@ -101,6 +103,12 @@ export default function EditCompatibleNodeModal({ const handleSubmit = async () => { if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + const iconUrl = formData.iconUrl.trim(); + if (!isValidProviderIconUrl(iconUrl)) { + setIconUrlError(t("iconUrlInvalid")); + return; + } + setIconUrlError(null); setSaving(true); try { const payload: any = { @@ -247,7 +255,7 @@ export default function EditCompatibleNodeModal({ value={formData.iconUrl} onChange={(e) => setFormData({ ...formData, iconUrl: e.target.value })} placeholder="https://example.com/logo.png" - hint={t("iconUrlHint")} + hint={iconUrlError ?? t("iconUrlHint")} /> (null); const [showAdvanced, setShowAdvanced] = useState(false); + const [iconUrlError, setIconUrlError] = useState(null); const apiTypeOptions = useMemo( () => [ @@ -188,6 +190,12 @@ export default function AddCompatibleProviderModal({ const handleSubmit = async () => { if (!hasRequiredFields) return; + const iconUrl = formData.iconUrl.trim(); + if (!isValidProviderIconUrl(iconUrl)) { + setIconUrlError(t("iconUrlInvalid")); + return; + } + setIconUrlError(null); setSubmitting(true); try { const body: Record = { @@ -328,7 +336,7 @@ export default function AddCompatibleProviderModal({ value={formData.iconUrl} onChange={(e) => setFormData({ ...formData, iconUrl: e.target.value })} placeholder="https://example.com/logo.png" - hint={t("iconUrlHint")} + hint={iconUrlError ?? t("iconUrlHint")} /> `src` (see ProviderIcon.tsx) exactly + * like any other image data URL, with the same onError fallback. + * + * Rejected: + * - malformed values, unsafe schemes (javascript:, ftp:, …) + * - non-image data URLs (`data:text/html;base64,…`, `data:application/…`) + * - data URLs without `;base64` (`data:image/png,…`) + * - data URLs with empty or invalid base64 payloads + * - payloads containing whitespace or other non-base64 characters + * (strict stored-payload validation — no whitespace stripping) + */ +export const MAX_ICON_URL_LENGTH = 2000; +// A real base64 icon legitimately exceeds the http(s) 2000-char cap — a small +// PNG/WebP badge is typically tens of KB of base64 text. Bound the data URL to +// a generous but strictly-bounded ceiling (256 KB base64 text ≈ a sizeable +// icon) so garbage input is still rejected while realistic icons are accepted. +// The DB column is plain TEXT and the request-body limit is 10 MB, so this cap +// is the governing constraint for data URLs. +export const MAX_ICON_DATA_URL_LENGTH = 256 * 1024; + +// HTTP token code points (RFC 7230 `tchar` / WHATWG "HTTP token code points") — +// the complete set `!#$%&'*+-.^_`|~` plus alphanumerics. Subtypes and parameter +// attributes are validated against this full alphabet, not a partial subset. +const HTTP_TOKEN_RE = /^[-!#$%&'*+.^_`|~A-Za-z0-9]+$/; + +const HTTP_SCHEME_RE = /^https?:\/\//i; +const DATA_SCHEME_RE = /^data:/i; + +// Terminal base64 marker (RFC 2397 — it comes AFTER all media-type parameters). +const BASE64_MARKER = ";base64"; +const DATA_SCHEME_LENGTH = "data:".length; + +export function isValidProviderIconUrl(value: string): boolean { + const trimmed = value.trim(); + if (trimmed === "") return true; + + if (DATA_SCHEME_RE.test(trimmed)) { + if (trimmed.length > MAX_ICON_DATA_URL_LENGTH) return false; + return isValidDataIconUrl(trimmed); + } + + // http(s) branch — preserves the pre-existing semantics and 2000-char cap. + if (trimmed.length > MAX_ICON_URL_LENGTH) return false; + if (!HTTP_SCHEME_RE.test(trimmed)) return false; + try { + const parsed = new URL(trimmed); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +/** + * Validates `data:image/[;attr=value]*;base64,` (RFC 2397). + * + * Steps: + * 1. Overall data: scheme is checked with the native URL parser. + * 2. The first comma splits the metadata header from the base64 payload. + * 3. The metadata segment must end (case-insensitively) in the terminal + * `;base64` marker. + * 4. The media type + parameters before the marker are parsed with a complete + * standard MIME grammar (RFC 2045/6838 + RFC 7230 tokens): type must be + * `image` (case-insensitive), subtype must be a non-empty HTTP token, and + * every parameter must be `attr=value` with a token attribute and a value + * that is either a token or a quoted-string. Valueless parameters + * (`;foo`) are rejected — RFC 2397 requires `parameter := attribute "=" + * value`. + * 5. The payload is validated strictly as RFC 4648 base64 (correct alphabet + * and padding, no whitespace). + * + * Quoted-string parameter values are accepted per RFC 2045; because the header + * is split at the FIRST comma, a quoted-string value containing a literal + * comma is conservatively rejected. + */ +function isValidDataIconUrl(value: string): boolean { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + if (parsed.protocol !== "data:") return false; + + const commaIndex = value.indexOf(","); + if (commaIndex === -1) return false; + const metadata = value.slice(0, commaIndex); + const payload = value.slice(commaIndex + 1); + if (payload.length === 0) return false; + + if (!metadata.toLowerCase().endsWith(BASE64_MARKER)) return false; + const mediaTypeWithParams = metadata.slice( + DATA_SCHEME_LENGTH, + metadata.length - BASE64_MARKER.length + ); + + if (!isValidImageMediaType(mediaTypeWithParams)) return false; + + // Strict stored-payload validation: no whitespace stripping. The payload + // must be exactly valid base64 (RFC 4648 alphabet, correct padding). + return z.base64().safeParse(payload).success; +} + +/** + * Parses `image/[;attr=value]*` with the complete MIME grammar: + * type must be exactly `image` (case-insensitive), subtype a non-empty HTTP + * token, followed by zero or more `;attr=value` parameters whose attribute is + * an HTTP token and whose value is either an HTTP token or a quoted-string + * (RFC 2045). Whitespace, valueless parameters, empty attributes/values, and + * trailing garbage are rejected. + */ +function isValidImageMediaType(input: string): boolean { + if (input.length === 0) return false; + + const slashIndex = input.indexOf("/"); + if (slashIndex <= 0 || slashIndex === input.length - 1) return false; + if (input.slice(0, slashIndex).toLowerCase() !== "image") return false; + + let position = slashIndex + 1; + let subtype = ""; + while (position < input.length && input[position] !== ";") { + subtype += input[position]; + ++position; + } + if (subtype.length === 0 || !HTTP_TOKEN_RE.test(subtype)) return false; + + while (position < input.length) { + if (input[position] !== ";") return false; + ++position; + + let attribute = ""; + while (position < input.length && input[position] !== "=" && input[position] !== ";") { + attribute += input[position]; + ++position; + } + if (attribute.length === 0 || !HTTP_TOKEN_RE.test(attribute)) return false; + // Valueless parameter — rejected per RFC 2397 (`attribute "=" value`). + if (position >= input.length || input[position] !== "=") return false; + ++position; + + const valueEnd = parseParameterValue(input, position); + if (valueEnd === null) return false; + position = valueEnd; + if (position < input.length && input[position] !== ";") return false; + } + + return true; +} + +/** + * Consumes a parameter value starting at `start` and returns the position just + * after it, or null on failure. A value is either an HTTP token or a + * quoted-string (RFC 2045 `value := token / quoted-string`). + */ +function parseParameterValue(input: string, start: number): number | null { + if (start >= input.length) return null; + let position = start; + + if (input[start] === '"') { + // quoted-string: DQUOTE *( qdtext / quoted-pair ) DQUOTE + let position = start + 1; + while (position < input.length) { + const char = input[position]; + if (char === '"') return position + 1; + if (char === "\\") { + // quoted-pair: "\" HTAB / SP / VCHAR / obs-text + if (position + 1 >= input.length) return null; + position += 2; + continue; + } + // qdtext: HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text + const code = char.charCodeAt(0); + if ( + char === "\t" || + code === 0x20 || + code === 0x21 || + (code >= 0x23 && code <= 0x5b) || + (code >= 0x5d && code <= 0x7e) || + code >= 0x80 + ) { + ++position; + continue; + } + return null; + } + return null; // unterminated quote + } + + let value = ""; + while (position < input.length && input[position] !== ";") { + const char = input[position]; + if (char === "%") { + const escape = input.slice(position + 1, position + 3); + if (!/^[0-9A-Fa-f]{2}$/.test(escape)) return null; + value += `%${escape}`; + position += 3; + continue; + } + value += char; + ++position; + } + if (value.length === 0 || !HTTP_TOKEN_RE.test(value)) return null; + return position; +} diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index bd70583f22..0a1902500e 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -23,21 +23,20 @@ import { export { validateProviderSpecificData }; +import { isValidProviderIconUrl } from "@/shared/validation/iconUrl"; + // ──── Provider Schemas ──── -// #2166: shared optional remote icon URL for compatible provider nodes. Empty string -// is accepted as "no custom icon" (clears any previously stored value). Restricted to -// http(s) — `.url()` alone also accepts syntactically-valid-but-unsafe schemes like -// `javascript:`/`data:`, which we never want persisted as an . +// #2166 + data-URL support: shared optional remote icon URL for compatible provider +// nodes. Empty string is accepted as "no custom icon". Accepts http(s) URLs AND +// valid `data:image/*;base64,...` data URLs; rejects malformed/unsafe schemes. The +// validator lives in src/shared/validation/iconUrl.ts so UI and API never diverge. const providerNodeIconUrlSchema = z .string() .trim() .max(2000) - .refine((value) => value === "" || z.string().url().safeParse(value).success, { - message: "Icon URL must be a valid URL", - }) - .refine((value) => value === "" || /^https?:\/\//i.test(value), { - message: "Icon URL must be a valid http:// or https:// URL", + .refine((value) => isValidProviderIconUrl(value), { + message: "Icon URL must be a valid http(s) or data:image/*;base64 URL", }) .optional(); diff --git a/tests/unit/provider-icon-url-validator.test.ts b/tests/unit/provider-icon-url-validator.test.ts new file mode 100644 index 0000000000..1698423659 --- /dev/null +++ b/tests/unit/provider-icon-url-validator.test.ts @@ -0,0 +1,222 @@ +// Focused tests for the shared icon-URL validator (src/shared/validation/iconUrl.ts) +// and the data-URL acceptance in createProviderNodeSchema/updateProviderNodeSchema. +// Mirrors the acceptance criteria: valid http(s) + valid `data:image/*;base64` URLs +// accepted; malformed values, non-image data URLs, non-base64 image data URLs, and +// unsafe schemes rejected. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isValidProviderIconUrl } from "../../src/shared/validation/iconUrl.ts"; +import { + createProviderNodeSchema, + updateProviderNodeSchema, +} from "../../src/shared/validation/schemas.ts"; + +// `iVBORw0KGgo=...` is a base64-encoded (truncated but structurally valid) PNG header. +const VALID_PNG_DATA_URL = "data:image/png;base64,iVBORw0KGgo="; +const VALID_SVG_DATA_URL = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4="; +const VALID_XICON_DATA_URL = "data:image/x-icon;base64,QUJDRA=="; +const VALID_JPEG_DATA_URL = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; +const VALID_HTTP = "https://example.com/logo.png"; +const VALID_HTTP_2000 = "https://example.com/" + "a".repeat(1970) + ".png"; + +// ---- shared validator ---- +test("isValidProviderIconUrl accepts empty and http(s)", () => { + assert.equal(isValidProviderIconUrl(""), true); + assert.equal(isValidProviderIconUrl(VALID_HTTP), true); + assert.equal(isValidProviderIconUrl("http://example.com/logo.png"), true); +}); + +test("isValidProviderIconUrl accepts valid data:image/*;base64 URLs", () => { + assert.equal(isValidProviderIconUrl(VALID_PNG_DATA_URL), true); + assert.equal(isValidProviderIconUrl(VALID_SVG_DATA_URL), true); + assert.equal(isValidProviderIconUrl(VALID_XICON_DATA_URL), true); + assert.equal(isValidProviderIconUrl(VALID_JPEG_DATA_URL), true); +}); + +test("data-URL scheme / media type / base64 marker are case-insensitive (RFC 2397)", () => { + assert.equal(isValidProviderIconUrl("DATA:image/png;BASE64,iVBORw0KGgo="), true); + assert.equal(isValidProviderIconUrl("Data:image/png;Base64,iVBORw0KGgo="), true); + assert.equal(isValidProviderIconUrl("data:IMAGE/PNG;base64,iVBORw0KGgo="), true); + assert.equal(isValidProviderIconUrl("data:image/Png;base64,iVBORw0KGgo="), true); +}); + +test("data-URL accepts valid media-type parameters before the terminal ;base64", () => { + assert.equal(isValidProviderIconUrl("data:image/svg+xml;charset=utf-8;base64,PHN2Zy8+"), true); + assert.equal(isValidProviderIconUrl("data:image/svg+xml;charset=UTF-8;base64,PHN2Zy8+"), true); + assert.equal(isValidProviderIconUrl("data:image/png;foo=bar;base64,QUJD"), true); + // Review case: percent-encoded token parameter value (`%` is an HTTP token char). + assert.equal(isValidProviderIconUrl("data:image/png;name=foo%20bar;base64,QUJD"), true); + assert.equal(isValidProviderIconUrl("data:image/png;name=foo%ZZ;base64,QUJD"), false); + // Multiple parameters + RFC 2045 quoted-string values are accepted. + assert.equal( + isValidProviderIconUrl('data:image/png;name="foo bar";charset=utf-8;base64,QUJD'), + true + ); + assert.equal(isValidProviderIconUrl('data:image/png;name="a;b";base64,QUJD'), true); + // Escaped quote inside a quoted-string value (quoted-pair). + assert.equal(isValidProviderIconUrl('data:image/png;name="say \\"hi\\"";base64,QUJD'), true); +}); + +test('data-URL rejects valueless parameters per RFC 2397 (attribute "=" value)', () => { + const invalid = [ + "data:image/png;foo;base64,QUJD", // review case: valueless parameter + "data:image/png;base64;base64,QUJD", // second ;base64 is a valueless parameter + "data:image/png;=bar;base64,QUJD", // empty attribute + "data:image/png;foo=;base64,QUJD", // empty value + "data:image/png;foo=bar;baz;base64,QUJD", // valueless among valued params + ]; + for (const v of invalid) { + assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`); + } +}); + +test("data-URL media types use the full HTTP token alphabet (RFC 7230/6838)", () => { + // All `tchar` code points in one subtype; plus common image subtypes that use + // `+`, `.` and `-` (the previous grammar only allowed `[a-z0-9.+-]`). + const tokenAlphabet = "!#$%&'*+-.^_`|~09"; + assert.equal(isValidProviderIconUrl(`data:image/${tokenAlphabet};base64,QUJD`), true); + assert.equal(isValidProviderIconUrl("data:image/svg+xml;base64,QUJD"), true); + assert.equal(isValidProviderIconUrl("data:image/vnd.microsoft.icon;base64,QUJD"), true); + assert.equal(isValidProviderIconUrl("data:image/x-ms-bmp;base64,QUJD"), true); + // Token characters rejected by the old partial regex are now accepted. + assert.equal(isValidProviderIconUrl("data:image/x~weird*name`;base64,QUJD"), true); + // Any valid token is a valid subtype — `png..` is a legal RFC 7230 token even + // though it looks odd; traversal-shaped payloads are harmless here because + // data URLs never touch a filesystem. + assert.equal(isValidProviderIconUrl("data:image/png..;base64,QUJD"), true); + // Non-token characters in the subtype are still rejected. + const invalidSubtypes = [ + "data:image/png/sub;base64,QUJD", // slash inside subtype + "data:image/pn g;base64,QUJD", // space + "data:image/;base64,QUJD", // empty subtype + "data:image/png\u0000;base64,QUJD", // control char + ]; + for (const v of invalidSubtypes) { + assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`); + } +}); + +test("data-URL media-type and parameter grammar edge cases", () => { + assert.equal(isValidProviderIconUrl("data:image/png;base64,QUJD"), true); + assert.equal(isValidProviderIconUrl("data:image/png;charset=utf-8;base64,QUJD"), true); + // `;base64` must be terminal — params after it are rejected. + assert.equal(isValidProviderIconUrl("data:image/png;base64;charset=utf-8,QUJD"), false); + // Attribute charset must be a token. + assert.equal(isValidProviderIconUrl("data:image/png;char set=utf-8;base64,QUJD"), false); + assert.equal(isValidProviderIconUrl('data:image/png;charset="unterminated;base64,QUJD'), false); + // First-comma split rule: a quoted-string value containing a literal comma is + // conservatively rejected (documented limitation). + assert.equal(isValidProviderIconUrl('data:image/png;name="a,b";base64,QUJD'), false); + // Header-only data URLs and empty metadata are rejected. + assert.equal(isValidProviderIconUrl("data:"), false); + assert.equal(isValidProviderIconUrl("data:;base64,QUJD"), false); + assert.equal(isValidProviderIconUrl("data:image/png;base64,"), false); +}); + +test("data-URL payloads are strictly validated — whitespace is rejected", () => { + const invalid = [ + "data:image/png;base64,iVBORw0K\nGgo=", // line break in payload + "data:image/png;base64,iVBORw0K\r\nGgo=", // CRLF in payload + "data:image/png;base64,iVBORw0K\tGgo=", // tab in payload + "data:image/png;base64,iVB ORw", // space in payload + "data:image/png;base64,iVBORw0K Ggo=", // space in payload + "data:image/png;base64,iVBOR\u00A0w0KGgo=", // non-breaking space mid-payload (Unicode whitespace) + ]; + for (const v of invalid) { + assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`); + } +}); + +test("isValidProviderIconUrl rejects malformed values and unsafe schemes", () => { + const invalid = [ + "not-a-url", + "javascript:alert(1)", + "ftp://broken", + "file:///etc/passwd", + "//example.com/logo.png", // scheme-relative — not explicitly http(s) + "https://", // no host + "https://exa mple.com/x.png", // space in host + ]; + for (const v of invalid) { + assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`); + } +}); + +test("isValidProviderIconUrl rejects non-image data URLs", () => { + const invalid = [ + "data:text/html;base64,QUJD", + "data:text/plain;base64,QUJD", + "data:application/json;base64,e30=", + "data:image/png", // no ;base64,payload + "data:application/octet-stream;base64,QUJD", + ]; + for (const v of invalid) { + assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`); + } +}); + +test("isValidProviderIconUrl rejects non-base64 image data URLs", () => { + const invalid = [ + "data:image/png,QUJD", // missing ;base64 + "data:image/png;base64,", // empty payload + "data:image/png;base64,!!!!", // invalid base64 chars + "data:image/png;base64,A", // payload not 4-char aligned + "data:image/png;base64,AAAA=", // over-padded + "data:image/png;base64,=QUJD=", // leading '=' + ]; + for (const v of invalid) { + assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`); + } +}); + +test("isValidProviderIconUrl enforces length caps", () => { + // http(s): 2000-char cap preserved. + const tooLongHttp = "https://example.com/" + "a".repeat(2000) + ".png"; + assert.equal(isValidProviderIconUrl(tooLongHttp), false); + assert.equal(isValidProviderIconUrl(VALID_HTTP_2000), true); + // data URL: generous 256 KB cap. + const tooLongData = "data:image/png;base64," + "A".repeat(256 * 1024 + 10); + assert.equal(isValidProviderIconUrl(tooLongData), false); +}); + +// ---- server-side schema integration ---- +test("createProviderNodeSchema accepts a valid data:image/*;base64 iconUrl", () => { + const result = createProviderNodeSchema.safeParse({ + name: "Test", + prefix: "test", + apiType: "chat", + iconUrl: VALID_PNG_DATA_URL, + }); + assert.equal(result.success, true); +}); + +test("createProviderNodeSchema rejects invalid data URLs", () => { + const invalid = [ + "data:text/html;base64,QUJD", + "data:image/png,QUJD", + "data:image/png;base64,!!!!", + "data:image/png;base64,", + "data:image/png;base64,iVB ORw", + ]; + for (const iconUrl of invalid) { + const result = createProviderNodeSchema.safeParse({ + name: "Test", + prefix: "test", + apiType: "chat", + iconUrl, + }); + assert.equal(result.success, false, `Should reject: ${JSON.stringify(iconUrl)}`); + } +}); + +test("updateProviderNodeSchema accepts a valid data:image/*;base64 iconUrl", () => { + const result = updateProviderNodeSchema.safeParse({ + name: "Test", + prefix: "test", + baseUrl: "https://test.com", + iconUrl: VALID_SVG_DATA_URL, + }); + assert.equal(result.success, true); +}); diff --git a/tests/unit/ui/add-compatible-provider-icon-url.test.tsx b/tests/unit/ui/add-compatible-provider-icon-url.test.tsx new file mode 100644 index 0000000000..eb650ca685 --- /dev/null +++ b/tests/unit/ui/add-compatible-provider-icon-url.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +// +// Field-level icon URL validation for the compatible-provider Add modal: an invalid +// iconUrl must surface as an inline field error BEFORE a blind POST, instead of only +// failing after the request round-trip. Mirrors the shared validator +// (src/shared/validation/iconUrl.ts) used by both the UI and the server schema. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: AddCompatibleProviderModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal"); + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function render(props: Record) { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { + root.render( + {}} + onCreated={() => {}} + {...(props as any)} + /> + ); + }); + containers.push({ root, el }); + return el; +} + +function inputByLabel(el: Element, label: string): HTMLInputElement { + const inputs = Array.from(el.querySelectorAll("input")); + const found = inputs.find((i) => { + const labelEl = i.previousElementSibling || i.parentElement?.previousElementSibling; + return labelEl?.textContent === label; + }); + if (!found) throw new Error(`No input for label: ${label}`); + return found; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function waitFor(fn: () => boolean, timeoutMs = 2000) { + const start = Date.now(); + while (!fn()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await new Promise((r) => setTimeout(r, 20)); + } +} + +beforeEach(() => { + vi.clearAllMocks(); + // Any POST that does reach the network should never happen for the invalid-input + // cases under test — fail loudly if it does. + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ node: {} }) } as Response) + ) + ); +}); + +afterEach(() => { + for (const { root, el } of containers.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + vi.unstubAllGlobals(); +}); + +describe("AddCompatibleProviderModal — iconUrl field-level validation", () => { + it("shows an inline error for an unsafe scheme and does NOT submit", async () => { + const el = render({}); + const modal = el.querySelector('[role="dialog"]')!; + + setInputValue(inputByLabel(modal, "nameLabel"), "My Node"); + setInputValue(inputByLabel(modal, "prefixLabel"), "mynode"); + setInputValue(inputByLabel(modal, "iconUrlLabel"), "javascript:alert(1)"); + + const buttons = Array.from(modal.querySelectorAll("button")); + const addBtn = buttons.find((b) => b.textContent === "add"); + act(() => addBtn!.click()); + await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false); + + expect(modal.textContent).toContain("iconUrlInvalid"); + // The invalid icon must never reach the API. + expect(fetch).not.toHaveBeenCalled(); + }); + + it("shows an inline error for a non-image data URL and does NOT submit", async () => { + const el = render({}); + const modal = el.querySelector('[role="dialog"]')!; + + setInputValue(inputByLabel(modal, "nameLabel"), "My Node"); + setInputValue(inputByLabel(modal, "prefixLabel"), "mynode"); + setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:text/html;base64,QUJD"); + + const buttons = Array.from(modal.querySelectorAll("button")); + const addBtn = buttons.find((b) => b.textContent === "add"); + act(() => addBtn!.click()); + await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false); + + expect(modal.textContent).toContain("iconUrlInvalid"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("accepts a valid data:image/*;base64 iconUrl and submits", async () => { + const el = render({}); + const modal = el.querySelector('[role="dialog"]')!; + + setInputValue(inputByLabel(modal, "nameLabel"), "My Node"); + setInputValue(inputByLabel(modal, "prefixLabel"), "mynode"); + setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo="); + + const buttons = Array.from(modal.querySelectorAll("button")); + const addBtn = buttons.find((b) => b.textContent === "add"); + act(() => addBtn!.click()); + await waitFor(() => (fetch as ReturnType).mock.calls.length > 0); + + expect(modal.textContent).not.toContain("iconUrlInvalid"); + const call = (fetch as ReturnType).mock.calls[0]; + expect(String(call[0])).toBe("/api/provider-nodes"); + const body = JSON.parse(String(call[1].body)); + expect(body.iconUrl).toBe("data:image/png;base64,iVBORw0KGgo="); + }); +}); diff --git a/tests/unit/ui/edit-compatible-node-icon-url.test.tsx b/tests/unit/ui/edit-compatible-node-icon-url.test.tsx new file mode 100644 index 0000000000..0d6d3cb072 --- /dev/null +++ b/tests/unit/ui/edit-compatible-node-icon-url.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +// +// Field-level icon URL validation for the compatible-provider Edit modal: an invalid +// iconUrl must surface as an inline field error BEFORE the onSave callback fires, and a +// valid data:image/*;base64 iconUrl must submit. Mirrors the shared validator +// (src/shared/validation/iconUrl.ts) used by both the UI and the server schema. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: EditCompatibleNodeModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal"); + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function render(node: Record, onSave?: () => Promise) { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { + root.render( + {})} + onClose={() => {}} + /> + ); + }); + containers.push({ root, el }); + return el; +} + +function inputByLabel(el: Element, label: string): HTMLInputElement { + const inputs = Array.from(el.querySelectorAll("input")); + const found = inputs.find((i) => { + const labelEl = i.previousElementSibling || i.parentElement?.previousElementSibling; + return labelEl?.textContent === label; + }); + if (!found) throw new Error(`No input for label: ${label}`); + return found; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function waitFor(fn: () => boolean, timeoutMs = 2000) { + const start = Date.now(); + while (!fn()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await new Promise((r) => setTimeout(r, 20)); + } +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + for (const { root, el } of containers.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + vi.unstubAllGlobals(); +}); + +const NODE = { + id: "oc-1", + name: "My Node", + prefix: "mynode", + baseUrl: "https://api.example.com/v1", + apiType: "chat", + iconUrl: "https://example.com/logo.png", +}; + +describe("EditCompatibleNodeModal — iconUrl field-level validation", () => { + it("shows an inline error for an unsafe scheme and does NOT call onSave", async () => { + const onSave = vi.fn(async () => {}); + const el = render({ ...NODE, iconUrl: "javascript:alert(1)" }); + const modal = el.querySelector('[role="dialog"]')!; + + setInputValue(inputByLabel(modal, "iconUrlLabel"), "javascript:alert(1)"); + const buttons = Array.from(modal.querySelectorAll("button")); + const saveBtn = buttons.find((b) => b.textContent === "save"); + act(() => saveBtn!.click()); + await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false); + + expect(modal.textContent).toContain("iconUrlInvalid"); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("shows an inline error for a non-image data URL and does NOT call onSave", async () => { + const onSave = vi.fn(async () => {}); + const el = render({ ...NODE, iconUrl: "data:text/html;base64,QUJD" }); + const modal = el.querySelector('[role="dialog"]')!; + + setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:text/html;base64,QUJD"); + const buttons = Array.from(modal.querySelectorAll("button")); + const saveBtn = buttons.find((b) => b.textContent === "save"); + act(() => saveBtn!.click()); + await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false); + + expect(modal.textContent).toContain("iconUrlInvalid"); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("accepts a valid data:image/*;base64 iconUrl and calls onSave with it", async () => { + const onSave = vi.fn(async () => {}); + const el = render({ ...NODE, iconUrl: "" }, onSave); + const modal = el.querySelector('[role="dialog"]')!; + + setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo="); + const buttons = Array.from(modal.querySelectorAll("button")); + const saveBtn = buttons.find((b) => b.textContent === "save"); + act(() => saveBtn!.click()); + await waitFor(() => onSave.mock.calls.length > 0); + + expect(modal.textContent).not.toContain("iconUrlInvalid"); + const payload = onSave.mock.calls[0][0]; + expect(payload.iconUrl).toBe("data:image/png;base64,iVBORw0KGgo="); + }); +});