mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
f8e35afecc
* 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.
254 lines
7.9 KiB
TypeScript
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");
|
|
});
|
|
});
|