Submatter/apps/web/app/api/chat/ticket/route.test.ts

88 lines
3 KiB
TypeScript
Raw Normal View History

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockAuth } = vi.hoisted(() => ({
mockAuth: vi.fn<() => Promise<{ user: { id: string } } | null>>(),
}));
vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
vi.mock("@/server/lib/resolve-workspace", () => ({
resolveWorkspace: vi.fn(),
}));
import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
import { POST } from "./route";
const publicOrigin = "https://echodo.stillwell.cloud";
function request(
origin: string | null,
forwardedHost = "echodo.stillwell.cloud",
) {
const headers = new Headers({
"Content-Type": "application/json",
"x-forwarded-host": forwardedHost,
"x-forwarded-proto": "https",
});
if (origin !== null) headers.set("origin", origin);
return new Request("http://0.0.0.0:3000/api/chat/ticket", {
method: "POST",
headers,
body: JSON.stringify({ workspace: "example" }),
});
}
beforeEach(() => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("NEXTAUTH_URL", publicOrigin);
vi.stubEnv("AUTH_URL", "");
vi.stubEnv("CHAT_TICKET_SECRET", "x".repeat(64));
mockAuth.mockResolvedValue({
user: { id: "10000000-0000-4000-8000-000000000001" },
});
vi.mocked(resolveWorkspace).mockResolvedValue({
id: "20000000-0000-4000-8000-000000000001",
} as Awaited<ReturnType<typeof resolveWorkspace>>);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("Chat ticket origin boundary", () => {
it("accepts the configured public origin behind an internal Docker URL", async () => {
const response = await POST(request(publicOrigin));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(resolveWorkspace).toHaveBeenCalled();
});
it.each([
null,
"null",
"https://evil.example",
`${publicOrigin}.evil.example`,
`${publicOrigin}/path`,
])("rejects origin %s before session lookup", async (origin) => {
expect((await POST(request(origin, "evil.example"))).status).toBe(403);
expect(auth).not.toHaveBeenCalled();
});
it("does not trust forged forwarded host and protocol", async () => {
expect(
(await POST(request("https://evil.example", "evil.example"))).status,
).toBe(403);
});
it("fails closed without a configured production origin", async () => {
vi.stubEnv("NEXTAUTH_URL", "");
expect((await POST(request("http://0.0.0.0:3000"))).status).toBe(403);
});
it("fails closed with an invalid configured origin", async () => {
vi.stubEnv("NEXTAUTH_URL", "not a URL");
expect((await POST(request(publicOrigin))).status).toBe(403);
});
it("still requires an authenticated session", async () => {
mockAuth.mockResolvedValue(null);
expect((await POST(request(publicOrigin))).status).toBe(401);
});
it("does not mint a ticket when workspace authorization fails", async () => {
vi.mocked(resolveWorkspace).mockRejectedValue(new Error("denied"));
expect((await POST(request(publicOrigin))).status).toBe(404);
});
});