Files
webstudio/apps/builder/app/services/api-auth.server.test.ts

196 lines
5.9 KiB
TypeScript

import { expect, test, vi } from "vitest";
import { TRPCError } from "@trpc/server";
import { AuthorizationError } from "@webstudio-is/trpc-interface/index.server";
import {
authorizeApiProject,
ensureApiCsrf,
getApiAuthorizationFailure,
requiresApiCsrf,
} from "./api-auth.server";
test.each([
[
new TRPCError({ code: "UNAUTHORIZED", message: "login required" }),
{ status: 401, code: "UNAUTHORIZED", message: "login required" },
],
[
new TRPCError({ code: "FORBIDDEN", message: "denied" }),
{ status: 403, code: "FORBIDDEN", message: "denied" },
],
[
new AuthorizationError("project denied"),
{ status: 403, code: "FORBIDDEN", message: "project denied" },
],
[
new Response("CSRF failed", { status: 403 }),
{ status: 403, code: "FORBIDDEN", message: "Forbidden" },
],
])("classifies API authorization failures", (error, expected) => {
expect(getApiAuthorizationFailure(error)).toEqual(expected);
});
test("does not classify unrelated failures as authorization failures", () => {
expect(
getApiAuthorizationFailure(new Error("database failed"))
).toBeUndefined();
});
test("requires CSRF for cookie-authenticated API requests", () => {
expect(requiresApiCsrf(new Request("https://example.com/rest/assets"))).toBe(
true
);
});
test("uses explicit token authentication without cookie CSRF", () => {
expect(
requiresApiCsrf(
new Request("https://example.com/rest/assets", {
headers: { "x-auth-token": "share-token" },
})
)
).toBe(false);
});
test("requires CSRF for Builder share-token requests", () => {
expect(
requiresApiCsrf(
new Request("https://example.com/rest/assets", {
headers: {
"x-auth-token": "share-token",
"x-webstudio-client": "browser",
},
})
)
).toBe(true);
});
test("requires API enablement for explicit API tokens", async () => {
const context = { authorization: { type: "token" } } as never;
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn().mockResolvedValue(undefined),
hasProjectPermit: vi.fn().mockResolvedValue(true),
ensureApiCsrf: vi.fn(),
};
await expect(
authorizeApiProject(
new Request("https://example.com/rest/assets"),
"project-1",
"view",
dependencies
)
).resolves.toBe(context);
expect(dependencies.assertApiProjectPermit).toHaveBeenCalledWith(
context,
"project-1",
"view"
);
expect(dependencies.hasProjectPermit).not.toHaveBeenCalled();
});
test("checks project permits for cookie-authenticated requests", async () => {
const context = { authorization: { type: "user" } } as never;
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn(),
hasProjectPermit: vi.fn().mockResolvedValue(true),
ensureApiCsrf: vi.fn(),
};
await authorizeApiProject(
new Request("https://example.com/rest/assets"),
"project-1",
"build",
dependencies
);
expect(dependencies.assertApiProjectPermit).toHaveBeenCalledWith(
context,
"project-1",
"build"
);
expect(dependencies.hasProjectPermit).not.toHaveBeenCalled();
});
test("keeps Builder share-token requests on project authorization", async () => {
const context = { authorization: { type: "token" } } as never;
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn(),
hasProjectPermit: vi.fn().mockResolvedValue(true),
ensureApiCsrf: vi.fn().mockResolvedValue(undefined),
};
await authorizeApiProject(
new Request("https://example.com/rest/assets", {
headers: { "x-webstudio-client": "browser", "x-auth-token": "token" },
}),
"project-1",
"build",
dependencies
);
expect(dependencies.assertApiProjectPermit).not.toHaveBeenCalled();
expect(dependencies.hasProjectPermit).toHaveBeenCalledWith(
{ projectId: "project-1", permit: "build" },
context
);
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
});
test("rejects Builder share tokens for a different project", async () => {
const context = { authorization: { type: "token" } } as never;
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn(),
hasProjectPermit: vi.fn().mockResolvedValue(false),
ensureApiCsrf: vi.fn().mockResolvedValue(undefined),
};
await expect(
authorizeApiProject(
new Request("https://example.com/rest/assets", {
headers: { "x-webstudio-client": "browser", "x-auth-token": "token" },
}),
"other-project",
"view",
dependencies
)
).rejects.toThrow("You don't have access to this project");
expect(dependencies.assertApiProjectPermit).not.toHaveBeenCalled();
});
test("does not trust the Builder client marker without valid CSRF", async () => {
const context = { authorization: { type: "token" } } as never;
const csrfError = new Response("Forbidden", { status: 403 });
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn(),
hasProjectPermit: vi.fn().mockResolvedValue(true),
ensureApiCsrf: vi.fn().mockRejectedValue(csrfError),
};
await expect(
authorizeApiProject(
new Request("https://example.com/rest/assets", {
headers: { "x-webstudio-client": "browser", "x-auth-token": "token" },
}),
"project-1",
"view",
dependencies
)
).rejects.toBe(csrfError);
expect(dependencies.assertApiProjectPermit).not.toHaveBeenCalled();
});
test("validates CSRF only once for one request", async () => {
const request = new Request("https://example.com/rest/assets", {
headers: { "x-webstudio-client": "browser" },
});
const validate = vi.fn().mockResolvedValue(undefined);
await ensureApiCsrf(request, validate);
await ensureApiCsrf(request, validate);
expect(validate).toHaveBeenCalledOnce();
});