Files
ttstd-infinite-canvas/web/test/user-avatar.test.tsx
2026-10-13 05:31:30 +08:00

128 lines
6.5 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { UserAvatar } from "../src/components/layout/user-avatar";
import { resolveUserAvatarURL } from "../src/lib/user-avatar-url";
import { DEFAULT_USER_AVATARS, isDefaultUserAvatarID } from "../src/lib/default-user-avatars";
import { apiBaseURL } from "../src/services/api/request";
import type { LocalUser } from "../src/stores/use-user-store";
const user: LocalUser = { id: "user-1", username: "user", displayName: "User", role: "user", status: "active" };
function renderAvatar(avatarUrl?: string) {
return renderToStaticMarkup(<UserAvatar user={{ ...user, avatarUrl }} className="size-8" />);
}
describe("shared user avatar image sources", () => {
test("renders authenticated profile images and linked HTTP avatars", () => {
for (const source of ["/api/auth/profile/avatar/user-1?v=012abc", "https://example.com/avatar.png", "http://example.com/avatar.jpg"]) {
const markup = renderAvatar(source);
expect(markup).toContain(`<img src="${resolveUserAvatarURL(source, apiBaseURL)}"`);
expect(markup).toContain("object-cover");
}
});
test("rejects unrelated relative URLs, traversal, protocol-relative and executable sources", () => {
for (const source of [
undefined,
"",
"/assets/avatar.png",
"/api/auth/profile/avatar/../settings",
"/api/auth/profile/avatar/user-1/../../settings",
"/api/auth/profile/avatar/user-1?redirect=https://example.com",
"//example.com/avatar.png",
"data:image/svg+xml,test",
"javascript:alert(1)",
"blob:https://example.com/test",
]) {
const markup = renderAvatar(source);
expect(markup).not.toContain("<img");
expect(markup).toContain("<svg");
}
});
test("preserves sidebar icon styling when using the system default", () => {
const markup = renderToStaticMarkup(<UserAvatar user={user} className="size-7" iconClassName="app-workspace-account-icon" />);
expect(markup).toContain("app-workspace-account-icon");
expect(markup).not.toContain("<img");
});
});
describe("profile avatar deployment routing", () => {
const canonical = "/api/auth/profile/avatar/user-1?v=012abc";
test("resolves the canonical path onto API origins and proxy prefixes", () => {
for (const [base, expected] of [
["/api", canonical],
["/api/", canonical],
["/proxy/yingce/api", "/proxy/yingce/api/auth/profile/avatar/user-1?v=012abc"],
["/proxy/yingce/api///", "/proxy/yingce/api/auth/profile/avatar/user-1?v=012abc"],
["https://backend.example.com/api", "https://backend.example.com/api/auth/profile/avatar/user-1?v=012abc"],
["https://backend.example.com/proxy/yingce/", "https://backend.example.com/proxy/yingce/auth/profile/avatar/user-1?v=012abc"],
["http://localhost:8080", "http://localhost:8080/auth/profile/avatar/user-1?v=012abc"],
["/", "/auth/profile/avatar/user-1?v=012abc"],
]) {
expect(resolveUserAvatarURL(canonical, base)).toBe(expected);
}
});
test("keeps third-party URLs independent of the configured API base", () => {
for (const source of ["https://thirdparty.example.com/avatar.png?q=123", "http://thirdparty.example.com/avatar.jpg"]) {
expect(resolveUserAvatarURL(source, "https://backend.example.com/proxy/api")).toBe(source);
}
});
test("rejects noncanonical paths before joining with a custom backend", () => {
for (const source of [
undefined,
"",
"/api/auth/profile/avatar/user-1",
"/api/auth/profile/avatar/user-1?v=",
"/api/auth/profile/avatar/user-1?v=012abc&next=/settings",
"/api/auth/profile/avatar/%2e%2e?v=012abc",
"/api/auth/profile/avatar/user-1/../other?v=012abc",
"/api/auth/profile/avatar/user-1?v=012abc#fragment",
"/proxy/api/auth/profile/avatar/user-1?v=012abc",
"//thirdparty.example.com/avatar.png",
"data:image/png;base64,AAAA",
]) {
expect(resolveUserAvatarURL(source, "https://backend.example.com/proxy/api")).toBe("");
}
});
});
describe("system avatar catalog and shared rendering", () => {
test("exposes eight stable distinct presets and validates exact IDs", () => {
expect(DEFAULT_USER_AVATARS.map((avatar) => avatar.id)).toEqual(["person", "director", "camera", "cat", "rabbit", "robot", "rocket", "sparkles"]);
expect(new Set(DEFAULT_USER_AVATARS.map((avatar) => avatar.id)).size).toBe(8);
for (const avatar of DEFAULT_USER_AVATARS) {
expect(isDefaultUserAvatarID(avatar.id)).toBe(true);
expect(avatar.label).not.toBe("");
}
for (const invalid of [undefined, null, "", "Cat", " cat", "cat ", "default", "../person"]) {
expect(isDefaultUserAvatarID(invalid)).toBe(false);
}
});
test("renders the selected icon globally and overrides inherited image URLs", () => {
const iconClasses = ["lucide-circle-user-round", "lucide-clapperboard", "lucide-camera", "lucide-cat", "lucide-rabbit", "lucide-bot", "lucide-rocket", "lucide-sparkles"];
for (const [index, preset] of DEFAULT_USER_AVATARS.entries()) {
const markup = renderToStaticMarkup(
<UserAvatar user={{ ...user, avatarMode: "default", avatarPreset: preset.id, avatarUrl: "https://example.com/inherited.png" }} className="creation-user-avatar" iconClassName="size-6 app-workspace-account-icon" />,
);
expect(markup).not.toContain("<img");
expect(markup).toContain(iconClasses[index]);
expect(markup).toContain("creation-user-avatar");
expect(markup).toContain("app-workspace-account-icon");
}
});
test("keeps custom images after remembering a preset preference", () => {
const markup = renderToStaticMarkup(<UserAvatar user={{ ...user, avatarMode: "custom", avatarPreset: "cat", avatarUrl: "/api/auth/profile/avatar/user-1?v=012abc" }} />);
expect(markup).toContain("<img");
expect(markup).not.toContain("lucide-cat");
const legacyDefault = renderToStaticMarkup(<UserAvatar user={{ ...user, avatarMode: "default" }} />);
expect(legacyDefault).toContain("lucide-circle-user-round");
});
});