mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* fix(web): resolve upstream URLs at runtime * fix(web): keep unconfigured upstreams same-origin * fix(web): restore dev-only localhost fallbacks for API and docs upstreams `next dev` on a developer machine now falls back to the conventional http://localhost:8080 backend (honoring BACKEND_PORT) and http://localhost:4000 docs origin when nothing is configured, so a bare `pnpm dev:web` keeps proxying out of the box. Builds and the runtime proxy keep the strict resolvers, so prebuilt images still leave unset upstreams unproxied. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
234 lines
7.0 KiB
TypeScript
234 lines
7.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
import { MULTICA_LOCALE_HEADER } from "./lib/locale-routing";
|
|
import { proxy } from "./proxy";
|
|
|
|
function makeRequest(
|
|
path: string,
|
|
cookies: Record<string, string> = {},
|
|
host = "app.multica.test",
|
|
) {
|
|
const cookieHeader = Object.entries(cookies)
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join("; ");
|
|
|
|
return new NextRequest(`https://${host}${path}`, {
|
|
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
});
|
|
}
|
|
|
|
function redirectLocation(
|
|
path: string,
|
|
cookies: Record<string, string> = {},
|
|
host?: string,
|
|
) {
|
|
return proxy(makeRequest(path, cookies, host)).headers.get("location");
|
|
}
|
|
|
|
function restoreEnv(key: string, value: string | undefined) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
|
|
function withoutRuntimeUpstreams(run: () => void) {
|
|
const previousRemoteApiUrl = process.env.REMOTE_API_URL;
|
|
const previousDocsUrl = process.env.DOCS_URL;
|
|
const previousPublicApiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
const previousPort = process.env.PORT;
|
|
delete process.env.REMOTE_API_URL;
|
|
delete process.env.DOCS_URL;
|
|
delete process.env.NEXT_PUBLIC_API_URL;
|
|
process.env.PORT = "3000";
|
|
|
|
try {
|
|
run();
|
|
} finally {
|
|
restoreEnv("REMOTE_API_URL", previousRemoteApiUrl);
|
|
restoreEnv("DOCS_URL", previousDocsUrl);
|
|
restoreEnv("NEXT_PUBLIC_API_URL", previousPublicApiUrl);
|
|
restoreEnv("PORT", previousPort);
|
|
}
|
|
}
|
|
|
|
describe("proxy legacy workspace route redirects", () => {
|
|
const sessionCookies = {
|
|
multica_logged_in: "1",
|
|
last_workspace_slug: "acme",
|
|
};
|
|
|
|
it.each([
|
|
["issues", "/acme/issues"],
|
|
["projects", "/acme/projects"],
|
|
["agents", "/acme/agents"],
|
|
["squads", "/acme/squads"],
|
|
["inbox", "/acme/inbox"],
|
|
["my-issues", "/acme/my-issues"],
|
|
["autopilots", "/acme/autopilots"],
|
|
["runtimes", "/acme/runtimes"],
|
|
["skills", "/acme/skills"],
|
|
["settings", "/acme/settings"],
|
|
["usage", "/acme/usage"],
|
|
])(
|
|
"redirects legacy /%s URLs through the last workspace slug",
|
|
(segment, expectedPath) => {
|
|
expect(redirectLocation(`/${segment}?tab=all`, sessionCookies)).toBe(
|
|
`https://app.multica.test${expectedPath}?tab=all`,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("preserves nested legacy paths and query strings", () => {
|
|
expect(
|
|
redirectLocation("/squads/squad-123?view=members", sessionCookies),
|
|
).toBe("https://app.multica.test/acme/squads/squad-123?view=members");
|
|
});
|
|
|
|
it("sends logged-out legacy URLs to login", () => {
|
|
expect(redirectLocation("/usage?tab=billing")).toBe(
|
|
"https://app.multica.test/login?tab=billing",
|
|
);
|
|
});
|
|
|
|
it("sends logged-in legacy URLs without a last workspace cookie to root", () => {
|
|
expect(
|
|
redirectLocation("/squads", { multica_logged_in: "1" }),
|
|
).toBe("https://app.multica.test/");
|
|
});
|
|
|
|
it("does not redirect workspace-scoped URLs whose first segment is already a slug", () => {
|
|
expect(redirectLocation("/acme/squads", sessionCookies)).toBeNull();
|
|
});
|
|
|
|
it("redirects app-host root URLs to the last workspace", () => {
|
|
expect(redirectLocation("/", sessionCookies)).toBe(
|
|
"https://app.multica.test/acme/issues",
|
|
);
|
|
});
|
|
|
|
it.each(["multica.ai", "www.multica.ai"])(
|
|
"does not redirect public marketing root on %s",
|
|
(host) => {
|
|
expect(redirectLocation("/", sessionCookies, host)).toBeNull();
|
|
},
|
|
);
|
|
|
|
it("still redirects explicit legacy app routes on the public marketing host", () => {
|
|
expect(redirectLocation("/issues/ABC-123", sessionCookies, "multica.ai")).toBe(
|
|
"https://multica.ai/acme/issues/ABC-123",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("proxy runtime upstream rewrites", () => {
|
|
it("does not rewrite API requests when no runtime API origin is configured", () => {
|
|
withoutRuntimeUpstreams(() => {
|
|
const res = proxy(makeRequest("/api/config?x=1"));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-middleware-rewrite")).toBeNull();
|
|
expect(
|
|
res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`),
|
|
).toBe("en");
|
|
});
|
|
});
|
|
|
|
it("does not rewrite docs requests when no runtime docs origin is configured", () => {
|
|
withoutRuntimeUpstreams(() => {
|
|
const res = proxy(makeRequest("/docs/zh"));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-middleware-rewrite")).toBeNull();
|
|
expect(
|
|
res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`),
|
|
).toBe("en");
|
|
});
|
|
});
|
|
|
|
it("rewrites API requests to the runtime API origin", () => {
|
|
const previous = process.env.REMOTE_API_URL;
|
|
process.env.REMOTE_API_URL = "http://backend:8080";
|
|
try {
|
|
const res = proxy(makeRequest("/api/config?x=1"));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-middleware-rewrite")).toBe(
|
|
"http://backend:8080/api/config?x=1",
|
|
);
|
|
} finally {
|
|
restoreEnv("REMOTE_API_URL", previous);
|
|
}
|
|
});
|
|
|
|
it("rewrites docs requests to the runtime docs origin", () => {
|
|
const previous = process.env.DOCS_URL;
|
|
process.env.DOCS_URL = "http://docs:4000";
|
|
try {
|
|
const res = proxy(makeRequest("/docs/zh/agents"));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-middleware-rewrite")).toBe(
|
|
"http://docs:4000/docs/zh/agents",
|
|
);
|
|
} finally {
|
|
restoreEnv("DOCS_URL", previous);
|
|
}
|
|
});
|
|
|
|
it("rewrites websocket requests to the runtime API origin", () => {
|
|
const previous = process.env.REMOTE_API_URL;
|
|
process.env.REMOTE_API_URL = "http://backend:8080";
|
|
try {
|
|
const res = proxy(makeRequest("/ws"));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-middleware-rewrite")).toBe(
|
|
"http://backend:8080/ws",
|
|
);
|
|
} finally {
|
|
restoreEnv("REMOTE_API_URL", previous);
|
|
}
|
|
});
|
|
|
|
it("does not rewrite frontend auth callback pages", () => {
|
|
const previous = process.env.REMOTE_API_URL;
|
|
process.env.REMOTE_API_URL = "http://backend:8080";
|
|
try {
|
|
const res = proxy(makeRequest("/auth/callback"));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-middleware-rewrite")).toBeNull();
|
|
expect(
|
|
res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`),
|
|
).toBe("en");
|
|
} finally {
|
|
restoreEnv("REMOTE_API_URL", previous);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("proxy root and locale handling", () => {
|
|
it("redirects logged-in root visits to the last workspace", () => {
|
|
const res = proxy(
|
|
makeRequest("/", {
|
|
multica_logged_in: "1",
|
|
last_workspace_slug: "acme",
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.get("location")).toBe(
|
|
"https://app.multica.test/acme/issues",
|
|
);
|
|
});
|
|
|
|
it("forwards locale on login requests", () => {
|
|
const res = proxy(makeRequest("/login", { "multica-locale": "zh-Hans" }));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("location")).toBeNull();
|
|
expect(
|
|
res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`),
|
|
).toBe("zh-Hans");
|
|
});
|
|
});
|