mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
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:
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
221
src/shared/validation/iconUrl.ts
Normal file
221
src/shared/validation/iconUrl.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
222
tests/unit/provider-icon-url-validator.test.ts
Normal file
222
tests/unit/provider-icon-url-validator.test.ts
Normal 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);
|
||||
});
|
||||
140
tests/unit/ui/add-compatible-provider-icon-url.test.tsx
Normal file
140
tests/unit/ui/add-compatible-provider-icon-url.test.tsx
Normal 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=");
|
||||
});
|
||||
});
|
||||
131
tests/unit/ui/edit-compatible-node-icon-url.test.tsx
Normal file
131
tests/unit/ui/edit-compatible-node-icon-url.test.tsx
Normal 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=");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user