88 lines
3 KiB
TypeScript
88 lines
3 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|