Skip to content
Merged
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
207 changes: 207 additions & 0 deletions apps/dokploy/__test__/domain-overview/acme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import {
buildAcmeReadCommand,
certificateCovers,
certificateTone,
daysUntil,
findCertificateFor,
parseAcmeCertificates,
parseCertificateChain,
} from "@dokploy/server/utils/domain-overview/acme";
import { describe, expect, it } from "vitest";

const FIXTURE_CERTIFICATE = `-----BEGIN CERTIFICATE-----
MIIByzCCAXGgAwIBAgIUWlFvc+nf6dEY5hzHVUVViA92C6kwCgYIKoZIzj0EAwIw
NjEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MR0wGwYDVQQKDBRFeGFtcGxlIFRlc3Qg
Rml4dHVyZTAeFw0yNjAxMDEwMDAwMDBaFw0yNzA2MDExMjAwMDBaMDYxFTATBgNV
BAMMDGV4YW1wbGUudGVzdDEdMBsGA1UECgwURXhhbXBsZSBUZXN0IEZpeHR1cmUw
WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8poBd9hdPZbBkGhYMU068RMvU5tEW
AGaG3Ak6Ia9ZJNXhgn2Rc8Kc1Xim/05OxNc7ubzinXH7XEBWyfLmI7Ylo10wWzAd
BgNVHQ4EFgQUkdsHQsjcvCmQo7qeXtOtA3bmJZAwDwYDVR0TAQH/BAUwAwEB/zAp
BgNVHREEIjAgggxleGFtcGxlLnRlc3SCEHd3dy5leGFtcGxlLnRlc3QwCgYIKoZI
zj0EAwIDSAAwRQIgWV3UtpalgH4fHv8AmFwlUfSdJjTLK5Li2K2OJKYZXrICIQCK
c9Rm3ru6I2cIJEIQwsANL6haSt6HrEbU14R9Zw3LWQ==
-----END CERTIFICATE-----
`;

const FAKE_ENTRY_KEY = "FAKE-ENTRY-KEY-MATERIAL-FOR-TESTS-ONLY";
const FAKE_ACCOUNT_KEY = "FAKE-ACCOUNT-KEY-MATERIAL-FOR-TESTS-ONLY";

const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");

const acmeDocument = (certificates: unknown[]) =>
JSON.stringify({
letsencrypt: {
Account: {
Email: "ops@example.test",
PrivateKey: FAKE_ACCOUNT_KEY,
},
Certificates: certificates,
},
});

const fixtureEntry = {
domain: { main: "example.test", sans: ["www.example.test"] },
certificate: encode(FIXTURE_CERTIFICATE),
key: FAKE_ENTRY_KEY,
Store: "default",
};

describe("acme.json parser", () => {
it("returns names, expiry and issuer and never the key material", () => {
const raw = acmeDocument([fixtureEntry]);

const certificates = parseAcmeCertificates(raw);

expect(certificates).toHaveLength(1);
expect(certificates[0]?.names).toEqual([
"example.test",
"www.example.test",
]);
expect(certificates[0]?.validTo.toISOString()).toBe(
"2027-06-01T12:00:00.000Z",
);
expect(certificates[0]?.issuer).toBe("Example Test Fixture example.test");
expect(Object.keys(certificates[0] ?? {}).sort()).toEqual([
"issuer",
"names",
"validTo",
]);
const serialized = JSON.stringify(certificates);
expect(serialized).not.toContain(FAKE_ENTRY_KEY);
expect(serialized).not.toContain(FAKE_ACCOUNT_KEY);
});

it("reads the reduced shape that the server-side jq filter prints", () => {
const reduced = JSON.stringify({
letsencrypt: {
Certificates: [
{
domain: { main: "Example.TEST.", sans: [] },
certificate: encode(FIXTURE_CERTIFICATE),
},
],
},
});

expect(parseAcmeCertificates(reduced)[0]?.names).toEqual(["example.test"]);
});

it("skips entries without a readable certificate", () => {
const raw = acmeDocument([
{ domain: { main: "example.test" }, certificate: encode("not a cert") },
{ domain: { main: "example.test" } },
]);

expect(parseAcmeCertificates(raw)).toEqual([]);
});

it("returns no certificates when the file has no letsencrypt section", () => {
expect(parseAcmeCertificates("{}")).toEqual([]);
});

it("does not echo the file content when the JSON cannot be parsed", () => {
const broken = `{"letsencrypt": {"Account": {"PrivateKey": "${FAKE_ACCOUNT_KEY}"`;

let thrown: unknown;
try {
parseAcmeCertificates(broken);
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toBe("acme.json could not be parsed");
expect(String(thrown)).not.toContain(FAKE_ACCOUNT_KEY);
});
});

describe("certificate expiry parser", () => {
it("reads validTo and the issuer from the first PEM block only", () => {
const parsed = parseCertificateChain(
encode(
`${FIXTURE_CERTIFICATE}${FIXTURE_CERTIFICATE.replace(
"MIIBy",
"MIIBz",
)}`,
),
);

expect(parsed?.validTo.toISOString()).toBe("2027-06-01T12:00:00.000Z");
expect(parsed?.issuer).toBe("Example Test Fixture example.test");
});

it("returns null when the chain holds no certificate", () => {
expect(parseCertificateChain(encode("plain text"))).toBeNull();
});

it("counts whole days left from a given moment", () => {
const validTo = new Date("2027-06-01T12:00:00.000Z");

expect(daysUntil(validTo, new Date("2027-05-19T12:00:00.000Z"))).toBe(13);
expect(daysUntil(validTo, new Date("2027-05-19T13:00:00.000Z"))).toBe(12);
expect(daysUntil(validTo, new Date("2027-06-02T12:00:00.000Z"))).toBe(-1);
});

it("marks under 14 days as critical and under 30 days as warning", () => {
expect(certificateTone(13)).toBe("critical");
expect(certificateTone(0)).toBe("critical");
expect(certificateTone(-3)).toBe("critical");
expect(certificateTone(14)).toBe("warning");
expect(certificateTone(29)).toBe("warning");
expect(certificateTone(30)).toBe("ok");
});
});

describe("certificate names", () => {
it("matches exact names and one wildcard label", () => {
expect(certificateCovers(["example.test"], "example.test")).toBe(true);
expect(certificateCovers(["*.example.test"], "www.example.test")).toBe(
true,
);
expect(certificateCovers(["*.example.test"], "example.test")).toBe(false);
expect(certificateCovers(["*.example.test"], "a.b.example.test")).toBe(
false,
);
});

it("prefers the certificate with the latest expiry", () => {
const older = {
names: ["example.test"],
validTo: new Date("2026-12-01T00:00:00Z"),
issuer: "older",
};
const newer = {
names: ["example.test"],
validTo: new Date("2027-06-01T00:00:00Z"),
issuer: "newer",
};

expect(findCertificateFor([older, newer], "example.test")?.issuer).toBe(
"newer",
);
expect(findCertificateFor([older], "other.example.test")).toBeUndefined();
});
});

describe("acme.json read command", () => {
it("checks the file first and reduces it on the server when jq is available", () => {
const command = buildAcmeReadCommand(
"/etc/dokploy/traefik/dynamic/acme.json",
);

expect(
command.startsWith(
"[ -f '/etc/dokploy/traefik/dynamic/acme.json' ] || exit 3",
),
).toBe(true);
expect(command).toContain("jq -c");
expect(command).toContain("certificate: .certificate?");
expect(command).not.toMatch(/PrivateKey|\.key\b/);
});

it("quotes the path so that it cannot end the argument", () => {
const command = buildAcmeReadCommand("/srv/it's/acme.json");

expect(command).toContain("'/srv/it'\\''s/acme.json'");
});
});
189 changes: 189 additions & 0 deletions apps/dokploy/__test__/domain-overview/dns-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const dns = vi.hoisted(() => ({
resolve4: vi.fn(),
resolve6: vi.fn(),
}));

vi.mock("node:dns", () => ({
promises: {
resolve4: dns.resolve4,
resolve6: dns.resolve6,
},
}));

const {
clearDnsLookupCache,
DNS_CACHE_TTL_MS,
DNS_LOOKUP_TIMEOUT_MS,
mapDnsStatus,
resolveDomainRecords,
} = await import("@dokploy/server/utils/domain-overview/dns-status");

const dnsError = (code: string) => Object.assign(new Error(code), { code });

const answer = (addresses: string[]) => ({ ok: true as const, addresses });
const failure = (code: string) => ({ ok: false as const, code });

describe("DNS status mapper", () => {
it("says the name points to this server when an address matches", () => {
expect(
mapDnsStatus({
records: { a: answer(["192.0.2.10"]), aaaa: failure("ENODATA") },
serverAddress: "192.0.2.10",
}),
).toBe("points_to_this_server");
});

it("says the name points to another address when none matches", () => {
expect(
mapDnsStatus({
records: { a: answer(["198.51.100.7"]), aaaa: failure("ENODATA") },
serverAddress: "192.0.2.10",
}),
).toBe("points_to_other_address");
});

it("reports NXDOMAIN when both families say the name does not exist", () => {
expect(
mapDnsStatus({
records: { a: failure("ENOTFOUND"), aaaa: failure("ENOTFOUND") },
serverAddress: "192.0.2.10",
}),
).toBe("nxdomain");
});

it("reports a record without an address when both families have no data", () => {
expect(
mapDnsStatus({
records: { a: failure("ENODATA"), aaaa: failure("ENODATA") },
serverAddress: "192.0.2.10",
}),
).toBe("no_address");
});

it("reports a failed lookup when the lookup timed out", () => {
expect(
mapDnsStatus({
records: { a: failure("ETIMEDOUT"), aaaa: failure("ENODATA") },
serverAddress: "192.0.2.10",
}),
).toBe("lookup_failed");
});

it("does not call the name another address while the server's family is unknown", () => {
expect(
mapDnsStatus({
records: { a: failure("ESERVFAIL"), aaaa: answer(["2001:db8::7"]) },
serverAddress: "192.0.2.10",
}),
).toBe("lookup_failed");
});

it("reports an unknown server address when the name resolves", () => {
expect(
mapDnsStatus({
records: { a: answer(["192.0.2.10"]), aaaa: failure("ENODATA") },
serverAddress: null,
}),
).toBe("server_address_unknown");
});
});

describe("DNS lookups", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
clearDnsLookupCache();
});

it("maps a name that resolves to this server", async () => {
dns.resolve4.mockResolvedValue(["192.0.2.10"]);
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));

const records = await resolveDomainRecords("points-here.example.test");

expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
"points_to_this_server",
);
});

it("maps a name that resolves to another address", async () => {
dns.resolve4.mockResolvedValue(["198.51.100.7"]);
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));

const records = await resolveDomainRecords("elsewhere.example.test");

expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
"points_to_other_address",
);
});

it("maps a name with no DNS record", async () => {
dns.resolve4.mockRejectedValue(dnsError("ENOTFOUND"));
dns.resolve6.mockRejectedValue(dnsError("ENOTFOUND"));

const records = await resolveDomainRecords("missing.example.test");

expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
"nxdomain",
);
});

it("maps a name whose record has no address", async () => {
dns.resolve4.mockRejectedValue(dnsError("ENODATA"));
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));

const records = await resolveDomainRecords("empty.example.test");

expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
"no_address",
);
});

it("maps a lookup that does not answer within the timeout", async () => {
vi.useFakeTimers();
dns.resolve4.mockReturnValue(new Promise<string[]>(() => {}));
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));

const pending = resolveDomainRecords("slow.example.test");
await vi.advanceTimersByTimeAsync(DNS_LOOKUP_TIMEOUT_MS);
const records = await pending;

expect(records.a.ok).toBe(false);
expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
"lookup_failed",
);
});

it("maps a resolver error that is not NXDOMAIN or NODATA as a failed lookup", async () => {
dns.resolve4.mockRejectedValue(dnsError("ECONNREFUSED"));
dns.resolve6.mockRejectedValue(dnsError("ECONNREFUSED"));

const records = await resolveDomainRecords("refused.example.test");

expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
"lookup_failed",
);
});

it("answers repeat lookups from the cache for ten minutes", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-08T10:00:00.000Z"));
dns.resolve4.mockResolvedValue(["192.0.2.10"]);
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));

await resolveDomainRecords("cached.example.test");
vi.setSystemTime(
new Date(Date.parse("2026-10-08T10:00:00.000Z") + DNS_CACHE_TTL_MS - 1),
);
await resolveDomainRecords("cached.example.test");
expect(dns.resolve4).toHaveBeenCalledTimes(1);

vi.setSystemTime(
new Date(Date.parse("2026-10-08T10:00:00.000Z") + DNS_CACHE_TTL_MS),
);
await resolveDomainRecords("cached.example.test");
expect(dns.resolve4).toHaveBeenCalledTimes(2);
});
});
Loading