feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)
Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
@@ -86,10 +86,10 @@ test("channel model fetch requires explicit selection before import", async () =
|
||||
expect(component).toContain("setFetchPreviewOpen(true)");
|
||||
expect(component).toContain("setSelectedFetchModels(result.models)");
|
||||
expect(component).toContain("已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型");
|
||||
expect(component).toContain("disabled={importing || allFetchModelsSelected}");
|
||||
expect(component).toContain("onClick={() => setSelectedFetchModels(fetchPreviewModels)}");
|
||||
expect(component).toContain("disabled={importing || selectedFetchModels.length === 0}");
|
||||
expect(component).toContain("onClick={() => setSelectedFetchModels([])}");
|
||||
expect(component).toContain("disabled={importing || !visibleFetchModels.length || allVisibleFetchModelsSelected}");
|
||||
expect(component).toContain("onClick={() => setSelectedFetchModels((current) => selectVisibleModels(current, visibleFetchModels))}");
|
||||
expect(component).toContain("disabled={importing || selectedVisibleFetchCount === 0}");
|
||||
expect(component).toContain("onClick={() => setSelectedFetchModels((current) => deselectVisibleModels(current, visibleFetchModels))}");
|
||||
expect(component).toContain("取消全选");
|
||||
expect(component).toContain("disabled={importing}");
|
||||
expect(component).toContain("importAdminChannelModels(channel.id, selectedFetchModels)");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosition } from "../src/lib/canvas/agent-launcher-position";
|
||||
|
||||
@@ -36,7 +37,7 @@ test("small pointer jitter remains a click, but dragging back to the origin rema
|
||||
// Execute the production hook with a tiny hook scheduler; only React and browser
|
||||
// side effects are substituted, not the pointer/click handlers being tested.
|
||||
const build = await Bun.build({
|
||||
entrypoints: [new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url).pathname],
|
||||
entrypoints: [fileURLToPath(new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url))],
|
||||
target: "browser",
|
||||
format: "cjs",
|
||||
plugins: [
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { isIsolatedPrevisRepro } from "../src/lib/dev-repro";
|
||||
|
||||
// Execute the real entry point; replace only its font, network and UI side effects.
|
||||
async function prepareEntry(dev, pathname) {
|
||||
const build = await Bun.build({
|
||||
entrypoints: [new URL("../src/main.tsx", import.meta.url).pathname],
|
||||
entrypoints: [fileURLToPath(new URL("../src/main.tsx", import.meta.url))],
|
||||
target: "browser",
|
||||
format: "iife",
|
||||
define: { "import.meta.env.DEV": JSON.stringify(dev) },
|
||||
|
||||
14
web/test/login-log-device.test.ts
Normal file
14
web/test/login-log-device.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { loginLogDevice } from "../src/pages/admin/login-logs/login-log-display";
|
||||
|
||||
describe("登录日志设备识别", () => {
|
||||
test("Edge 不误判为 Chrome,Android 不误判为 Linux", () => {
|
||||
expect(loginLogDevice("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0")).toEqual({ browser: "Edge 130", os: "Windows" });
|
||||
expect(loginLogDevice("Mozilla/5.0 (Linux; Android 14) Chrome/130.0.0.0 Mobile Safari/537.36")).toEqual({ browser: "Chrome 130", os: "Android 14" });
|
||||
});
|
||||
test("iPhone 和未知 UA 明确展示识别边界", () => {
|
||||
expect(loginLogDevice("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Version/18.0 Mobile/15E148 Safari/604.1")).toEqual({ browser: "Safari 18", os: "iOS 18.0" });
|
||||
expect(loginLogDevice("custom-client")).toEqual({ browser: "未识别浏览器", os: "未识别系统" });
|
||||
expect(loginLogDevice("")).toEqual({ browser: "未记录", os: "未记录" });
|
||||
});
|
||||
});
|
||||
136
web/test/profile-avatar-crop.test.ts
Normal file
136
web/test/profile-avatar-crop.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { centeredAspectSelection, resizeSelection, translateSelection, type CropGrip } from "../src/lib/canvas/image-crop-selection";
|
||||
import { cropProfileAvatar, planProfileAvatarCrop } from "../src/services/profile-avatar";
|
||||
|
||||
describe("fixed avatar crop geometry", () => {
|
||||
test("starts with the largest centered square on wide, tall and odd images", () => {
|
||||
for (const size of [
|
||||
{ width: 1200, height: 800 },
|
||||
{ width: 800, height: 1200 },
|
||||
{ width: 901, height: 600 },
|
||||
]) {
|
||||
const crop = centeredAspectSelection(size, 1);
|
||||
expect(crop.width * size.width).toBeCloseTo(Math.min(size.width, size.height));
|
||||
expect(crop.height * size.height).toBeCloseTo(Math.min(size.width, size.height));
|
||||
expect(crop.x + crop.width / 2).toBeCloseTo(0.5);
|
||||
expect(crop.y + crop.height / 2).toBeCloseTo(0.5);
|
||||
const plan = planProfileAvatarCrop(size, crop);
|
||||
expect(plan.source.width).toBe(plan.source.height);
|
||||
expect(plan.outputSide).toBe(512);
|
||||
}
|
||||
});
|
||||
test("every locked grip and movement preserves physical square pixels", () => {
|
||||
const size = { width: 1200, height: 600 };
|
||||
const initial = centeredAspectSelection(size, 1);
|
||||
for (const x of [-1, 0, 1] as const)
|
||||
for (const y of [-1, 0, 1] as const) {
|
||||
if (!x && !y) continue;
|
||||
const resized = resizeSelection(initial, [x, y] as CropGrip, -0.1, -0.1, true);
|
||||
const moved = translateSelection(resized, 0.2, -0.3);
|
||||
expect(moved.width * size.width).toBeCloseTo(moved.height * size.height);
|
||||
expect(planProfileAvatarCrop(size, moved).source.width).toBe(planProfileAvatarCrop(size, moved).source.height);
|
||||
}
|
||||
});
|
||||
test("square crops on panoramic images can shrink from every grip and remain square", () => {
|
||||
for (const size of [
|
||||
{ width: 2000, height: 100 },
|
||||
{ width: 100, height: 2000 },
|
||||
]) {
|
||||
const initial = centeredAspectSelection(size, 1);
|
||||
for (const x of [-1, 0, 1] as const)
|
||||
for (const y of [-1, 0, 1] as const) {
|
||||
if (!x && !y) continue;
|
||||
const next = resizeSelection(initial, [x, y], (-x * initial.width) / 4, (-y * initial.height) / 4, true);
|
||||
expect(next.width * size.width).toBeCloseTo(75);
|
||||
expect(next.height * size.height).toBeCloseTo(75);
|
||||
expect(next.x).toBeGreaterThanOrEqual(0);
|
||||
expect(next.y).toBeGreaterThanOrEqual(0);
|
||||
expect(next.x + next.width).toBeLessThanOrEqual(1);
|
||||
expect(next.y + next.height).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
test("one-pixel keyboard steps shrink panoramic squares and enforce a reachable positive minimum", () => {
|
||||
for (const size of [
|
||||
{ width: 2000, height: 100 },
|
||||
{ width: 100, height: 2000 },
|
||||
]) {
|
||||
const initial = centeredAspectSelection(size, 1);
|
||||
expect(resizeSelection(initial, [1, 0], -1 / size.width, 0, true).width * size.width).toBeCloseTo(99);
|
||||
expect(resizeSelection(initial, [0, 1], 0, -1 / size.height, true).height * size.height).toBeCloseTo(99);
|
||||
const smallest = resizeSelection(initial, [1, 1], -10, -10, true);
|
||||
expect(smallest.width * size.width).toBeCloseTo(6);
|
||||
expect(smallest.height * size.height).toBeCloseTo(6);
|
||||
}
|
||||
});
|
||||
test("rejects decoded images over 16M pixels, invalid and non-square crops", () => {
|
||||
expect(() => planProfileAvatarCrop({ width: 4001, height: 4000 }, { x: 0, y: 0, width: 1, height: 1 })).toThrow("1600");
|
||||
expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0, y: 0, width: 1, height: 0.5 })).toThrow("正方形");
|
||||
expect(() => centeredAspectSelection({ width: 100, height: 0 }, 1)).toThrow();
|
||||
expect(() => centeredAspectSelection({ width: 100, height: 100 }, 0)).toThrow();
|
||||
expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 2, y: 0, width: 1, height: 1 })).toThrow();
|
||||
expect(planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0.2, y: 0.3, width: 0.4, height: 0.4 })).toEqual({ source: { x: 20, y: 30, width: 40, height: 40 }, outputSide: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
async function withRasterDOM(width: number, height: number, run: (state: { drawn: unknown[][]; imageSources: string[]; canvas: { width: number; height: number }; failExport: () => void }) => Promise<void>) {
|
||||
const previousImage = globalThis.Image;
|
||||
const previousDocument = globalThis.document;
|
||||
const drawn: unknown[][] = [];
|
||||
const imageSources: string[] = [];
|
||||
let exportFails = false;
|
||||
class TestImage {
|
||||
naturalWidth = width;
|
||||
naturalHeight = height;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(value: string) {
|
||||
imageSources.push(value);
|
||||
if (value) queueMicrotask(() => this.onload?.());
|
||||
}
|
||||
}
|
||||
const canvas = { width: 0, height: 0, getContext: () => ({ drawImage: (...values: unknown[]) => drawn.push(values) }), toBlob: (callback: (blob: Blob | null) => void) => callback(exportFails ? null : new Blob(["png"], { type: "image/png" })) };
|
||||
Object.assign(globalThis, { Image: TestImage, document: { createElement: () => canvas } });
|
||||
try {
|
||||
await run({
|
||||
drawn,
|
||||
imageSources,
|
||||
canvas,
|
||||
failExport: () => {
|
||||
exportFails = true;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
Object.assign(globalThis, { Image: previousImage, document: previousDocument });
|
||||
}
|
||||
}
|
||||
|
||||
describe("avatar PNG export", () => {
|
||||
test("draws the chosen source region into a square bounded by 512 and releases buffers", async () => {
|
||||
await withRasterDOM(1200, 800, async ({ drawn, canvas, imageSources }) => {
|
||||
const file = await cropProfileAvatar("blob:test", { x: 0.25, y: 0.125, width: 0.5, height: 0.75 }, "../../private.jpg");
|
||||
expect(drawn[0].slice(1)).toEqual([300, 100, 600, 600, 0, 0, 512, 512]);
|
||||
expect(file.name).toBe("avatar.png");
|
||||
expect(file.type).toBe("image/png");
|
||||
expect(file.size).toBeGreaterThan(0);
|
||||
expect(canvas.width).toBe(0);
|
||||
expect(canvas.height).toBe(0);
|
||||
expect(imageSources.at(-1)).toBe("");
|
||||
});
|
||||
});
|
||||
test("fails explicitly when PNG export fails and still releases image and canvas", async () => {
|
||||
await withRasterDOM(100, 100, async ({ failExport, canvas, imageSources }) => {
|
||||
failExport();
|
||||
await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("PNG");
|
||||
expect(canvas.width).toBe(0);
|
||||
expect(imageSources.at(-1)).toBe("");
|
||||
});
|
||||
});
|
||||
test("rejects pixel bombs before drawing", async () => {
|
||||
await withRasterDOM(5000, 5000, async ({ drawn, imageSources }) => {
|
||||
await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("1600");
|
||||
expect(drawn).toHaveLength(0);
|
||||
expect(imageSources.at(-1)).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
44
web/test/profile-session.test.ts
Normal file
44
web/test/profile-session.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { apiClient } from "../src/services/api/request";
|
||||
import { loadProfileSession } from "../src/services/profile-session";
|
||||
import { useUserStore, type LocalUser } from "../src/stores/use-user-store";
|
||||
|
||||
const originalUser: LocalUser = { id: "profile-user", username: "user", displayName: "User", role: "user", status: "active", avatarMode: "default", avatarPreset: "person" };
|
||||
|
||||
test("profile refresh synchronizes avatars while rejecting stale account and cancelled responses", async () => {
|
||||
const originalRequest = apiClient.request;
|
||||
const previousUser = useUserStore.getState().user;
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
try {
|
||||
for (const scenario of ["refresh", "switched-account", "wrong-user", "cancelled", "failure"] as const) {
|
||||
useUserStore.getState().setUser(originalUser);
|
||||
let respond!: (value: unknown) => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
apiClient.request = (() =>
|
||||
new Promise((resolve, fail) => {
|
||||
respond = resolve;
|
||||
reject = fail;
|
||||
})) as typeof apiClient.request;
|
||||
const key = ["user-profile", scenario];
|
||||
const request = client.fetchQuery({ queryKey: key, queryFn: ({ signal }) => loadProfileSession(originalUser.id, signal) }).catch((error) => error);
|
||||
if (scenario === "switched-account") useUserStore.getState().setUser({ ...originalUser, id: "other-user" });
|
||||
if (scenario === "cancelled") {
|
||||
await client.cancelQueries({ queryKey: key });
|
||||
useUserStore.getState().setUser({ ...originalUser, avatarPreset: "cat" });
|
||||
}
|
||||
if (scenario === "failure") reject(new Error("profile unavailable"));
|
||||
else respond({ data: { code: 0, data: { user: { ...originalUser, id: scenario === "wrong-user" ? "unexpected-user" : originalUser.id, avatarPreset: "robot" } }, msg: "ok" }, status: 200 });
|
||||
await request;
|
||||
await Promise.resolve();
|
||||
const stored = useUserStore.getState().user;
|
||||
expect(stored?.id).toBe(scenario === "switched-account" ? "other-user" : originalUser.id);
|
||||
expect(stored?.avatarPreset).toBe(scenario === "refresh" ? "robot" : scenario === "cancelled" ? "cat" : "person");
|
||||
}
|
||||
} finally {
|
||||
await client.cancelQueries();
|
||||
client.clear();
|
||||
apiClient.request = originalRequest;
|
||||
useUserStore.setState({ user: previousUser });
|
||||
}
|
||||
});
|
||||
99
web/test/profile-settings.test.ts
Normal file
99
web/test/profile-settings.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { profileAvatarError, PROFILE_AVATAR_MAX_BYTES, profilePasswordError, profileReauthReady, profileSecondsRemaining } from "../src/pages/settings/profile-validation";
|
||||
import { settingsPath } from "../src/lib/settings-navigation";
|
||||
import { changeProfileEmail, changeProfilePassword, getUserProfile, profileLinuxDOReauthURL, requestProfileEmailCode, resetProfileAvatar, selectDefaultProfileAvatar, uploadProfileAvatar } from "../src/services/api/profile";
|
||||
import { ApiError, apiClient } from "../src/services/api/request";
|
||||
|
||||
describe("profile input and security expiry", () => {
|
||||
test("rejects unsupported, empty and oversized avatar uploads", () => {
|
||||
expect(profileAvatarError({ type: "image/svg+xml", size: 100 })).toBeDefined();
|
||||
expect(profileAvatarError({ type: "image/png", size: 0 })).toBeDefined();
|
||||
expect(profileAvatarError({ type: "image/jpeg", size: PROFILE_AVATAR_MAX_BYTES + 1 })).toBeDefined();
|
||||
expect(profileAvatarError({ type: "image/png", size: PROFILE_AVATAR_MAX_BYTES })).toBeUndefined();
|
||||
});
|
||||
test("checks password UTF-8 byte limit to prevent bcrypt truncation", () => {
|
||||
expect(profilePasswordError("1234567")).toBeDefined();
|
||||
expect(profilePasswordError("a".repeat(72))).toBeUndefined();
|
||||
expect(profilePasswordError("a".repeat(73))).toBeDefined();
|
||||
expect(profilePasswordError("密".repeat(24))).toBeUndefined();
|
||||
expect(profilePasswordError("密".repeat(25))).toBeDefined();
|
||||
expect(profilePasswordError("😀".repeat(4))).toBeDefined();
|
||||
expect(profilePasswordError("😀".repeat(8))).toBeUndefined();
|
||||
});
|
||||
test("expires identity proof and code cooldown at the boundary", () => {
|
||||
const deadline = "2026-10-12T12:00:00Z";
|
||||
const timestamp = Date.parse(deadline);
|
||||
expect(profileReauthReady(deadline, timestamp - 1)).toBe(true);
|
||||
expect(profileReauthReady(deadline, timestamp)).toBe(false);
|
||||
expect(profileReauthReady("invalid", timestamp)).toBe(false);
|
||||
expect(profileReauthReady(undefined, timestamp)).toBe(false);
|
||||
expect(profileSecondsRemaining(timestamp, timestamp - 1001)).toBe(2);
|
||||
expect(profileSecondsRemaining(timestamp, timestamp + 1)).toBe(0);
|
||||
});
|
||||
test("account settings default to profile and creation continues to channels", () => {
|
||||
expect(settingsPath()).toBe("/settings?section=profile");
|
||||
expect(settingsPath(undefined, true)).toBe("/settings?section=channels&continue=1");
|
||||
expect(settingsPath("models", true)).toBe("/settings?section=models&continue=1");
|
||||
expect(settingsPath("profile")).toBe("/settings?section=profile");
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile API request contract", () => {
|
||||
test("forwards load cancellation and uses multipart avatar without forcing content type", async () => {
|
||||
const original = apiClient.request;
|
||||
const requests: Array<Record<string, unknown>> = [];
|
||||
apiClient.request = (async (config: Record<string, unknown>) => {
|
||||
requests.push(config);
|
||||
return { data: { code: 0, data: { user: { id: "user-1" } }, msg: "ok" }, status: 200 };
|
||||
}) as typeof apiClient.request;
|
||||
try {
|
||||
const signal = new AbortController().signal;
|
||||
await getUserProfile(signal);
|
||||
const file = new File(["image-bytes"], "avatar.png", { type: "image/png" });
|
||||
await uploadProfileAvatar(file);
|
||||
await resetProfileAvatar();
|
||||
await selectDefaultProfileAvatar("cat");
|
||||
expect(requests[0]).toMatchObject({ url: "/auth/profile", method: "get", signal });
|
||||
expect(requests[1]).toMatchObject({ url: "/auth/profile/avatar", method: "put" });
|
||||
const multipart = requests[1].data as FormData;
|
||||
expect(multipart.get("file")).toBeInstanceOf(File);
|
||||
expect((multipart.get("file") as File).name).toBe("avatar.png");
|
||||
expect(requests[1].headers).toBeUndefined();
|
||||
expect(requests[2]).toMatchObject({ url: "/auth/profile/avatar", method: "delete" });
|
||||
expect(requests[3]).toMatchObject({ url: "/auth/profile/avatar/default", method: "post", data: { preset: "cat" } });
|
||||
} finally {
|
||||
apiClient.request = original;
|
||||
}
|
||||
});
|
||||
test("keeps passwords, email codes and tickets in POST bodies", async () => {
|
||||
const original = apiClient.request;
|
||||
const requests: Array<Record<string, unknown>> = [];
|
||||
apiClient.request = (async (config: Record<string, unknown>) => {
|
||||
requests.push(config);
|
||||
return { data: { code: 0, data: { changed: true }, msg: "ok" }, status: 200 };
|
||||
}) as typeof apiClient.request;
|
||||
try {
|
||||
await changeProfilePassword({ currentPassword: "old-password", newPassword: "new-password" });
|
||||
await requestProfileEmailCode({ email: "new@example.test", currentPassword: "old-password" });
|
||||
await changeProfileEmail({ ticket: "test-ticket", emailCode: "123456", currentPassword: "old-password" });
|
||||
expect(requests.map((request) => request.url)).toEqual(["/auth/profile/password", "/auth/profile/email-code", "/auth/profile/email"]);
|
||||
expect(requests.every((request) => request.method === "post" && request.params === undefined)).toBe(true);
|
||||
expect(requests[2].data).toEqual({ ticket: "test-ticket", emailCode: "123456", currentPassword: "old-password" });
|
||||
expect(profileLinuxDOReauthURL()).toEndWith("/auth/profile/linuxdo/start");
|
||||
expect(profileLinuxDOReauthURL()).not.toContain("?");
|
||||
} finally {
|
||||
apiClient.request = original;
|
||||
}
|
||||
});
|
||||
test("propagates failed security writes with the backend reason", async () => {
|
||||
const original = apiClient.request;
|
||||
apiClient.request = (async () => ({ data: { code: 40001, data: null, msg: "身份验证失败", reason: "bad_auth_request" }, status: 400 })) as typeof apiClient.request;
|
||||
try {
|
||||
const error = await changeProfilePassword({ currentPassword: "wrong", newPassword: "new-password" }).catch((failure) => failure);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(error.reason).toBe("bad_auth_request");
|
||||
} finally {
|
||||
apiClient.request = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
127
web/test/user-avatar.test.tsx
Normal file
127
web/test/user-avatar.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user