From 062bd3332543891af2af656cc154861cf072abf0 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:18:42 -0400 Subject: [PATCH 1/2] fix(api): make the OpenAPI document generate again /swagger rendered an empty spec ("does not specify a valid version field") because settings.getOpenApiDocument returned 500: the generator throws for the whole router as soon as one procedure has an input it cannot describe. - dockerDiskUsage.getDiskUsage and settings.getDockerDiskUsage took detailLimit as a union of numeric literals, which the generator rejects for query inputs. Both now share dockerDiskUsageDetailLimitSchema, a coerced number piped into 5 | 10 | 15, so the generator accepts it and REST callers can pass ?detailLimit=5. - github.appSetupState takes a discriminated-union input the generator cannot express. It only drives the in-panel GitHub App setup redirect, so it is excluded from OpenAPI like readStatsLogs. - A test generates the document for the full app router so a new procedure cannot silently break /swagger again. Co-Authored-By: Claude Opus 5.5 --- .../__test__/api/openapi-document.test.ts | 40 +++++++++++++++++++ .../settings/docker-server-boundary.test.ts | 3 +- .../request-log-authorization.test.ts | 3 +- .../server/api/routers/docker-disk-usage.ts | 6 +-- apps/dokploy/server/api/routers/github.ts | 10 +++++ apps/dokploy/server/api/routers/settings.ts | 6 +-- packages/server/src/utils/docker/utils.ts | 10 +++++ 7 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 apps/dokploy/__test__/api/openapi-document.test.ts diff --git a/apps/dokploy/__test__/api/openapi-document.test.ts b/apps/dokploy/__test__/api/openapi-document.test.ts new file mode 100644 index 00000000000..7bc7ff47f20 --- /dev/null +++ b/apps/dokploy/__test__/api/openapi-document.test.ts @@ -0,0 +1,40 @@ +import { dockerDiskUsageDetailLimitSchema } from "@dokploy/server"; +import { generateOpenApiDocument } from "@dokploy/trpc-openapi"; +import { describe, expect, it } from "vitest"; +import { appRouter } from "@/server/api/root"; + +const generate = () => + generateOpenApiDocument(appRouter, { + title: "Dokploy API", + version: "test", + baseUrl: "http://localhost/api", + }); + +describe("OpenAPI document", () => { + // One procedure the generator can't describe throws for the whole router, + // and /swagger then renders an empty spec instead of the API. + it("generates for every procedure in the app router", () => { + const document = generate(); + + expect(document.openapi).toMatch(/^3\./); + const getDiskUsage = document.paths?.["/dockerDiskUsage.getDiskUsage"] + ?.get as { parameters?: { name: string }[] } | undefined; + expect(getDiskUsage?.parameters?.map(({ name }) => name)).toContain( + "detailLimit", + ); + }); +}); + +describe("dockerDiskUsageDetailLimitSchema", () => { + it("accepts the allowed limits from tRPC and query strings", () => { + expect(dockerDiskUsageDetailLimitSchema.parse(5)).toBe(5); + expect(dockerDiskUsageDetailLimitSchema.parse("15")).toBe(15); + expect(dockerDiskUsageDetailLimitSchema.parse(null)).toBeNull(); + expect(dockerDiskUsageDetailLimitSchema.parse(undefined)).toBeUndefined(); + }); + + it("rejects other limits", () => { + expect(() => dockerDiskUsageDetailLimitSchema.parse(7)).toThrow(); + expect(() => dockerDiskUsageDetailLimitSchema.parse("all")).toThrow(); + }); +}); diff --git a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts index b6c6089118f..3a5f55447e4 100644 --- a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts +++ b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts @@ -64,9 +64,10 @@ const mocks = vi.hoisted(() => ({ const redactWebServerSettings = (settings: T) => settings; -vi.mock("@dokploy/server", () => ({ +vi.mock("@dokploy/server", async () => ({ CLEANUP_CRON_JOB: "0 0 * * *", DEFAULT_UPDATE_DATA: {}, + dockerDiskUsageDetailLimitSchema: (await import("zod")).z.any(), DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES", DOKPLOY_KEEP_IMAGES_MAX: 5, DOKPLOY_KEEP_IMAGES_MIN: 3, diff --git a/apps/dokploy/__test__/settings/request-log-authorization.test.ts b/apps/dokploy/__test__/settings/request-log-authorization.test.ts index f9066263f0a..4604c9d3f48 100644 --- a/apps/dokploy/__test__/settings/request-log-authorization.test.ts +++ b/apps/dokploy/__test__/settings/request-log-authorization.test.ts @@ -87,9 +87,10 @@ const mocks = vi.hoisted(() => ({ writeTraefikSetup: vi.fn(), })); -vi.mock("@dokploy/server", () => ({ +vi.mock("@dokploy/server", async () => ({ CLEANUP_CRON_JOB: "0 0 * * *", DEFAULT_UPDATE_DATA: {}, + dockerDiskUsageDetailLimitSchema: (await import("zod")).z.any(), DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES", DOKPLOY_KEEP_IMAGES_MAX: 5, DOKPLOY_KEEP_IMAGES_MIN: 3, diff --git a/apps/dokploy/server/api/routers/docker-disk-usage.ts b/apps/dokploy/server/api/routers/docker-disk-usage.ts index d193da37bf1..03731f59064 100644 --- a/apps/dokploy/server/api/routers/docker-disk-usage.ts +++ b/apps/dokploy/server/api/routers/docker-disk-usage.ts @@ -1,5 +1,6 @@ import { cleanupBuilders, + dockerDiskUsageDetailLimitSchema, findServerById, getBuildCache, getDockerDiskUsage, @@ -14,10 +15,7 @@ export const dockerDiskUsageRouter = createTRPCRouter({ getDiskUsage: withPermission("docker", "read") .input( z.object({ - detailLimit: z - .union([z.literal(5), z.literal(10), z.literal(15)]) - .nullable() - .optional(), + detailLimit: dockerDiskUsageDetailLimitSchema, serverId: z.string().optional(), }), ) diff --git a/apps/dokploy/server/api/routers/github.ts b/apps/dokploy/server/api/routers/github.ts index fe204f9c711..3ba67bb75c3 100644 --- a/apps/dokploy/server/api/routers/github.ts +++ b/apps/dokploy/server/api/routers/github.ts @@ -81,6 +81,16 @@ const GITHUB_APP_SETUP_STATE_TTL_MS = 60 * 60 * 1000; export const githubRouter = createTRPCRouter({ appSetupState: withPermission("gitProviders", "create") + // The generator can't express a discriminated-union query input, and this + // state only drives the in-panel GitHub App setup redirect. + .meta({ + openapi: { + path: "/github-app-setup-state", + method: "GET", + override: true, + enabled: false, + }, + }) .input(apiGithubAppSetupState) .query(async ({ input, ctx }) => { if ( diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index b7f957e5768..81996325e8b 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -15,6 +15,7 @@ import { DOKPLOY_KEEP_IMAGES_ENV, DOKPLOY_KEEP_IMAGES_MAX, DOKPLOY_KEEP_IMAGES_MIN, + dockerDiskUsageDetailLimitSchema, filterProtectedTraefikEntries, findServerById, getAccessibleServerIds, @@ -333,10 +334,7 @@ export const settingsRouter = createTRPCRouter({ .input( z .object({ - detailLimit: z - .union([z.literal(5), z.literal(10), z.literal(15)]) - .nullable() - .optional(), + detailLimit: dockerDiskUsageDetailLimitSchema, }) .optional(), ) diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index a696ba677c7..84d325994ab 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -6,6 +6,7 @@ import type { Compose } from "@dokploy/server/services/compose"; import type { ContainerInfo, ResourceRequirements } from "dockerode"; import { parse } from "dotenv"; import { quote } from "shell-quote"; +import { z } from "zod"; import type { ApplicationNested } from "../builders"; import type { LibsqlNested } from "../databases/libsql"; import type { MariadbNested } from "../databases/mariadb"; @@ -311,6 +312,15 @@ export interface DockerDiskUsageVerboseDetails { export type DockerDiskUsageDetailLimit = 5 | 10 | 15 | null; +// The OpenAPI generator rejects numeric literals in query inputs, which takes +// down the whole /swagger document; a coerced number piped into the literals +// passes its check and also accepts "?detailLimit=5" from REST callers. +export const dockerDiskUsageDetailLimitSchema = z.coerce + .number() + .pipe(z.union([z.literal(5), z.literal(10), z.literal(15)])) + .nullable() + .optional(); + export const resolveDockerDiskUsageDetailLimit = ( detailLimit?: DockerDiskUsageDetailLimit, ): DockerDiskUsageDetailLimit => (detailLimit === undefined ? 10 : detailLimit); From d65c346f2e6c162827524b771f87b64a5a3de20c Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:27:50 -0400 Subject: [PATCH 2/2] test(super-password): freeze Date in the Telegram alert test "Time left" is floored to whole minutes. When a millisecond passed between building expiresAt and formatting the alert, the message read "23 h 59 min" and the test failed in CI. Freezing only Date keeps fetch and the rest of the timers real. Co-Authored-By: Claude Opus 5.5 --- .../__test__/security/super-password-telegram.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/dokploy/__test__/security/super-password-telegram.test.ts b/apps/dokploy/__test__/security/super-password-telegram.test.ts index a5b4d4a4b75..3fc724a8da0 100644 --- a/apps/dokploy/__test__/security/super-password-telegram.test.ts +++ b/apps/dokploy/__test__/security/super-password-telegram.test.ts @@ -42,6 +42,10 @@ const telegramBodies = () => describe("super password alerts over the Telegram Bot API", () => { beforeEach(() => { + // "Time left" is floored to minutes, so a real clock ticking between + // building expiresAt and formatting it renders 23 h 59 min. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-07T12:00:00Z")); fake.reset(); mocks.fetch.mockReset(); mocks.fetch.mockResolvedValue(new Response("{}", { status: 200 })); @@ -71,6 +75,7 @@ describe("super password alerts over the Telegram Bot API", () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); });