mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
* fix(desktop): navigate existing reserved workspace slugs Co-authored-by: multica-agent <[email protected]> * fix(core): bind workspace slug lookup to the mounted provider (MUL-8028) - Bind the reserved-slug membership lookup from QueryProvider's effect instead of createQueryClient(), so StrictMode's discarded extra client can never become the bound one; unbind is owner-checked. - Only skip legacy unscoped-link prefixing for a path that is a known member workspace + workspace route, so /chat in content still becomes /<current>/chat (chat is not a reserved slug). - Group the new tests under describe blocks and cover both regressions. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> Co-authored-by: Multica Eve <[email protected]>
314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
import {
|
|
bindWorkspaceSlugLookup,
|
|
createQueryClient,
|
|
} from "@multica/core/query-client";
|
|
import { workspaceKeys } from "@multica/core/workspace/queries";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
openLink,
|
|
parseWorkspaceEntityLink,
|
|
toInternalAppPath,
|
|
} from "./link-handler";
|
|
|
|
const APP_ORIGIN = "https://app.multica.ai";
|
|
|
|
function navigatedPaths(): string[] {
|
|
return dispatched.map((e) => (e as CustomEvent<{ path: string }>).detail.path);
|
|
}
|
|
|
|
let dispatched: Event[] = [];
|
|
let openSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
let unbindWorkspaces: () => void = () => {};
|
|
|
|
/** Give the current account membership of `slugs`, as the mounted provider would. */
|
|
function withWorkspaces(slugs: string[]) {
|
|
const client = createQueryClient();
|
|
unbindWorkspaces = bindWorkspaceSlugLookup(client);
|
|
client.setQueryData(workspaceKeys.list(), slugs.map((slug) => ({ slug })));
|
|
return client;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
dispatched = [];
|
|
vi.spyOn(window, "dispatchEvent").mockImplementation((e: Event) => {
|
|
dispatched.push(e);
|
|
return true;
|
|
});
|
|
openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
unbindWorkspaces();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("toInternalAppPath", () => {
|
|
it("returns the path (with search and hash) for a URL on the app origin", () => {
|
|
expect(
|
|
toInternalAppPath(`${APP_ORIGIN}/acme/issues/MUL-1?tab=a#c`, APP_ORIGIN),
|
|
).toBe("/acme/issues/MUL-1?tab=a#c");
|
|
});
|
|
|
|
it("returns null for another origin", () => {
|
|
expect(toInternalAppPath("https://github.com/a/b/pull/1", APP_ORIGIN)).toBeNull();
|
|
});
|
|
|
|
it("returns null when the platform exposes no app origin", () => {
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/acme/issues/1`, null)).toBeNull();
|
|
});
|
|
|
|
it("keeps backend-served paths external so downloads and assets still work", () => {
|
|
// Every one of these first segments is a reserved slug, which is exactly
|
|
// why the reserved list — not a hand-kept deny-list — decides this.
|
|
expect(
|
|
toInternalAppPath(`${APP_ORIGIN}/api/attachments/abc/download`, APP_ORIGIN),
|
|
).toBeNull();
|
|
expect(
|
|
toInternalAppPath(`${APP_ORIGIN}/uploads/2026/07/report.pdf`, APP_ORIGIN),
|
|
).toBeNull();
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/uploads`, APP_ORIGIN)).toBeNull();
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/_next/static/x.js`, APP_ORIGIN)).toBeNull();
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/favicon.ico`, APP_ORIGIN)).toBeNull();
|
|
});
|
|
|
|
it("keeps pre-workspace and root paths external — they are not workspace pages", () => {
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/login`, APP_ORIGIN)).toBeNull();
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/auth/callback`, APP_ORIGIN)).toBeNull();
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/`, APP_ORIGIN)).toBeNull();
|
|
});
|
|
|
|
it("ignores case and percent-encoding when matching a reserved first segment", () => {
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/UPLOADS/x.pdf`, APP_ORIGIN)).toBeNull();
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/%75ploads/x.pdf`, APP_ORIGIN)).toBeNull();
|
|
});
|
|
|
|
it("returns null for non-http schemes and unparseable hrefs", () => {
|
|
expect(toInternalAppPath("mailto:[email protected]", APP_ORIGIN)).toBeNull();
|
|
expect(toInternalAppPath("not a url", APP_ORIGIN)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("openLink", () => {
|
|
it("navigates in-app for a URL pointing back at this deployment (MUL-5208)", () => {
|
|
openLink(`${APP_ORIGIN}/acme/issues/MUL-1`, "acme", APP_ORIGIN);
|
|
expect(navigatedPaths()).toEqual(["/acme/issues/MUL-1"]);
|
|
expect(openSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("navigates in-app for a cross-workspace app URL without rewriting the slug", () => {
|
|
openLink(`${APP_ORIGIN}/other/issues/MUL-1`, "acme", APP_ORIGIN);
|
|
expect(navigatedPaths()).toEqual(["/other/issues/MUL-1"]);
|
|
});
|
|
|
|
it("opens an external URL in a new window", () => {
|
|
openLink("https://github.com/multica-ai/multica/pull/1", "acme", APP_ORIGIN);
|
|
expect(dispatched).toHaveLength(0);
|
|
expect(openSpy).toHaveBeenCalledWith(
|
|
"https://github.com/multica-ai/multica/pull/1",
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
);
|
|
});
|
|
|
|
it("still opens an app URL externally when no app origin is known", () => {
|
|
openLink(`${APP_ORIGIN}/acme/issues/MUL-1`, "acme");
|
|
expect(dispatched).toHaveLength(0);
|
|
expect(openSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("prefixes the current slug on a slugless workspace path", () => {
|
|
openLink("/issues/MUL-1", "acme", APP_ORIGIN);
|
|
expect(navigatedPaths()).toEqual(["/acme/issues/MUL-1"]);
|
|
});
|
|
|
|
it("leaves a path that already carries a slug alone", () => {
|
|
openLink("/other/issues/MUL-1", "acme", APP_ORIGIN);
|
|
expect(navigatedPaths()).toEqual(["/other/issues/MUL-1"]);
|
|
});
|
|
|
|
it("defaults the disposition to push and carries an explicit click intent through the event", () => {
|
|
openLink("/acme/issues/MUL-1", "acme", APP_ORIGIN);
|
|
openLink("/acme/issues/MUL-2", "acme", APP_ORIGIN, "background-tab");
|
|
const details = dispatched.map(
|
|
(e) => (e as CustomEvent<{ path: string; disposition: string }>).detail,
|
|
);
|
|
expect(details).toEqual([
|
|
{ path: "/acme/issues/MUL-1", disposition: "push" },
|
|
{ path: "/acme/issues/MUL-2", disposition: "background-tab" },
|
|
]);
|
|
});
|
|
|
|
it("ignores the intent for an external URL — it always hands off to the browser", () => {
|
|
openLink("https://github.com/a/b", "acme", APP_ORIGIN, "foreground-tab");
|
|
expect(dispatched).toHaveLength(0);
|
|
expect(openSpy).toHaveBeenCalledWith(
|
|
"https://github.com/a/b",
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("parseWorkspaceEntityLink", () => {
|
|
const PROJECT_ID = "8f14e45f-ceea-4d0e-a1a2-9b1c0d3e4f5a";
|
|
const ISSUE_ID = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed";
|
|
|
|
it("parses an absolute project URL on the app origin", () => {
|
|
expect(
|
|
parseWorkspaceEntityLink(
|
|
`${APP_ORIGIN}/acme/projects/${PROJECT_ID}`,
|
|
APP_ORIGIN,
|
|
),
|
|
).toEqual({ kind: "project", id: PROJECT_ID, slug: "acme" });
|
|
});
|
|
|
|
it("parses an absolute issue URL on the app origin", () => {
|
|
expect(
|
|
parseWorkspaceEntityLink(`${APP_ORIGIN}/acme/issues/${ISSUE_ID}`, APP_ORIGIN),
|
|
).toEqual({ kind: "issue", id: ISSUE_ID, slug: "acme" });
|
|
});
|
|
|
|
it("parses a site-relative path without needing an app origin", () => {
|
|
expect(parseWorkspaceEntityLink(`/acme/projects/${PROJECT_ID}`)).toEqual({
|
|
kind: "project",
|
|
id: PROJECT_ID,
|
|
slug: "acme",
|
|
});
|
|
});
|
|
|
|
it("reports a null slug for the slugless legacy form", () => {
|
|
expect(parseWorkspaceEntityLink(`/projects/${PROJECT_ID}`)).toEqual({
|
|
kind: "project",
|
|
id: PROJECT_ID,
|
|
slug: null,
|
|
});
|
|
});
|
|
|
|
it("returns null for another origin", () => {
|
|
expect(
|
|
parseWorkspaceEntityLink(
|
|
`https://evil.example/acme/projects/${PROJECT_ID}`,
|
|
APP_ORIGIN,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
// A leading slash does not mean "this site". Both of these name another host
|
|
// and a browser follows them there, so the parser has to resolve the href
|
|
// rather than test its prefix.
|
|
it("returns null for a host-bearing href that still starts with a slash", () => {
|
|
expect(
|
|
parseWorkspaceEntityLink(`//evil.example/projects/${PROJECT_ID}`, APP_ORIGIN),
|
|
).toBeNull();
|
|
// Backslashes are normalised to slashes, so this names evil.example too —
|
|
// and it slips past a `//` prefix test.
|
|
expect(
|
|
parseWorkspaceEntityLink(`/\\evil.example/projects/${PROJECT_ID}`, APP_ORIGIN),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("returns null for a non-http scheme", () => {
|
|
expect(parseWorkspaceEntityLink("javascript:alert(1)", APP_ORIGIN)).toBeNull();
|
|
});
|
|
|
|
// The slugless form resolves against the current workspace either way; the
|
|
// two spellings must not disagree about that.
|
|
it("treats the slugless form the same whether or not it carries the origin", () => {
|
|
expect(
|
|
parseWorkspaceEntityLink(`${APP_ORIGIN}/projects/${PROJECT_ID}`, APP_ORIGIN),
|
|
).toEqual({ kind: "project", id: PROJECT_ID, slug: null });
|
|
});
|
|
|
|
it("returns null for a list page", () => {
|
|
expect(parseWorkspaceEntityLink("/acme/projects")).toBeNull();
|
|
});
|
|
|
|
it("returns null for a deeper route under the entity", () => {
|
|
expect(
|
|
parseWorkspaceEntityLink(`/acme/projects/${PROJECT_ID}/settings`),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("returns null for an entity route this parser has no chip for", () => {
|
|
expect(parseWorkspaceEntityLink(`/acme/agents/${PROJECT_ID}`)).toBeNull();
|
|
});
|
|
|
|
// A query string or fragment addresses something narrower than the entity
|
|
// page, and a chip cannot carry it.
|
|
it("returns null when the link carries a query string or fragment", () => {
|
|
expect(
|
|
parseWorkspaceEntityLink(`/acme/projects/${PROJECT_ID}?tab=issues`),
|
|
).toBeNull();
|
|
expect(
|
|
parseWorkspaceEntityLink(`/acme/issues/${ISSUE_ID}#comment-3`),
|
|
).toBeNull();
|
|
});
|
|
|
|
// `copyLink` / `openInNewTab` build `paths.issueDetail(identifier || id)` and
|
|
// the issue route rewrites a UUID URL back to the identifier, so this — not
|
|
// the UUID form — is what a user actually copies out of the app.
|
|
it("parses an issue addressed by identifier", () => {
|
|
expect(parseWorkspaceEntityLink("/acme/issues/MUL-1")).toEqual({
|
|
kind: "issue",
|
|
id: "MUL-1",
|
|
slug: "acme",
|
|
});
|
|
});
|
|
|
|
// A project has no shorthand, so an identifier-shaped id under /projects/
|
|
// addresses nothing this parser could resolve.
|
|
it("returns null for an identifier-shaped project id", () => {
|
|
expect(parseWorkspaceEntityLink("/acme/projects/MUL-1")).toBeNull();
|
|
});
|
|
|
|
it("returns null for an id that is neither a UUID nor an identifier", () => {
|
|
expect(parseWorkspaceEntityLink("/acme/issues/roadmap")).toBeNull();
|
|
// Lowercase is not the identifier form — matching it would turn ordinary
|
|
// hyphenated path segments into entity references.
|
|
expect(parseWorkspaceEntityLink("/acme/issues/mul-1")).toBeNull();
|
|
});
|
|
|
|
it("returns null when the slug position holds a reserved slug", () => {
|
|
expect(parseWorkspaceEntityLink(`/login/projects/${PROJECT_ID}`)).toBeNull();
|
|
});
|
|
|
|
it("returns null for a malformed percent-escape", () => {
|
|
expect(parseWorkspaceEntityLink("/acme/projects/%E0%A4%A")).toBeNull();
|
|
});
|
|
});
|
|
|
|
|
|
describe("existing reserved-slug workspaces", () => {
|
|
it("keeps known reserved workspace links internal, including route-name slugs", () => {
|
|
const client = withWorkspaces(["dingtalk", "issues"]);
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/dingtalk/issues/MUL-123`, APP_ORIGIN)).toBe(
|
|
"/dingtalk/issues/MUL-123",
|
|
);
|
|
expect(
|
|
parseWorkspaceEntityLink(`${APP_ORIGIN}/dingtalk/issues/MUL-123`, APP_ORIGIN),
|
|
).toEqual({ kind: "issue", id: "MUL-123", slug: "dingtalk" });
|
|
openLink(`${APP_ORIGIN}/dingtalk/issues/MUL-123`, "acme", APP_ORIGIN);
|
|
openLink("/issues/issues/MUL-123", "acme", APP_ORIGIN);
|
|
expect(navigatedPaths()).toEqual(["/dingtalk/issues/MUL-123", "/issues/issues/MUL-123"]);
|
|
expect(openSpy).not.toHaveBeenCalled();
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/dingtalk/bind`, APP_ORIGIN)).toBeNull();
|
|
client.clear();
|
|
expect(toInternalAppPath(`${APP_ORIGIN}/dingtalk/issues/MUL-123`, APP_ORIGIN)).toBeNull();
|
|
});
|
|
|
|
it("still scopes legacy unscoped links whose route is not a reserved slug", () => {
|
|
withWorkspaces(["acme"]);
|
|
openLink("/chat", "acme", APP_ORIGIN);
|
|
openLink("/inbox", "acme", APP_ORIGIN);
|
|
expect(navigatedPaths()).toEqual(["/acme/chat", "/acme/inbox"]);
|
|
});
|
|
|
|
it("leaves a member workspace named after a route alone", () => {
|
|
withWorkspaces(["chat"]);
|
|
openLink("/chat/issues", "acme", APP_ORIGIN);
|
|
openLink("/chat", "acme", APP_ORIGIN);
|
|
expect(navigatedPaths()).toEqual(["/chat/issues", "/acme/chat"]);
|
|
});
|
|
});
|