/* Copyright 2026 Lotus Guild SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { EventEmitter } from "events"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; import { ALLOWED_DECORATION_ORIGINS, startLotusDecorations, useLotusDecoration, } from "./lotusDecorations"; import { LotusWidgetActions } from "./lotusActions"; const lazyActions = new EventEmitter(); const reply = vi.fn(); const send = vi.fn().mockResolvedValue({}); vi.mock("../widget", () => ({ widget: { api: { transport: { reply: (...a: unknown[]) => reply(...a), send: (...a: unknown[]) => send(...a), }, }, // Getter: `vi.mock` factories run at import time, before the const above. get lazyActions(): EventEmitter { return lazyActions; }, }, })); function pushDecorations(decorations: Record): void { // `useSyncExternalStore`'s re-render from the module-level `emit()` needs to // be flushed inside `act()`, since the emitter fires outside of React's own // event handling. act(() => { lazyActions.emit(LotusWidgetActions.Decorations, { detail: { data: { decorations } }, }); }); } beforeEach(() => { reply.mockClear(); send.mockClear(); }); afterEach(() => { lazyActions.removeAllListeners(); }); test("[lotus #28] safeImageUrl only accepts the pinned decoration CDN origin (or blob:)", () => { const stop = startLotusDecorations(); const { result } = renderHook(() => useLotusDecoration("@alice:example.org")); pushDecorations({ "@alice:example.org": `${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`, }); expect(result.current).toBe(`${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`); // A different https host is rejected outright, even though it was // previously allowed by the "any https" check. pushDecorations({ "@alice:example.org": "https://evil.example/x.png" }); expect(result.current).toBeUndefined(); // Path traversal off the allowed origin is still on-origin, so the origin // check alone doesn't stop it (validation of the slug itself is the host's // job) — but a completely different scheme/host must never get through. pushDecorations({ "@alice:example.org": "javascript:alert(1)", }); expect(result.current).toBeUndefined(); pushDecorations({ "@alice:example.org": "blob:https://example.org/abc" }); expect(result.current).toBe("blob:https://example.org/abc"); stop(); }); test("[lotus #17] the roster survives a handler remount within the same page session", () => { const stop1 = startLotusDecorations(); pushDecorations({ "@alice:example.org": `${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`, }); const { result } = renderHook(() => useLotusDecoration("@alice:example.org")); expect(result.current).toBe(`${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`); // Tear the handler down (as InCallView would on an EC-side remount) and // bring it back up, WITHOUT the host re-pushing anything. stop1(); const stop2 = startLotusDecorations(); // The roster must still be there — it must not have been wiped to {} by // the teardown. expect(result.current).toBe(`${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`); stop2(); }); test("[lotus #17] (re)registering the handler asks the host to re-push state", () => { const stop1 = startLotusDecorations(); expect(send).toHaveBeenCalledWith(LotusWidgetActions.RequestState, {}); send.mockClear(); stop1(); const stop2 = startLotusDecorations(); expect(send).toHaveBeenCalledWith(LotusWidgetActions.RequestState, {}); stop2(); }); test("a second concurrent registration does not re-request state or double-register", () => { const stop1 = startLotusDecorations(); send.mockClear(); const stop2 = startLotusDecorations(); expect(send).not.toHaveBeenCalled(); stop2(); stop1(); });