Files
OmniRoute/tests/unit/provider-icon-url-validator.test.ts

308 lines
12 KiB
TypeScript

// 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,
MAX_ICON_DATA_URL_LENGTH,
MAX_ICON_URL_LENGTH,
} 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(1976) + ".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);
});
function dataIconUrlNearLength(maxLength: number): string {
const prefix = "data:image/png;base64,";
const payloadLength = maxLength - prefix.length;
const aligned = payloadLength - (payloadLength % 4);
return prefix + "A".repeat(aligned);
}
function dataIconUrlOverLimit(maxLength: number): string {
const atLimit = dataIconUrlNearLength(maxLength);
return atLimit + "AAAA";
}
test("create/update schemas accept a data:image iconUrl up to the shared 256 KiB cap", () => {
const iconUrl = dataIconUrlNearLength(MAX_ICON_DATA_URL_LENGTH);
assert.ok(iconUrl.length > MAX_ICON_URL_LENGTH);
assert.ok(iconUrl.length <= MAX_ICON_DATA_URL_LENGTH);
assert.equal(isValidProviderIconUrl(iconUrl), true);
const created = createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl,
});
assert.equal(created.success, true, created.success ? "" : JSON.stringify(created.error.issues));
const updated = updateProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
baseUrl: "https://test.com",
iconUrl,
});
assert.equal(updated.success, true, updated.success ? "" : JSON.stringify(updated.error.issues));
});
test("create/update schemas reject an over-limit data:image iconUrl", () => {
const iconUrl = dataIconUrlOverLimit(MAX_ICON_DATA_URL_LENGTH);
assert.ok(iconUrl.length > MAX_ICON_DATA_URL_LENGTH);
assert.ok(iconUrl.length <= MAX_ICON_DATA_URL_LENGTH + 4);
assert.equal(isValidProviderIconUrl(iconUrl), false);
const created = createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl,
});
assert.equal(created.success, false);
const updated = updateProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
baseUrl: "https://test.com",
iconUrl,
});
assert.equal(updated.success, false);
});
test("create/update schemas keep the 2000-char cap for http(s) iconUrl", () => {
const tooLongHttp = VALID_HTTP + "a".repeat(MAX_ICON_URL_LENGTH);
assert.ok(tooLongHttp.length > MAX_ICON_URL_LENGTH);
assert.equal(
createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl: VALID_HTTP_2000,
}).success,
true
);
assert.equal(
createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl: tooLongHttp,
}).success,
false
);
});