From cd520dacc5952a45bc89d3d1290a2bce58023a96 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 14:38:18 -0700 Subject: [PATCH] add tests --- .../test/unit/services/proxiesService.test.ts | 208 ++++++++++++++++++ .../unit/validation/monitorValidation.test.ts | 69 ++++++ .../unit/validation/proxyValidation.test.ts | 97 ++++++++ 3 files changed, 374 insertions(+) create mode 100644 server/test/unit/services/proxiesService.test.ts create mode 100644 server/test/unit/validation/proxyValidation.test.ts diff --git a/server/test/unit/services/proxiesService.test.ts b/server/test/unit/services/proxiesService.test.ts new file mode 100644 index 000000000..038754f28 --- /dev/null +++ b/server/test/unit/services/proxiesService.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ProxiesService } from "../../../src/domain/proxies/proxy.service.ts"; +import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createProxiesRepo = () => ({ + create: jest.fn(), + findById: jest.fn(), + findByTeamId: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), +}); + +const createMonitorsRepo = () => ({ + findMonitorCountByProxyId: jest.fn(), +}); + +const createSettingsService = () => ({ + getDBSettings: jest.fn(), +}); + +const createService = () => { + const proxiesRepository = createProxiesRepo(); + const monitorsRepository = createMonitorsRepo(); + const settingsService = createSettingsService(); + + (monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(0); + (settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: null }); + + const service = new ProxiesService(proxiesRepository as any, monitorsRepository as any, settingsService as any); + + return { service, proxiesRepository, monitorsRepository, settingsService }; +}; + +const makeProxy = (overrides?: Partial): Proxy => + ({ + id: "proxy-1", + teamId: "team-1", + name: "Egress proxy", + protocol: "http", + host: "proxy.internal", + port: 3128, + username: "user", + password: "secret", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Proxy; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("ProxiesService", () => { + describe("createProxy", () => { + it("sets teamId and delegates to repository", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy()); + + await service.createProxy({ name: "Egress proxy", protocol: "http", host: "proxy.internal", port: 3128 }, "team-1"); + + expect(proxiesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Egress proxy" })); + }); + + it("omits password from the response and reports hasPassword", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.createProxy({ name: "Egress proxy" }, "team-1"); + + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + + it("strips blank credentials before creating", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined })); + + await service.createProxy({ name: "Egress proxy", username: "", password: "" }, "team-1"); + + const created = (proxiesRepository.create as jest.Mock).mock.calls[0][0] as Partial; + expect(created).not.toHaveProperty("username"); + expect(created).not.toHaveProperty("password"); + }); + }); + + describe("getProxy", () => { + it("delegates to repository and masks the password", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.getProxy("proxy-1", "team-1"); + + expect(proxiesRepository.findById).toHaveBeenCalledWith("proxy-1", "team-1"); + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + + it("reports hasPassword false when none is stored", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy({ password: undefined })); + + const result = await service.getProxy("proxy-1", "team-1"); + + expect(result.hasPassword).toBe(false); + }); + }); + + describe("getProxiesByTeamId", () => { + it("masks every proxy in the list", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByTeamId as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", password: undefined })]); + + const result = await service.getProxiesByTeamId("team-1"); + + expect(proxiesRepository.findByTeamId).toHaveBeenCalledWith("team-1"); + expect(result).toHaveLength(2); + for (const proxy of result) { + expect(proxy).not.toHaveProperty("password"); + } + expect(result[0].hasPassword).toBe(true); + expect(result[1].hasPassword).toBe(false); + }); + }); + + describe("updateProxy", () => { + it("keeps the stored password when the patch has a blank one", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed", password: "", username: "" }); + + const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial; + expect(patch).not.toHaveProperty("password"); + expect(patch).not.toHaveProperty("username"); + expect(patch.name).toBe("Renamed"); + }); + + it("passes a new password through", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ password: "next" })); + + await service.updateProxy("proxy-1", "team-1", { password: "next" }); + + const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial; + expect(patch.password).toBe("next"); + }); + + it("maps clear flags to unset options and keeps them out of the patch", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined })); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed", clearPassword: true, clearUsername: true }); + + const [, , patch, options] = (proxiesRepository.updateById as jest.Mock).mock.calls[0]; + expect(patch).not.toHaveProperty("clearPassword"); + expect(patch).not.toHaveProperty("clearUsername"); + expect(options).toEqual({ unsetPassword: true, unsetUsername: true }); + }); + + it("does not unset when clear flags are absent", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed" }); + + const options = (proxiesRepository.updateById as jest.Mock).mock.calls[0][3]; + expect(options).toEqual({ unsetPassword: false, unsetUsername: false }); + }); + + it("masks the updated proxy in the response", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.updateProxy("proxy-1", "team-1", { name: "Renamed" }); + + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + }); + + describe("deleteProxy", () => { + it("deletes when unreferenced and masks the response", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.deleteById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.deleteProxy("proxy-1", "team-1"); + + expect(proxiesRepository.deleteById).toHaveBeenCalledWith("proxy-1", "team-1"); + expect(result).not.toHaveProperty("password"); + }); + + it("rejects with 409 when monitors reference the proxy", async () => { + const { service, proxiesRepository, monitorsRepository } = createService(); + (monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(3); + + await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 }); + expect(proxiesRepository.deleteById).not.toHaveBeenCalled(); + }); + + it("rejects with 409 when the proxy is the global proxy", async () => { + const { service, proxiesRepository, settingsService } = createService(); + (settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: "proxy-1" }); + + await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 }); + expect(proxiesRepository.deleteById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index d6998367a..4de4538e1 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -470,3 +470,72 @@ describe("monitorValidation — customUpCodes", () => { }); }); }); + +describe("monitorValidation — proxy fields", () => { + const baseHttpBody = { + name: "HTTP check", + type: "http" as const, + url: "https://example.com", + }; + + describe("createMonitorBodyValidation", () => { + it("defaults proxyMode to inherit", () => { + const parsed = createMonitorBodyValidation.parse(baseHttpBody); + + expect(parsed.proxyMode).toBe("inherit"); + expect(parsed.proxyId).toBeUndefined(); + }); + + it("accepts custom mode with a proxyId", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" }); + + expect(parsed.proxyMode).toBe("custom"); + expect(parsed.proxyId).toBe("proxy-1"); + }); + + it("rejects custom mode without a proxyId", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow(); + }); + + it("rejects an unknown proxyMode", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow(); + }); + + it("allows a stray proxyId on non-custom modes", () => { + for (const proxyMode of ["inherit", "none"] as const) { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" }); + expect(parsed.proxyMode).toBe(proxyMode); + } + }); + }); + + describe("editMonitorBodyValidation", () => { + it("leaves proxyMode unset when omitted (no default injected on PATCH)", () => { + const parsed = editMonitorBodyValidation.parse({ name: "Renamed" }); + + expect(parsed.proxyMode).toBeUndefined(); + }); + + it("rejects custom mode without a proxyId", () => { + expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom" })).toThrow(); + }); + + it("accepts custom mode with a proxyId", () => { + const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" }); + + expect(parsed.proxyId).toBe("proxy-1"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("defaults proxyMode to inherit on import", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [baseHttpBody] }); + + expect(parsed.monitors[0].proxyMode).toBe("inherit"); + }); + + it("rejects an imported monitor with custom mode and no proxyId", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHttpBody, proxyMode: "custom" }] })).toThrow(); + }); + }); +}); diff --git a/server/test/unit/validation/proxyValidation.test.ts b/server/test/unit/validation/proxyValidation.test.ts new file mode 100644 index 000000000..2046f16df --- /dev/null +++ b/server/test/unit/validation/proxyValidation.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@jest/globals"; +import { createProxyBodyValidation, editProxyBodyValidation, proxyResponseSchema } from "../../../src/api/validation/proxyValidation.ts"; + +const validBody = { + name: "Egress proxy", + protocol: "http" as const, + host: "proxy.internal", + port: 3128, +}; + +describe("proxyValidation", () => { + describe("createProxyBodyValidation", () => { + it("accepts a minimal valid body", () => { + const parsed = createProxyBodyValidation.parse(validBody); + + expect(parsed).toEqual(validBody); + }); + + it("accepts optional credentials", () => { + const parsed = createProxyBodyValidation.parse({ ...validBody, username: "user", password: "secret" }); + + expect(parsed.username).toBe("user"); + expect(parsed.password).toBe("secret"); + }); + + it("rejects an empty name", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, name: "" })).toThrow(); + }); + + it("rejects an empty host", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, host: "" })).toThrow(); + }); + + it("rejects a protocol outside the tuple", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, protocol: "socks5" })).toThrow(); + }); + + it("accepts the port boundaries", () => { + expect(createProxyBodyValidation.parse({ ...validBody, port: 1 }).port).toBe(1); + expect(createProxyBodyValidation.parse({ ...validBody, port: 65535 }).port).toBe(65535); + }); + + it("rejects out-of-range and non-integer ports", () => { + for (const port of [0, -1, 65536, 3128.5, "3128"]) { + expect(() => createProxyBodyValidation.parse({ ...validBody, port })).toThrow(); + } + }); + }); + + describe("editProxyBodyValidation", () => { + it("accepts the create shape without clear flags", () => { + const parsed = editProxyBodyValidation.parse(validBody); + + expect(parsed.clearPassword).toBeUndefined(); + expect(parsed.clearUsername).toBeUndefined(); + }); + + it("accepts clearPassword and clearUsername", () => { + const parsed = editProxyBodyValidation.parse({ ...validBody, clearPassword: true, clearUsername: true }); + + expect(parsed.clearPassword).toBe(true); + expect(parsed.clearUsername).toBe(true); + }); + + it("rejects non-boolean clear flags", () => { + expect(() => editProxyBodyValidation.parse({ ...validBody, clearPassword: "yes" })).toThrow(); + }); + }); + + describe("proxyResponseSchema", () => { + const validResponse = { + id: "proxy-1", + teamId: "team-1", + name: "Egress proxy", + protocol: "http", + host: "proxy.internal", + port: 3128, + hasPassword: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }; + + it("accepts a masked proxy", () => { + expect(() => proxyResponseSchema.parse(validResponse)).not.toThrow(); + }); + + it("requires hasPassword", () => { + const { hasPassword: _hasPassword, ...withoutFlag } = validResponse; + + expect(() => proxyResponseSchema.parse(withoutFlag)).toThrow(); + }); + + it("does not declare a password field", () => { + expect(Object.keys(proxyResponseSchema.shape)).not.toContain("password"); + }); + }); +});