mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 03:15:34 +02:00
* feat(autopilots): hide webhook URL token by default (MUL-5374) The webhook URL is a bearer credential — anyone who reads it off a screen share or screenshot can fire the autopilot. GH #6004 reports exactly that leak during a live demo. The trigger row and the post-create panel now render the URL through a shared WebhookUrlField that masks the token segment by default. Clicking the value (or the eye toggle) reveals it; Copy keeps working while hidden, so the common case never needs a reveal. The plaintext token is not in the DOM until the user asks for it — this is a real display boundary, not a CSS blur. Co-authored-by: multica-agent <github@multica.ai> * fix(autopilots): scope webhook URL reveal to the URL it was granted for (MUL-5374) The reveal was a bare boolean, so a token rotation under a mounted trigger row swapped in the new URL while `revealed` was still true — exposing the new credential in the clear at the exact moment the user rotated to contain a leak. Track which URL the reveal was granted for and derive `revealed` during render. Deriving it (rather than resetting in an effect) means the new token never reaches the DOM, not even for the pre-effect frame. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
75 lines
3.3 KiB
TypeScript
75 lines
3.3 KiB
TypeScript
import { describe, it, expect, beforeAll, vi } from "vitest";
|
|
import { screen, fireEvent } from "@testing-library/react";
|
|
import { renderWithI18n } from "../../test/i18n";
|
|
import { WebhookUrlField } from "./webhook-url-field";
|
|
|
|
// sonner.toast is a fire-and-forget side-effect; stub it so Copy doesn't blow
|
|
// up on toast invocation.
|
|
vi.mock("sonner", () => ({
|
|
toast: { success: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
|
|
// jsdom doesn't provide navigator.clipboard by default. Stub it once.
|
|
beforeAll(() => {
|
|
Object.assign(navigator, { clipboard: { writeText } });
|
|
});
|
|
|
|
const URL = "https://api.example.com/api/webhooks/autopilots/awt_supersecret";
|
|
|
|
describe("WebhookUrlField", () => {
|
|
it("hides the token by default — the plaintext is not in the DOM", () => {
|
|
const { container } = renderWithI18n(<WebhookUrlField url={URL} />);
|
|
expect(container.textContent).not.toContain("awt_supersecret");
|
|
expect(screen.getByText(/api\/webhooks\/autopilots\/•+/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("reveals the full URL when the masked value is clicked", () => {
|
|
const { container } = renderWithI18n(<WebhookUrlField url={URL} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "Webhook URL hidden — click to show" }));
|
|
expect(container.textContent).toContain(URL);
|
|
});
|
|
|
|
it("hides again via the toggle", () => {
|
|
const { container } = renderWithI18n(<WebhookUrlField url={URL} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "Webhook URL hidden — click to show" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "Hide URL" }));
|
|
expect(container.textContent).not.toContain("awt_supersecret");
|
|
});
|
|
|
|
it("copies the real URL while it is still hidden", async () => {
|
|
writeText.mockClear();
|
|
const { container } = renderWithI18n(<WebhookUrlField url={URL} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "Copy URL" }));
|
|
expect(writeText).toHaveBeenCalledWith(URL);
|
|
expect(container.textContent).not.toContain("awt_supersecret");
|
|
});
|
|
|
|
it("re-hides when the URL changes — a rotated token is never inherited revealed", () => {
|
|
const rotated = "https://api.example.com/api/webhooks/autopilots/awt_rotatedsecret";
|
|
const { container, rerender } = renderWithI18n(<WebhookUrlField url={URL} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "Webhook URL hidden — click to show" }));
|
|
expect(container.textContent).toContain(URL);
|
|
|
|
// Rotate: the row stays mounted and only the prop changes. The new
|
|
// credential must come back masked, with no revealed frame in between.
|
|
rerender(<WebhookUrlField url={rotated} />);
|
|
expect(container.textContent).not.toContain("awt_rotatedsecret");
|
|
expect(
|
|
screen.getByRole("button", { name: "Webhook URL hidden — click to show" }),
|
|
).toBeInTheDocument();
|
|
|
|
// The new URL can still be revealed on its own.
|
|
fireEvent.click(screen.getByRole("button", { name: "Webhook URL hidden — click to show" }));
|
|
expect(container.textContent).toContain(rotated);
|
|
});
|
|
|
|
it("renders trailing actions alongside the field", () => {
|
|
renderWithI18n(
|
|
<WebhookUrlField url={URL} actions={<button type="button">Rotate</button>} />,
|
|
);
|
|
expect(screen.getByRole("button", { name: "Rotate" })).toBeInTheDocument();
|
|
});
|
|
});
|