26952ae0d9
- POST /api/me/avatar/url stores the https link (users.avatar_link) instead of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302 to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no localhost or private/reserved IP literal. Upload and link replace each other. - Settings -> Account first loads the link as an embedded image and refuses one this browser cannot show: a picture behind a login (Mattermost's /api/v4/users/<id>/image) gets no cookie cross-site and 401s. - Files sidebar: each workspace under its owner's home, chevrons remembered per browser (agenthub.files.homesOpen); ownerless ones stay flat.
195 lines
8.0 KiB
TypeScript
195 lines
8.0 KiB
TypeScript
/**
|
|
* @file AccountSettings.test.tsx
|
|
* @description Settings → Account: shows the account's own information, saves
|
|
* only a changed display name/email, refuses a password change whose two new
|
|
* passwords differ, keeps a password that misses a rule from being sent,
|
|
* sends the current + new password, asks before signing
|
|
* the other devices out, and uploads/removes the avatar or sets it from a link.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
|
|
const me = vi.hoisted(() => ({
|
|
account: vi.fn(),
|
|
updateAccount: vi.fn(),
|
|
changePassword: vi.fn(),
|
|
signOutOthers: vi.fn(),
|
|
setAvatar: vi.fn(),
|
|
setAvatarFromUrl: vi.fn(),
|
|
removeAvatar: vi.fn(),
|
|
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
|
|
}));
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return { ...actual, api: { me } };
|
|
});
|
|
const confirm = vi.hoisted(() => vi.fn());
|
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: confirm }));
|
|
|
|
import { AccountSettings } from "../AccountSettings";
|
|
|
|
const account = {
|
|
mode: "server" as const,
|
|
user: {
|
|
id: 2,
|
|
username: "mia",
|
|
role: "developer" as const,
|
|
disabled: false,
|
|
displayName: "Mia Tran",
|
|
email: null,
|
|
createdAt: "2026-10-01T00:00:00.000Z",
|
|
},
|
|
workspaces: 3,
|
|
sessions: 12,
|
|
logins: 2,
|
|
lastLoginAt: null,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
for (const fn of Object.values(me)) if ("mockReset" in fn) fn.mockReset();
|
|
me.account.mockResolvedValue(account);
|
|
me.updateAccount.mockResolvedValue(account);
|
|
me.changePassword.mockResolvedValue({ ok: true });
|
|
me.signOutOthers.mockResolvedValue({ ended: 1 });
|
|
confirm.mockReset().mockResolvedValue(true);
|
|
});
|
|
|
|
describe("AccountSettings", () => {
|
|
it("uploads a picked avatar and removes it again", async () => {
|
|
const ue = userEvent.setup();
|
|
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-14T00:00:00Z" } };
|
|
me.setAvatar.mockResolvedValue(withAvatar);
|
|
me.removeAvatar.mockResolvedValue(account);
|
|
render(<AccountSettings />);
|
|
await screen.findByTestId("account-card");
|
|
expect(screen.getByTestId("user-avatar-initials").textContent).toBe("Mi");
|
|
expect(screen.queryByTestId("account-avatar-remove")).toBeNull();
|
|
const file = new File([new Uint8Array([0x89, 0x50])], "me.png", { type: "image/png" });
|
|
await ue.upload(screen.getByTestId("account-avatar-input"), file);
|
|
await waitFor(() => expect(me.setAvatar).toHaveBeenCalledWith(file));
|
|
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
|
|
"/api/me/avatar?v=2026-10-14T00:00:00Z"
|
|
);
|
|
await ue.click(screen.getByTestId("account-avatar-remove"));
|
|
await waitFor(() => expect(me.removeAvatar).toHaveBeenCalled());
|
|
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
|
|
});
|
|
|
|
/** jsdom never loads an image: play the browser's answer to `src`. */
|
|
const stubImage = (loads: boolean) =>
|
|
vi.stubGlobal(
|
|
"Image",
|
|
class {
|
|
naturalWidth = loads ? 64 : 0;
|
|
referrerPolicy = "";
|
|
onload: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
set src(_v: string) {
|
|
setTimeout(() => (loads ? this.onload?.() : this.onerror?.()), 0);
|
|
}
|
|
}
|
|
);
|
|
|
|
it("refuses a link this browser cannot load as an image, and saves nothing", async () => {
|
|
const ue = userEvent.setup();
|
|
stubImage(false);
|
|
render(<AccountSettings />);
|
|
await screen.findByTestId("account-card");
|
|
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
|
|
await ue.type(
|
|
screen.getByTestId("account-avatar-url-input"),
|
|
"https://chat.example/api/v4/users/x/image{Enter}"
|
|
);
|
|
await waitFor(() => expect(screen.getByTestId("account-avatar-url-input")).toBeTruthy());
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(me.setAvatarFromUrl).not.toHaveBeenCalled();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("sets the avatar from a link and closes the field", async () => {
|
|
const ue = userEvent.setup();
|
|
stubImage(true);
|
|
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
|
|
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
|
|
render(<AccountSettings />);
|
|
await screen.findByTestId("account-card");
|
|
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
|
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
|
|
await ue.type(screen.getByTestId("account-avatar-url-input"), " https://x.vn/me.png {Enter}");
|
|
await waitFor(() => expect(me.setAvatarFromUrl).toHaveBeenCalledWith("https://x.vn/me.png"));
|
|
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
|
|
"/api/me/avatar?v=2026-10-21T00:00:00Z"
|
|
);
|
|
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("shows the account's own information", async () => {
|
|
render(<AccountSettings />);
|
|
const card = await screen.findByTestId("account-card");
|
|
expect(card.textContent).toContain("Mia Tran");
|
|
expect(card.textContent).toContain("@mia");
|
|
expect(card.textContent).toContain("Developer");
|
|
expect(card.textContent).toContain("12");
|
|
});
|
|
|
|
it("saves only a changed field", async () => {
|
|
const ue = userEvent.setup();
|
|
render(<AccountSettings />);
|
|
await screen.findByTestId("account-card");
|
|
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
|
|
await ue.click(screen.getByRole("button", { name: "Save" }));
|
|
await waitFor(() => expect(me.updateAccount).toHaveBeenCalledWith({ email: "mia@x.vn" }));
|
|
});
|
|
|
|
it("refuses two different new passwords", async () => {
|
|
const ue = userEvent.setup();
|
|
render(<AccountSettings />);
|
|
await screen.findByTestId("account-card");
|
|
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
|
|
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
|
|
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-999");
|
|
expect(screen.getByText("The two new passwords differ.")).toBeTruthy();
|
|
const button = screen.getByRole("button", { name: "Change password" });
|
|
expect(button.hasAttribute("disabled")).toBe(true);
|
|
expect(me.changePassword).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("changes the password with the current one", async () => {
|
|
const ue = userEvent.setup();
|
|
render(<AccountSettings />);
|
|
await screen.findByTestId("account-card");
|
|
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
|
|
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
|
|
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
|
|
// No upper-case letter: the checklist says so and the button stays off.
|
|
expect(
|
|
screen
|
|
.getByTestId("password-rules")
|
|
.querySelector('[data-rule="upper"]')
|
|
?.getAttribute("data-ok")
|
|
).toBe("false");
|
|
expect(screen.getByRole("button", { name: "Change password" })).toBeDisabled();
|
|
await ue.clear(screen.getByLabelText("New password"));
|
|
await ue.clear(screen.getByLabelText("Confirm new password"));
|
|
await ue.type(screen.getByLabelText("New password"), "New-pass-123");
|
|
await ue.type(screen.getByLabelText("Confirm new password"), "New-pass-123");
|
|
await ue.click(screen.getByRole("button", { name: "Change password" }));
|
|
await waitFor(() =>
|
|
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "New-pass-123")
|
|
);
|
|
});
|
|
|
|
it("asks before signing the other devices out", async () => {
|
|
const ue = userEvent.setup();
|
|
render(<AccountSettings />);
|
|
await screen.findByTestId("account-card");
|
|
await ue.click(screen.getByRole("button", { name: "Sign out other devices" }));
|
|
expect(confirm).toHaveBeenCalled();
|
|
await waitFor(() => expect(me.signOutOthers).toHaveBeenCalled());
|
|
});
|
|
});
|