Skip to content
Open
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
142 changes: 142 additions & 0 deletions apps/dokploy/__test__/schedules/database-schedule-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
findDatabase: {
postgres: vi.fn(),
mysql: vi.fn(),
mariadb: vi.fn(),
mongo: vi.fn(),
redis: vi.fn(),
libsql: vi.fn(),
},
checkServicePermissionAndAccess: vi.fn(),
}));

vi.mock("@trpc/server", () => ({
TRPCError: class extends Error {
code: string;

constructor({ code, message }: { code: string; message: string }) {
super(message);
this.code = code;
}
},
}));
vi.mock("@dokploy/server/services/postgres", () => ({
findPostgresById: mocks.findDatabase.postgres,
}));
vi.mock("@dokploy/server/services/mysql", () => ({
findMySqlById: mocks.findDatabase.mysql,
}));
vi.mock("@dokploy/server/services/mariadb", () => ({
findMariadbById: mocks.findDatabase.mariadb,
}));
vi.mock("@dokploy/server/services/mongo", () => ({
findMongoById: mocks.findDatabase.mongo,
}));
vi.mock("@dokploy/server/services/redis", () => ({
findRedisById: mocks.findDatabase.redis,
}));
vi.mock("@dokploy/server/services/libsql", () => ({
findLibsqlById: mocks.findDatabase.libsql,
}));
vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess,
}));

const { checkDatabaseScheduleAccess } = await import(
"@/server/api/utils/database-schedule-access"
);

const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};

beforeEach(() => {
vi.resetAllMocks();
for (const findDatabase of Object.values(mocks.findDatabase)) {
findDatabase.mockResolvedValue({
environment: { project: { organizationId: "org-1" } },
});
}
});

describe.each([
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"libsql",
] as const)("%s schedule permissions", (databaseType) => {
it.each(["create", "read", "update", "delete"] as const)(
"checks the %s schedule permission and service access",
async (action) => {
const permissions = { schedule: [action] };
await checkDatabaseScheduleAccess(
ctx,
databaseType,
"database-1",
permissions,
);

expect(
mocks.checkServicePermissionAndAccess,
).toHaveBeenCalledExactlyOnceWith(ctx, "database-1", permissions);
for (const [type, findDatabase] of Object.entries(mocks.findDatabase)) {
if (type === databaseType) {
expect(findDatabase).toHaveBeenCalledExactlyOnceWith("database-1");
} else {
expect(findDatabase).not.toHaveBeenCalled();
}
}
},
);

it("does not require read permission when cancelling a run", async () => {
mocks.checkServicePermissionAndAccess.mockImplementation(
async (_ctx, _id, permissions) => {
if (permissions.deployment.includes("read")) {
throw new Error("Read access denied");
}
},
);
await checkDatabaseScheduleAccess(ctx, databaseType, "database-1", {
deployment: ["cancel"],
});

expect(
mocks.checkServicePermissionAndAccess,
).toHaveBeenCalledExactlyOnceWith(ctx, "database-1", {
deployment: ["cancel"],
});
});

it("rejects databases belonging to a different organization", async () => {
mocks.findDatabase[databaseType].mockResolvedValue({
environment: { project: { organizationId: "another-org" } },
});

await expect(
checkDatabaseScheduleAccess(ctx, databaseType, "database-1", {
schedule: ["read"],
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("rejects permission failures before looking up the database", async () => {
mocks.checkServicePermissionAndAccess.mockRejectedValue(
new Error("Access denied"),
);

await expect(
checkDatabaseScheduleAccess(ctx, databaseType, "database-1", {
schedule: ["read"],
}),
).rejects.toThrow("Access denied");
for (const findDatabase of Object.values(mocks.findDatabase)) {
expect(findDatabase).not.toHaveBeenCalled();
}
});
});
145 changes: 145 additions & 0 deletions apps/dokploy/__test__/schedules/database-schedule-deletion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
databaseScheduleIdColumns,
databaseScheduleTypes,
schedules,
} from "@dokploy/server/db/schema/schedule";
import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
findMany: vi.fn(),
removeJob: vi.fn(),
removeScheduleJob: vi.fn(),
mode: { cloud: true },
}));

vi.mock("@dokploy/server", () => ({
get IS_CLOUD() {
return mocks.mode.cloud;
},
removeScheduleJob: mocks.removeScheduleJob,
}));
vi.mock("@dokploy/server/db", () => ({
db: { query: { schedules: { findMany: mocks.findMany } } },
}));
vi.mock("@/server/utils/backup", () => ({ removeJob: mocks.removeJob }));

const { cancelDatabaseScheduleJobs, cancelProjectDatabaseScheduleJobs } =
await import("@/server/utils/database-schedules");

beforeEach(() => {
vi.resetAllMocks();
mocks.mode.cloud = true;
mocks.removeJob.mockResolvedValue({ result: true });
mocks.findMany.mockResolvedValue([
{
scheduleId: "schedule-1",
cronExpression: "0 9 * * *",
timezone: "Australia/Sydney",
},
{
scheduleId: "schedule-2",
cronExpression: "0 10 * * *",
timezone: null,
},
]);
});

describe.each(databaseScheduleTypes)("%s database deletion", (databaseType) => {
it("unregisters every cloud job with its original cron and timezone", async () => {
await cancelDatabaseScheduleJobs(databaseType, "database-1");

expect(mocks.findMany).toHaveBeenCalledExactlyOnceWith({
where: eq(
schedules[databaseScheduleIdColumns[databaseType]],
"database-1",
),
columns: {
scheduleId: true,
cronExpression: true,
timezone: true,
},
});
expect(mocks.removeJob).toHaveBeenNthCalledWith(1, {
type: "schedule",
scheduleId: "schedule-1",
cronSchedule: "0 9 * * *",
timezone: "Australia/Sydney",
});
expect(mocks.removeJob).toHaveBeenNthCalledWith(2, {
type: "schedule",
scheduleId: "schedule-2",
cronSchedule: "0 10 * * *",
timezone: null,
});
expect(mocks.removeScheduleJob).not.toHaveBeenCalled();
});

it("cancels self-hosted jobs instead of contacting the cloud worker", async () => {
mocks.mode.cloud = false;
await cancelDatabaseScheduleJobs(databaseType, "database-1");

expect(mocks.removeScheduleJob).toHaveBeenNthCalledWith(1, "schedule-1");
expect(mocks.removeScheduleJob).toHaveBeenNthCalledWith(2, "schedule-2");
expect(mocks.removeJob).not.toHaveBeenCalled();
});
});

it("does not silently continue deletion when the cloud scheduler is unavailable", async () => {
mocks.removeJob.mockRejectedValueOnce(new Error("Queue unavailable"));

await expect(
cancelDatabaseScheduleJobs("postgres", "database-1"),
).rejects.toThrow("Queue unavailable");
// Callers must await cancellation before removing the database row.
// A rejected cancellation keeps its FK and schedule rows intact.
expect(mocks.removeJob).toHaveBeenCalledTimes(1);
});

it("rejects a cloud scheduler HTTP error body instead of deleting the database", async () => {
// removeJob returns parsed JSON without checking Response.ok.
mocks.removeJob.mockResolvedValueOnce({ message: "Invalid API Key" });

await expect(
cancelDatabaseScheduleJobs("postgres", "database-1"),
).rejects.toThrow(
"Cloud scheduler did not confirm removal of database schedule schedule-1",
);
expect(mocks.removeJob).toHaveBeenCalledTimes(1);
});

it("accepts a confirmed already-absent cloud job", async () => {
// Disabled schedules are included in cleanup, but may not be registered.
mocks.removeJob.mockResolvedValueOnce({ result: false });

await expect(
cancelDatabaseScheduleJobs("postgres", "database-1"),
).resolves.toBeUndefined();
expect(mocks.removeJob).toHaveBeenCalledTimes(2);
});

it("cancels database jobs when a project is cascade-deleted", async () => {
mocks.findMany.mockResolvedValue([]);
const environment = Object.fromEntries(
databaseScheduleTypes.map((type) => [
type,
[{ [databaseScheduleIdColumns[type]]: `database-${type}` }],
]),
);

await cancelProjectDatabaseScheduleJobs([
environment,
] as unknown as Parameters<typeof cancelProjectDatabaseScheduleJobs>[0]);

expect(mocks.findMany).toHaveBeenCalledTimes(databaseScheduleTypes.length);
for (const type of databaseScheduleTypes) {
expect(mocks.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: eq(
schedules[databaseScheduleIdColumns[type]],
`database-${type}`,
),
}),
);
}
});
Loading
Loading