fix(providers): support data URL icons for compatible nodes (#9555)

Co-authored-by: xz-dev <xz-dev@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Xiangzhe
2026-08-10 14:22:59 +08:00
committed by GitHub
parent ee0073dc4a
commit 2e6c151902
9 changed files with 742 additions and 11 deletions

View File

@@ -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<string | null>(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")}
/>
<Toggle
label={t("newApiAggregatorToggleLabel")}

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
import {
CLIENT_IDENTITY_PROFILE_OPTIONS,
getClientIdentityProfileHeaders,
@@ -115,6 +116,7 @@ export default function AddCompatibleProviderModal({
method?: string | null;
}>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [iconUrlError, setIconUrlError] = useState<string | null>(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<string, unknown> = {
@@ -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")}
/>
<Toggle

View File

@@ -4948,6 +4948,7 @@
"baseUrlHint": "必填。 提供者 API 基本 URL。",
"iconUrlLabel": "图标 URL",
"iconUrlHint": "可选。显示为此服务商图标的图片 URL。",
"iconUrlInvalid": "无效的图标 URL。请使用 http(s):// 或 data:image/*;base64 URL。",
"anthropicPrefixPlaceholder": "ac-prod",
"openaiPrefixPlaceholder": "oc-prod",
"anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1",

View File

@@ -4948,6 +4948,7 @@
"baseUrlHint": "必填。 提供者 API 基本 URL。",
"iconUrlLabel": "圖示網址",
"iconUrlHint": "選用。顯示為此提供者圖示的圖片網址。",
"iconUrlInvalid": "無效的圖示網址。請使用 http(s):// 或 data:image/*;base64 URL。",
"anthropicPrefixPlaceholder": "ac-prod",
"openaiPrefixPlaceholder": "oc-prod",
"anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1",

View File

@@ -0,0 +1,221 @@
import { z } from "zod";
/**
* Shared source of truth for compatible-provider node icon URL validation
* (#2166 + data-URL support). Used by both the server-side Zod schema
* (`providerNodeIconUrlSchema` in `schemas/provider.ts`) and the Add/Edit
* compatible-provider UI modals for field-level errors — a single validator,
* no divergent regexes.
*
* Accepted shapes:
* - "" → no custom icon (clears a previously stored one)
* - http(s) URL → existing #2166 semantics, 2000-char cap
* - `data:image/*;base64,...` → valid image MIME + non-empty, valid base64 payload
*
* The data-URL header (scheme, media type, base64 marker) is matched
* case-insensitively per RFC 2397, and optional media-type parameters (e.g.
* `;charset=utf-8`) are accepted before the terminal `;base64` marker.
* `image/svg+xml` is an ordinary member of `image/*` and is accepted — it is
* rendered as an operator-supplied <img> `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/<subtype>[;attr=value]*;base64,<payload>` (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/<subtype>[;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;
}

View File

@@ -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 <img src>.
// #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();

View File

@@ -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);
});

View File

@@ -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<typeof createRoot>; el: HTMLDivElement }> = [];
function render(props: Record<string, unknown>) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<AddCompatibleProviderModal
isOpen
mode="openai"
onClose={() => {}}
onCreated={() => {}}
{...(props as any)}
/>
);
});
containers.push({ root, el });
return el;
}
function inputByLabel(el: Element, label: string): HTMLInputElement {
const inputs = Array.from(el.querySelectorAll<HTMLInputElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("button"));
const addBtn = buttons.find((b) => b.textContent === "add");
act(() => addBtn!.click());
await waitFor(() => (fetch as ReturnType<typeof vi.fn>).mock.calls.length > 0);
expect(modal.textContent).not.toContain("iconUrlInvalid");
const call = (fetch as ReturnType<typeof vi.fn>).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=");
});
});

View File

@@ -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<typeof createRoot>; el: HTMLDivElement }> = [];
function render(node: Record<string, unknown>, onSave?: () => Promise<void>) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<EditCompatibleNodeModal
isOpen
node={node as any}
onSave={onSave || (async () => {})}
onClose={() => {}}
/>
);
});
containers.push({ root, el });
return el;
}
function inputByLabel(el: Element, label: string): HTMLInputElement {
const inputs = Array.from(el.querySelectorAll<HTMLInputElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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=");
});
});