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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { cloudflareAccessRouter } from "@/server/api/routers/cloudflare-access";
import { createCallerFactory } from "@/server/api/trpc";

/**
* Cloudflare Access configuration changes org-wide auth on a published domain,
* so every procedure is admin-gated. A `member` must never reach them.
*/
const createCaller = createCallerFactory(cloudflareAccessRouter);

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;

describe("cloudflareAccess router authorization", () => {
const caller = createCaller(ctxFor("member"));

it("member cannot read Access config", async () => {
await expect(
caller.byDomainId({ domainId: "dom-1" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("member cannot enable/update Access", async () => {
await expect(
caller.upsert({
domainId: "dom-1",
sessionDuration: "24h",
allowEmails: ["a@example.com"],
allowEmailDomains: [],
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("member cannot remove Access", async () => {
await expect(caller.remove({ domainId: "dom-1" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
});
117 changes: 117 additions & 0 deletions apps/dokploy/__test__/cloudflare/cloudflare-access-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
buildAccessIncludeRules,
createAccessApplication,
createAccessPolicy,
} from "@dokploy/server/utils/providers/cloudflare";
import { afterEach, describe, expect, it, vi } from "vitest";

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("buildAccessIncludeRules", () => {
it("maps emails and email domains to Cloudflare include rules", () => {
const rules = buildAccessIncludeRules(
["a@example.com", "b@example.com"],
["example.org"],
);
expect(rules).toEqual([
{ email: { email: "a@example.com" } },
{ email: { email: "b@example.com" } },
{ email_domain: { domain: "example.org" } },
]);
});

it("returns an empty array when there are no allow rules", () => {
expect(buildAccessIncludeRules([], [])).toEqual([]);
});
});

describe("createAccessApplication", () => {
it("posts a self_hosted application bound to the host", async () => {
const fetchMock = stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "app-1", name: "app", domain: "app.example.com" },
}),
);
const app = await createAccessApplication("token", "acct-1", {
name: "app",
domain: "app.example.com",
sessionDuration: "24h",
});
expect(app.id).toBe("app-1");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.cloudflare.com/client/v4/accounts/acct-1/access/apps",
);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
name: "app",
type: "self_hosted",
domain: "app.example.com",
session_duration: "24h",
});
});

it("defaults the session duration to 24h", async () => {
const fetchMock = stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "app-1", name: "app", domain: "app.example.com" },
}),
);
await createAccessApplication("token", "acct-1", {
name: "app",
domain: "app.example.com",
});
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(init.body as string).session_duration).toBe("24h");
});
});

describe("createAccessPolicy", () => {
it("posts an app-scoped allow policy with include rules", async () => {
const fetchMock = stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "pol-1", name: "allow", decision: "allow" },
}),
);
const include = buildAccessIncludeRules(["a@example.com"], ["example.org"]);
const policy = await createAccessPolicy("token", "acct-1", "app-1", {
name: "Dokploy allow policy",
include,
});
expect(policy.id).toBe("pol-1");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
// App-scoped endpoint, NOT the detached /access/policies endpoint.
expect(url).toBe(
"https://api.cloudflare.com/client/v4/accounts/acct-1/access/apps/app-1/policies",
);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
name: "Dokploy allow policy",
decision: "allow",
include: [
{ email: { email: "a@example.com" } },
{ email_domain: { domain: "example.org" } },
],
});
});
});
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" });
});
});
Loading