Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/dokploy/__test__/cloudflare/cloudflare-api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
CloudflareApiError,
verifyToken,
} from "@dokploy/server/utils/providers/cloudflare";
import { afterEach, describe, expect, it, vi } from "vitest";

/**
* Minimal stand-in for a `fetch` Response. The client only relies on `ok`,
* `status` and `json()`.
*/
const fakeResponse = (ok: boolean, status: number, body: unknown) =>
({
ok,
status,
json: async () => body,
}) as unknown as Response;

const stubFetch = (response: Response) => {
const fetchMock = vi.fn().mockResolvedValue(response);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("verifyToken", () => {
it("resolves and sends a bearer token to the verify endpoint", async () => {
const fetchMock = stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "abc", status: "active" },
}),
);

const result = await verifyToken("token-123");

expect(result.status).toBe("active");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://api.cloudflare.com/client/v4/user/tokens/verify");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer token-123",
);
});

it("throws a mapped CloudflareApiError when the token is invalid", async () => {
stubFetch(
fakeResponse(false, 401, {
success: false,
errors: [{ code: 1000, message: "Invalid API Token" }],
messages: [],
result: null,
}),
);

await expect(verifyToken("bad-token")).rejects.toThrow("Invalid API Token");
await expect(verifyToken("bad-token")).rejects.toBeInstanceOf(
CloudflareApiError,
);
});

it("throws when the token verifies but is not active", async () => {
stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "abc", status: "disabled" },
}),
);

await expect(verifyToken("token")).rejects.toThrow(/not active|disabled/i);
});

it("never leaks the API token in the error message", async () => {
const secret = "super-secret-token-value";
stubFetch(
fakeResponse(false, 403, {
success: false,
errors: [
{ code: 9109, message: "Unauthorized to access requested resource" },
],
messages: [],
result: null,
}),
);

await expect(verifyToken(secret)).rejects.toMatchObject({
message: expect.not.stringContaining(secret),
});
});

it("falls back to a generic message when the body has no errors", async () => {
stubFetch(fakeResponse(false, 500, { success: false }));

await expect(verifyToken("token")).rejects.toThrow(/HTTP 500/);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";

/**
* Publishing a domain via Cloudflare triggers org-wide DNS/Tunnel changes, so it
* must require an owner/admin even when the caller holds service-level
* `domain:create`. These tests isolate that gate by stubbing the service-access
* check (so a member passes it) and asserting the Cloudflare admin gate still
* blocks the member.
*/
vi.mock("@dokploy/server/services/permission", async (importOriginal) => ({
...(await importOriginal<object>()),
checkServicePermissionAndAccess: vi.fn(() => Promise.resolve()),
}));

vi.mock("@dokploy/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dokploy/server")>();
return {
...actual,
createDomain: vi.fn(async (input: { host: string }) => ({
domainId: "dom-1",
host: input.host,
applicationId: "app-1",
})),
findDomainById: vi.fn(async () => ({
domainId: "dom-1",
host: "app.example.com",
applicationId: "app-1",
composeId: null,
previewDeploymentId: null,
publishToCloudflare: false,
})),
provisionCloudflareForDomain: vi.fn(async () => {}),
isCloudflarePublished: vi.fn(() => false),
findCloudflareById: vi.fn(async () => ({
cloudflareId: "cf-1",
organizationId: "org-1",
name: "prod",
apiToken: "secret",
accountId: "acct-1",
})),
};
});

const { domainRouter } = await import("@/server/api/routers/domain");
const { createCallerFactory } = await import("@/server/api/trpc");
const { findCloudflareById } = await import("@dokploy/server");

const createCaller = createCallerFactory(domainRouter);

const ctxFor = (role: "owner" | "admin" | "member") =>
({
user: { id: "user-1", email: "user@test.com", role },
session: { activeOrganizationId: "org-1" },
req: {} as unknown,
res: {} as unknown,
}) as never;

const publishInput = {
host: "app.example.com",
domainType: "application" as const,
applicationId: "app-1",
publishToCloudflare: true,
cloudflareId: "cf-1",
cloudflareTunnelMode: "shared-managed" as const,
};

describe("domain.create Cloudflare publish gate", () => {
it("rejects a member trying to publish via Cloudflare", async () => {
const caller = createCaller(ctxFor("member"));
await expect(caller.create(publishInput)).rejects.toThrow(
/owners or admins can publish/i,
);
});

it("allows an admin to publish via Cloudflare", async () => {
const caller = createCaller(ctxFor("admin"));
await expect(caller.create(publishInput)).resolves.toBeDefined();
});

it("rejects publishing with an integration from another organization", async () => {
vi.mocked(findCloudflareById).mockResolvedValueOnce({
cloudflareId: "cf-1",
organizationId: "other-org",
name: "prod",
apiToken: "secret",
accountId: "acct-1",
} as Awaited<ReturnType<typeof findCloudflareById>>);
const caller = createCaller(ctxFor("admin"));
await expect(caller.create(publishInput)).rejects.toThrow(
/not found in this organization/i,
);
});
});

describe("domain.update Cloudflare publish gate", () => {
it("rejects a member turning on Cloudflare publishing", async () => {
const caller = createCaller(ctxFor("member"));
await expect(
caller.update({ domainId: "dom-1", ...publishInput }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
82 changes: 82 additions & 0 deletions apps/dokploy/__test__/cloudflare/cloudflare-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { cloudflareRouter } from "@/server/api/routers/cloudflare";
import { createCallerFactory } from "@/server/api/trpc";

/**
* These tests assert the authorization model for the Cloudflare integration:
* a `member` must never be able to read or mutate org-scoped Cloudflare
* credentials, while owner/admin pass the gate.
*
* This guards against the subtle bug where `withPermission("cloudflare", …)`
* would be used instead of `adminProcedure`: because `cloudflare` is an
* enterprise-only resource, `checkPermission` short-circuits for static roles
* and would silently authorize a `member`. The router uses `adminProcedure`
* to enforce an owner/admin role directly — these tests fail if that ever
* regresses to a permission-based gate.
*/
const createCaller = createCallerFactory(cloudflareRouter);

const ctxFor = (role: "owner" | "admin" | "member") =>
({
user: { id: "user-1", email: "user@test.com", role },
session: { activeOrganizationId: "org-1" },
req: {} as unknown,
res: {} as unknown,
}) as never;

const validCreate = {
name: "production",
apiToken: "cf-test-token",
accountId: "acct-1",
};

describe("cloudflare router authorization", () => {
describe("a member is denied every Cloudflare operation", () => {
const caller = createCaller(ctxFor("member"));

it("cannot create", async () => {
await expect(caller.create(validCreate)).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});

it("cannot update", async () => {
await expect(
caller.update({ cloudflareId: "x", name: "y", accountId: "z" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("cannot remove", async () => {
await expect(caller.remove({ cloudflareId: "x" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});

it("cannot test a connection", async () => {
await expect(
caller.testConnection({ apiToken: "t", accountId: "a" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("cannot list or read", async () => {
await expect(caller.all()).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
await expect(caller.one({ cloudflareId: "x" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
});

describe("owners and admins pass the admin gate", () => {
it("owner can create", async () => {
const caller = createCaller(ctxFor("owner"));
await expect(caller.create(validCreate)).resolves.toBeDefined();
});

it("admin can create", async () => {
const caller = createCaller(ctxFor("admin"));
await expect(caller.create(validCreate)).resolves.toBeDefined();
});
});
});
56 changes: 56 additions & 0 deletions apps/dokploy/__test__/cloudflare/cloudflare-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";

/**
* The stored Cloudflare API token must never be returned to the client. These
* tests stub the service layer to return a row that still contains the token
* and assert the router strips it before responding.
*/
const SECRET = "cf-secret-token-value";

const fullRow = {
cloudflareId: "cf-1",
name: "production",
apiToken: SECRET,
accountId: "acct-1",
defaultTunnelId: null,
organizationId: "org-1",
createdAt: new Date(),
};

vi.mock("@dokploy/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dokploy/server")>();
return {
...actual,
createCloudflare: vi.fn(async () => fullRow),
findCloudflareById: vi.fn(async () => fullRow),
};
});

const { cloudflareRouter } = await import("@/server/api/routers/cloudflare");
const { createCallerFactory } = await import("@/server/api/trpc");

const createCaller = createCallerFactory(cloudflareRouter);
const caller = createCaller({
user: { id: "user-1", email: "owner@test.com", role: "owner" },
session: { activeOrganizationId: "org-1" },
req: {} as unknown,
res: {} as unknown,
} as never);

describe("cloudflare token redaction", () => {
it("does not return the API token from create", async () => {
const result = await caller.create({
name: "production",
apiToken: SECRET,
accountId: "acct-1",
});
expect(result).not.toHaveProperty("apiToken");
expect(JSON.stringify(result)).not.toContain(SECRET);
});

it("does not return the API token from one", async () => {
const result = await caller.one({ cloudflareId: "cf-1" });
expect(result).not.toHaveProperty("apiToken");
expect(JSON.stringify(result)).not.toContain(SECRET);
});
});
Loading