mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-22 16:35:26 +00:00
fix(domain): tear down preview Traefik router on domain delete
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Hoisted mutable mock functions so the vi.mock factories below can wire up
|
||||
// determininistic responses that individual tests override per-case.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findDomainById: vi.fn(),
|
||||
removeDomainById: vi.fn(),
|
||||
findApplicationById: vi.fn(),
|
||||
findPreviewDeploymentById: vi.fn(),
|
||||
removeDomain: vi.fn(),
|
||||
manageDomain: vi.fn(),
|
||||
checkServicePermissionAndAccess: vi.fn(),
|
||||
checkPermission: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
validateRequest: vi.fn(),
|
||||
hasValidLicense: vi.fn(),
|
||||
}));
|
||||
|
||||
// lib/auth runs betterAuth()/drizzleAdapter() at module load. Stub it so
|
||||
// pulling in the tRPC plumbing never initializes better-auth against the
|
||||
// globally-mocked db (see __test__/setup.ts).
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
auth: {},
|
||||
handler: vi.fn(),
|
||||
validateRequest: mocks.validateRequest,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkPermission: mocks.checkPermission,
|
||||
checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: mocks.audit,
|
||||
}));
|
||||
|
||||
// Replace the @dokploy/server barrel with controllable stubs so the domain
|
||||
// router's service calls are deterministic without DB/docker access, and so
|
||||
// the tRPC context init never loads heavy modules via the barrel.
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
findDomainById: mocks.findDomainById,
|
||||
removeDomainById: mocks.removeDomainById,
|
||||
findApplicationById: mocks.findApplicationById,
|
||||
findPreviewDeploymentById: mocks.findPreviewDeploymentById,
|
||||
removeDomain: mocks.removeDomain,
|
||||
manageDomain: mocks.manageDomain,
|
||||
createDomain: vi.fn(),
|
||||
updateDomainById: vi.fn(),
|
||||
findDomainsByApplicationId: vi.fn(),
|
||||
findDomainsByComposeId: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
generateTraefikMeDomain: vi.fn(),
|
||||
getServerIpCandidates: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
validateDomain: vi.fn(),
|
||||
hasValidLicense: mocks.hasValidLicense,
|
||||
}));
|
||||
|
||||
import { domainRouter } from "@/server/api/routers/domain";
|
||||
|
||||
const ctx = {
|
||||
session: { activeOrganizationId: "org-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
email: "u@example.com",
|
||||
role: "owner",
|
||||
ownerId: "org-1",
|
||||
},
|
||||
db: {},
|
||||
req: {},
|
||||
res: {},
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined);
|
||||
mocks.checkPermission.mockResolvedValue(undefined);
|
||||
mocks.removeDomainById.mockResolvedValue({ domainId: "" });
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.validateRequest.mockResolvedValue({ session: null, user: null });
|
||||
mocks.hasValidLicense.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("domainRouter.delete — Traefik cleanup", () => {
|
||||
it("removes the preview router from preview-*.yml on delete (regression: the router was orphaned)", async () => {
|
||||
const previewDomain = {
|
||||
domainId: "dom-preview",
|
||||
host: "preview-parent.example.com",
|
||||
applicationId: null,
|
||||
composeId: null,
|
||||
previewDeploymentId: "pd-1",
|
||||
uniqueConfigKey: 42,
|
||||
domainType: "preview",
|
||||
enabled: true,
|
||||
};
|
||||
const previewDeployment = {
|
||||
previewDeploymentId: "pd-1",
|
||||
applicationId: "app-1",
|
||||
appName: "preview-parent-ab12cd",
|
||||
};
|
||||
const parentApplication = {
|
||||
applicationId: "app-1",
|
||||
appName: "parent",
|
||||
serverId: null,
|
||||
};
|
||||
|
||||
mocks.findDomainById.mockResolvedValue(previewDomain);
|
||||
mocks.findPreviewDeploymentById.mockResolvedValue(previewDeployment);
|
||||
mocks.findApplicationById.mockResolvedValue(parentApplication);
|
||||
|
||||
const caller = domainRouter.createCaller(ctx);
|
||||
await caller.delete({ domainId: "dom-preview" });
|
||||
|
||||
// The DB row is deleted regardless of the bug.
|
||||
expect(mocks.removeDomainById).toHaveBeenCalledOnce();
|
||||
expect(mocks.removeDomainById).toHaveBeenCalledWith("dom-preview");
|
||||
|
||||
// BUG FIX: a preview domain has applicationId=null, so before the fix the
|
||||
// `if (domain.applicationId)` guard skipped removeDomain entirely and the
|
||||
// router kept serving traffic. The fix must invoke removeDomain.
|
||||
expect(mocks.removeDomain).toHaveBeenCalledOnce();
|
||||
const [applicationArg, uniqueKeyArg] =
|
||||
mocks.removeDomain.mock.calls[0] ?? [];
|
||||
expect(uniqueKeyArg).toBe(42);
|
||||
// removeDomain must target the preview's config file: the handler mutates
|
||||
// appName to the preview appName (preview-*.yml), not the parent's
|
||||
// (parent.yml). Without the mutation, the wrong file would be touched.
|
||||
expect(applicationArg.appName).toBe("preview-parent-ab12cd");
|
||||
expect(applicationArg.applicationId).toBe("app-1");
|
||||
|
||||
// Authorization and cleanup each resolve the preview deployment.
|
||||
expect(mocks.findPreviewDeploymentById).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.findPreviewDeploymentById).toHaveBeenCalledWith("pd-1");
|
||||
|
||||
// Application domains are never looked up on the preview path.
|
||||
expect(mocks.findApplicationById).toHaveBeenCalledOnce();
|
||||
expect(mocks.findApplicationById).toHaveBeenCalledWith("app-1");
|
||||
});
|
||||
|
||||
it("still removes the router for application domains (no regression)", async () => {
|
||||
const appDomain = {
|
||||
domainId: "dom-app",
|
||||
host: "app.example.com",
|
||||
applicationId: "app-2",
|
||||
composeId: null,
|
||||
previewDeploymentId: null,
|
||||
uniqueConfigKey: 7,
|
||||
domainType: "application",
|
||||
enabled: true,
|
||||
};
|
||||
mocks.findDomainById.mockResolvedValue(appDomain);
|
||||
mocks.findApplicationById.mockResolvedValue({
|
||||
applicationId: "app-2",
|
||||
appName: "myapp",
|
||||
serverId: null,
|
||||
});
|
||||
|
||||
const caller = domainRouter.createCaller(ctx);
|
||||
await caller.delete({ domainId: "dom-app" });
|
||||
|
||||
expect(mocks.removeDomainById).toHaveBeenCalledWith("dom-app");
|
||||
expect(mocks.removeDomain).toHaveBeenCalledOnce();
|
||||
const [applicationArg, uniqueKeyArg] =
|
||||
mocks.removeDomain.mock.calls[0] ?? [];
|
||||
// Application path uses the application's own appName (no mutation).
|
||||
expect(applicationArg.appName).toBe("myapp");
|
||||
expect(uniqueKeyArg).toBe(7);
|
||||
// Application path never resolves a preview deployment.
|
||||
expect(mocks.findPreviewDeploymentById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call file-provider cleanup for compose domains (no regression)", async () => {
|
||||
const composeDomain = {
|
||||
domainId: "dom-compose",
|
||||
host: "compose.example.com",
|
||||
applicationId: null,
|
||||
composeId: "comp-1",
|
||||
previewDeploymentId: null,
|
||||
uniqueConfigKey: 99,
|
||||
domainType: "compose",
|
||||
enabled: true,
|
||||
};
|
||||
mocks.findDomainById.mockResolvedValue(composeDomain);
|
||||
|
||||
const caller = domainRouter.createCaller(ctx);
|
||||
await caller.delete({ domainId: "dom-compose" });
|
||||
|
||||
expect(mocks.removeDomainById).toHaveBeenCalledWith("dom-compose");
|
||||
// Compose domains are published via docker labels, not the file provider,
|
||||
// so delete must not touch removeDomain. The fix's preview branch must not
|
||||
// widen to compose domains.
|
||||
expect(mocks.removeDomain).not.toHaveBeenCalled();
|
||||
expect(mocks.findPreviewDeploymentById).not.toHaveBeenCalled();
|
||||
expect(mocks.findApplicationById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
loadOrCreateConfig,
|
||||
writeTraefikConfig,
|
||||
} from "@dokploy/server/utils/traefik/application";
|
||||
import { removeDomain } from "@dokploy/server/utils/traefik/domain";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// removeDomain delegates path/forward-auth cleanup to siblings that operate on
|
||||
// the shared middlewares.yml. Stub those so this test stays focused on the
|
||||
// per-app router/service removal and the app-config-file lifecycle, which is
|
||||
// what the domain.delete fix now wires preview domains into.
|
||||
vi.mock("@dokploy/server/utils/traefik/middleware", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/traefik/middleware")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
removePathMiddlewares: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
vi.mock(
|
||||
"@dokploy/server/utils/traefik/forward-auth",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/traefik/forward-auth")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
removeForwardAuthMiddleware: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// preview config files are named preview-<parentApp>-<rand6>.yml; the domain.delete
|
||||
// fix mutates appName to this value before calling removeDomain.
|
||||
const APP_NAME = "preview-parent-ab12cd";
|
||||
|
||||
const seedFile = (keys: number[]) => {
|
||||
const routers: Record<string, unknown> = {};
|
||||
const services: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
routers[`${APP_NAME}-router-${key}`] = {
|
||||
rule: `Host(\`preview-${key}.example.com\`)`,
|
||||
service: `${APP_NAME}-service-${key}`,
|
||||
entryPoints: ["web"],
|
||||
middlewares: [],
|
||||
};
|
||||
routers[`${APP_NAME}-router-websecure-${key}`] = {
|
||||
rule: `Host(\`preview-${key}.example.com\`)`,
|
||||
service: `${APP_NAME}-service-${key}`,
|
||||
entryPoints: ["websecure"],
|
||||
middlewares: [],
|
||||
tls: { certResolver: "letsencrypt" },
|
||||
};
|
||||
services[`${APP_NAME}-service-${key}`] = {
|
||||
loadBalancer: {
|
||||
servers: [{ url: `http://${APP_NAME}:3000` }],
|
||||
passHostHeader: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
writeTraefikConfig(
|
||||
{ http: { routers: routers as never, services: services as never } },
|
||||
APP_NAME,
|
||||
);
|
||||
};
|
||||
|
||||
describe("removeDomain (preview-*.yml cleanup)", () => {
|
||||
let cwd: string;
|
||||
let dynamicPath: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-remove-domain-"));
|
||||
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
|
||||
fs.mkdirSync(dynamicPath, { recursive: true });
|
||||
configPath = path.join(dynamicPath, `${APP_NAME}.yml`);
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes the router and service for the given uniqueConfigKey while keeping others", async () => {
|
||||
seedFile([42, 7]);
|
||||
|
||||
await removeDomain({ appName: APP_NAME, serverId: null } as never, 42);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
const config = loadOrCreateConfig(APP_NAME);
|
||||
expect(config.http?.routers?.[`${APP_NAME}-router-42`]).toBeUndefined();
|
||||
expect(
|
||||
config.http?.routers?.[`${APP_NAME}-router-websecure-42`],
|
||||
).toBeUndefined();
|
||||
expect(config.http?.services?.[`${APP_NAME}-service-42`]).toBeUndefined();
|
||||
expect(config.http?.routers?.[`${APP_NAME}-router-7`]).toBeDefined();
|
||||
expect(
|
||||
config.http?.routers?.[`${APP_NAME}-router-websecure-7`],
|
||||
).toBeDefined();
|
||||
expect(config.http?.services?.[`${APP_NAME}-service-7`]).toBeDefined();
|
||||
});
|
||||
|
||||
it("deletes the whole preview-*.yml when the removed router was the last one", async () => {
|
||||
seedFile([42]);
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
|
||||
await removeDomain({ appName: APP_NAME, serverId: null } as never, 42);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -240,6 +240,15 @@ export const domainRouter = createTRPCRouter({
|
||||
if (domain.applicationId) {
|
||||
const application = await findApplicationById(domain.applicationId);
|
||||
await removeDomain(application, domain.uniqueConfigKey);
|
||||
} else if (domain.previewDeploymentId) {
|
||||
const previewDeployment = await findPreviewDeploymentById(
|
||||
domain.previewDeploymentId,
|
||||
);
|
||||
const application = await findApplicationById(
|
||||
previewDeployment.applicationId,
|
||||
);
|
||||
application.appName = previewDeployment.appName;
|
||||
await removeDomain(application, domain.uniqueConfigKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user