Files
openclaw/src/gateway/server-methods/users.test.ts
T
Peter Steinberger f8e35afecc feat: avatar upload UI, self-service profiles, server-side avatar route (#111421)
* feat: avatar upload UI, self-service profiles, server-side avatar proxy

* refactor(ui): make resolveAvatar synchronous so avatar updates render immediately

resolveAvatar no longer does async work (gravatar moved server-side), so
the until()-deferred render left the identity chip img stale after
updateSelfUser. Rendering synchronously reuses the img element and
reflects avatarUrl changes on the next render.

* fix(gateway): bound avatar Gravatar lookup to one timeout budget

Linked-email Gravatar lookups ran sequentially, each with its own 5s
timeout, so an upstream outage stalled the held connection for
5s x linked-email-count. Resolve them concurrently and pick the first
hit in email order, keeping primary-email precedence while capping the
request to a single timeout budget.

* fix(gateway): cap per-profile Gravatar fan-out to bound sockets and memory

A profile with many linked emails would fan out one concurrent fetch per
email with no bound, each able to buffer up to MAX_GRAVATAR_BYTES before
the cache budget applies. Cap the lookups to the first 8 primary-ordered
emails so a single avatar request holds a bounded number of sockets and
transient bytes.

* test: reconcile avatar tests with server-side gravatar model after merge

main landed a client-side Gravatar path (browser computes the gravatar.com
URL); this branch resolves avatars through the same-origin gateway route,
which is the only approach that works under the Control UI CSP (img-src
'self'). Update the identity-section, app-sidebar footer chip, and
profile-page e2e expectations to assert the canonical /api/users/<id>/avatar
route (gateway serves the Gravatar fallback behind it) instead of a
CSP-blocked direct gravatar.com image.

* test(gateway): fix fetchImpl mock param type for check-test-types

The Gravatar mock typed its param as (url: string), narrower than the
fetchImpl signature (URL | RequestInfo); function-param contravariance
made it unassignable under tsgo:test. Widen to the fetch input type and
extract the URL via a small helper that avoids no-base-to-string on a
Request.

* fix(gateway): short-circuit Gravatar lookups and version presence avatar URL

Two autoreview findings on the avatar path:

- Privacy: the concurrent Promise.all fan-out queried every linked email's
  hash against Gravatar even when the primary already had one, exposing
  secondary work/personal addresses. Resolve sequentially with short-circuit
  so a later hash is disclosed only after the earlier email is a definite
  miss, under one shared request deadline that still bounds total latency.

- Staleness: presence published an unversioned /api/users/<id>/avatar, so a
  reconnecting viewer's <img> kept the stale cached image after an upload.
  Carry the profile revision as ?v=<updatedAt> so a changed avatar refetches.
2026-07-19 08:52:01 -07:00

254 lines
7.9 KiB
TypeScript

import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
validateUsersLinkEmailResult,
validateUsersSelfResult,
validateUsersSetAvatarResult,
validateUsersSetDisplayNameResult,
} from "../../../packages/gateway-protocol/src/index.js";
import { usersHandlers } from "./users.js";
const linkEmail = vi.hoisted(() => vi.fn());
const listProfiles = vi.hoisted(() => vi.fn());
const setAvatar = vi.hoisted(() => vi.fn());
const setDisplayName = vi.hoisted(() => vi.fn());
const ensureProfileForEmail = vi.hoisted(() => vi.fn());
const getUserProfileListItem = vi.hoisted(() => vi.fn());
const resolveUserProfileId = vi.hoisted(() => vi.fn());
vi.mock("../../state/user-profiles.js", () => ({
ensureProfileForEmail,
getUserProfileListItem,
linkEmail,
listProfiles,
resolveUserProfileId,
setAvatar,
setDisplayName,
UserProfileNotFoundError: class UserProfileNotFoundError extends Error {},
}));
async function runUsersHandler(
method: keyof typeof usersHandlers,
params: object,
client?: object,
) {
const respond = vi.fn();
await expectDefined(
usersHandlers[method],
`${method} test invariant`,
)({ client, params, respond } as never);
return respond;
}
describe("users gateway methods", () => {
const profile = {
id: "profile-1",
displayName: "Ada",
avatarMime: null,
mergedInto: null,
createdAt: 1,
updatedAt: 1,
emails: ["ada@example.com"],
hasAvatar: false,
};
const adminClient = { connect: { scopes: ["operator.admin"] } };
const selfClient = {
authenticatedUserId: "ada@example.com",
connect: { scopes: ["operator.write"] },
};
beforeEach(() => {
ensureProfileForEmail.mockReset();
getUserProfileListItem.mockReset();
resolveUserProfileId.mockReset();
linkEmail.mockReset();
listProfiles.mockReset();
setAvatar.mockReset();
setDisplayName.mockReset();
});
it("lists profiles through the read method", async () => {
listProfiles.mockReturnValue([{ id: "profile-1" }]);
expect(await runUsersHandler("users.list", {})).toHaveBeenCalledWith(true, {
profiles: [{ id: "profile-1" }],
});
});
it("creates and returns the caller's profile idempotently", async () => {
ensureProfileForEmail.mockReturnValue({ id: profile.id });
getUserProfileListItem.mockReturnValue(profile);
const first = await runUsersHandler("users.self", {}, selfClient);
const second = await runUsersHandler("users.self", {}, selfClient);
expect(first).toHaveBeenCalledWith(true, { profile });
expect(second).toHaveBeenCalledWith(true, { profile });
expect(validateUsersSelfResult(first.mock.calls[0]?.[1])).toBe(true);
expect(ensureProfileForEmail).toHaveBeenNthCalledWith(1, "ada@example.com");
expect(ensureProfileForEmail).toHaveBeenNthCalledWith(2, "ada@example.com");
expect(getUserProfileListItem).toHaveBeenNthCalledWith(1, profile.id);
expect(getUserProfileListItem).toHaveBeenNthCalledWith(2, profile.id);
});
it("rejects users.self without an authenticated user", async () => {
expect(
await runUsersHandler("users.self", {}, { connect: { scopes: ["operator.write"] } }),
).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "FORBIDDEN",
message: "users.self requires an authenticated user",
}),
);
expect(ensureProfileForEmail).not.toHaveBeenCalled();
});
it("validates and routes email links", async () => {
linkEmail.mockReturnValue(profile);
const respond = await runUsersHandler("users.linkEmail", {
email: "ada@example.com",
targetProfileId: "profile-1",
});
expect(respond).toHaveBeenCalledWith(true, { profile });
expect(validateUsersLinkEmailResult(respond.mock.calls[0]?.[1])).toBe(true);
expect(linkEmail).toHaveBeenCalledWith("ada@example.com", "profile-1");
});
it("returns protocol-complete display name mutations", async () => {
setDisplayName.mockReturnValue(profile);
const respond = await runUsersHandler(
"users.setDisplayName",
{
profileId: "profile-1",
displayName: "Ada",
},
adminClient,
);
expect(validateUsersSetDisplayNameResult(respond.mock.calls[0]?.[1])).toBe(true);
});
it("returns protocol-complete avatar mutations", async () => {
setAvatar.mockReturnValue({
ok: true,
value: { ...profile, avatarMime: "image/png", hasAvatar: true },
});
const respond = await runUsersHandler(
"users.setAvatar",
{
profileId: "profile-1",
mime: "image/png",
avatarBase64: "AQ==",
},
adminClient,
);
expect(validateUsersSetAvatarResult(respond.mock.calls[0]?.[1])).toBe(true);
});
it("rejects blank email aliases as invalid requests", async () => {
expect(
await runUsersHandler("users.linkEmail", {
email: " ",
targetProfileId: "profile-1",
}),
).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "INVALID_REQUEST", message: "email must not be empty" }),
);
expect(linkEmail).not.toHaveBeenCalled();
});
it("rejects malformed avatar payloads before storage", async () => {
expect(
await runUsersHandler("users.setAvatar", {
profileId: "profile-1",
mime: "image/png",
avatarBase64: "not base64",
}),
).toHaveBeenCalledWith(false, undefined, expect.objectContaining({ code: "INVALID_REQUEST" }));
expect(setAvatar).not.toHaveBeenCalled();
});
it("returns avatar constraint failures as invalid requests", async () => {
setAvatar.mockReturnValue({ ok: false, error: { code: "avatar_too_large" } });
expect(
await runUsersHandler(
"users.setAvatar",
{
profileId: "profile-1",
mime: "image/png",
avatarBase64: "AQ==",
},
adminClient,
),
).toHaveBeenCalledWith(false, undefined, expect.objectContaining({ code: "INVALID_REQUEST" }));
});
it("allows an identified write caller to edit its own profile", async () => {
ensureProfileForEmail.mockReturnValue(profile);
resolveUserProfileId.mockReturnValue(profile.id);
setDisplayName.mockReturnValue(profile);
setAvatar.mockReturnValue({ ok: true, value: profile });
const displayName = await runUsersHandler(
"users.setDisplayName",
{ profileId: "profile-1", displayName: "Ada Lovelace" },
selfClient,
);
const avatar = await runUsersHandler(
"users.setAvatar",
{ profileId: "profile-1", mime: "image/png", avatarBase64: "AQ==" },
selfClient,
);
expect(displayName).toHaveBeenCalledWith(true, { profile });
expect(avatar).toHaveBeenCalledWith(true, { profile });
expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@example.com");
});
it("denies an identified write caller changing another profile's avatar", async () => {
ensureProfileForEmail.mockReturnValue(profile);
resolveUserProfileId.mockReturnValue("profile-2");
expect(
await runUsersHandler(
"users.setAvatar",
{ profileId: "profile-2", mime: "image/png", avatarBase64: "AQ==" },
selfClient,
),
).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "FORBIDDEN",
message: "profile edits require the owning user or operator.admin",
}),
);
expect(setAvatar).not.toHaveBeenCalled();
});
it("allows an owner to edit through a tombstoned durable profile id", async () => {
ensureProfileForEmail.mockReturnValue(profile);
resolveUserProfileId.mockReturnValue(profile.id);
setDisplayName.mockReturnValue(profile);
expect(
await runUsersHandler(
"users.setDisplayName",
{ profileId: "merged-profile-1", displayName: "Ada Lovelace" },
selfClient,
),
).toHaveBeenCalledWith(true, { profile });
expect(resolveUserProfileId).toHaveBeenCalledWith("merged-profile-1");
});
});