diff --git a/src/views/DeploymentDetailView.test.ts b/src/views/DeploymentDetailView.test.ts new file mode 100644 index 0000000..ce2d245 --- /dev/null +++ b/src/views/DeploymentDetailView.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import DeploymentDetailView from "./DeploymentDetailView.vue"; + +const mockRoute = { + params: { name: "test-app" }, + query: {}, +}; + +vi.mock("vue-router", () => ({ + useRoute: vi.fn(() => mockRoute), + useRouter: vi.fn(() => ({ + push: vi.fn(), + back: vi.fn(), + })), +})); + +vi.mock("@/services/api", () => ({ + deploymentsApi: { + get: vi.fn().mockResolvedValue({ + data: { + deployment: { + name: "test-app", + status: "running", + type: "docker-compose", + path: "/deployments/test-app", + services: [{ name: "web", status: "running", container_id: "abc123" }], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + proxy_status: { enabled: true, domain: "test-app.example.com" }, + }, + }), + getEnvVars: vi.fn().mockResolvedValue({ + data: { env_vars: [] }, + }), + getLogs: vi.fn().mockResolvedValue({ + data: { logs: "Test logs" }, + }), + logs: vi.fn().mockResolvedValue({ + data: { logs: "Test logs" }, + }), + getStats: vi.fn().mockResolvedValue({ + data: { + stats: { + cpu_percent: 10, + memory_usage: 100000000, + memory_limit: 1000000000, + }, + }, + }), + listFiles: vi.fn().mockResolvedValue({ + data: { files: [] }, + }), + getComposeFile: vi.fn().mockResolvedValue({ + data: { content: "version: '3'\nservices:\n web:\n image: nginx" }, + }), + start: vi.fn().mockResolvedValue({ data: { message: "Started" } }), + stop: vi.fn().mockResolvedValue({ data: { message: "Stopped" } }), + restart: vi.fn().mockResolvedValue({ data: { message: "Restarted" } }), + delete: vi.fn().mockResolvedValue({ data: { message: "Deleted" } }), + }, + proxyApi: { + getStatus: vi.fn().mockResolvedValue({ + data: { status: { enabled: true } }, + }), + }, + securityApi: { + getDeploymentSecurity: vi.fn().mockResolvedValue({ + data: { + enabled: false, + blocked_ips: [], + protected_paths: [], + rate_limits: [], + }, + }), + getDeploymentEvents: vi.fn().mockResolvedValue({ + data: { events: [] }, + }), + updateDeploymentSecurity: vi.fn().mockResolvedValue({ + data: { message: "Updated" }, + }), + }, + containersApi: { + exec: vi.fn().mockResolvedValue({ data: {} }), + }, + filesApi: { + read: vi.fn().mockResolvedValue({ data: { content: "" } }), + write: vi.fn().mockResolvedValue({ data: { message: "Written" } }), + getContent: vi.fn().mockResolvedValue({ data: { content: "" } }), + }, +})); + +vi.mock("@/composables/useNotifications", () => ({ + useNotifications: () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }), +})); + +describe("DeploymentDetailView", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const mountView = () => { + return mount(DeploymentDetailView, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + }), + ], + mocks: { + $route: mockRoute, + }, + stubs: { + RouterLink: { + template: "", + props: ["to"], + }, + teleport: true, + ContainerTerminal: true, + LogViewer: true, + }, + }, + }); + }; + + describe("View structure", () => { + it("renders the deployment detail view container", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.find(".deployment-detail").exists()).toBe(true); + }); + + it("contains detail header", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.find(".detail-header").exists()).toBe(true); + }); + + it("contains detail tabs section", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.find(".detail-tabs").exists()).toBe(true); + }); + }); + + describe("Tab navigation", () => { + it("displays all eight tabs", async () => { + const wrapper = mountView(); + await flushPromises(); + const tabs = wrapper.findAll(".tab-btn"); + expect(tabs.length).toBe(8); + }); + + it("has Overview tab", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.text()).toContain("Overview"); + }); + + it("has Files tab", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.text()).toContain("Files"); + }); + + it("has Logs tab", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.text()).toContain("Logs"); + }); + + it("has Terminal tab", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.text()).toContain("Terminal"); + }); + + it("has Environment tab", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.text()).toContain("Environment"); + }); + + it("has Quick Actions tab", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.text()).toContain("Quick Actions"); + }); + + it("has Security tab", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.text()).toContain("Security"); + }); + + it("has Configuration tab", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.text()).toContain("Configuration"); + }); + + it("Overview tab is active by default", async () => { + const wrapper = mountView(); + await flushPromises(); + const activeTab = wrapper.find(".tab-btn.active"); + expect(activeTab.text()).toContain("Overview"); + }); + + it("can switch tabs by clicking", async () => { + const wrapper = mountView(); + await flushPromises(); + + const logsTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Logs")); + await logsTab?.trigger("click"); + + expect(wrapper.find(".tab-btn.active").text()).toContain("Logs"); + }); + }); + + describe("Tab definitions", () => { + it("has correct tab structure", async () => { + const wrapper = mountView(); + await flushPromises(); + expect((wrapper.vm as any).tabs).toEqual([ + { id: "overview", label: "Overview", icon: "pi pi-info-circle" }, + { id: "files", label: "Files", icon: "pi pi-folder" }, + { id: "logs", label: "Logs", icon: "pi pi-file-edit" }, + { id: "terminal", label: "Terminal", icon: "pi pi-desktop" }, + { id: "environment", label: "Environment", icon: "pi pi-list" }, + { id: "actions", label: "Quick Actions", icon: "pi pi-bolt" }, + { id: "security", label: "Security", icon: "pi pi-shield" }, + { id: "config", label: "Configuration", icon: "pi pi-cog" }, + ]); + }); + }); + + describe("Security tab with incomplete data", () => { + it("renders security tab without errors when protected_paths is undefined", async () => { + const { securityApi } = await import("@/services/api"); + vi.mocked(securityApi.getDeploymentSecurity).mockResolvedValueOnce({ + data: { + enabled: false, + blocked_ips: [], + }, + } as any); + + const wrapper = mountView(); + await flushPromises(); + + const securityTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Security")); + await securityTab?.trigger("click"); + await flushPromises(); + + expect(wrapper.find(".tab-btn.active").text()).toContain("Security"); + }); + + it("renders security tab without errors when rate_limits is undefined", async () => { + const { securityApi } = await import("@/services/api"); + vi.mocked(securityApi.getDeploymentSecurity).mockResolvedValueOnce({ + data: { + enabled: false, + blocked_ips: [], + protected_paths: [], + }, + } as any); + + const wrapper = mountView(); + await flushPromises(); + + const securityTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Security")); + await securityTab?.trigger("click"); + await flushPromises(); + + expect(wrapper.find(".tab-btn.active").text()).toContain("Security"); + }); + + it("renders security tab without errors when security config is empty object", async () => { + const { securityApi } = await import("@/services/api"); + vi.mocked(securityApi.getDeploymentSecurity).mockResolvedValueOnce({ + data: {}, + } as any); + + const wrapper = mountView(); + await flushPromises(); + + const securityTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Security")); + await securityTab?.trigger("click"); + await flushPromises(); + + expect(wrapper.find(".tab-btn.active").text()).toContain("Security"); + }); + + it("displays zero counts when security arrays are empty", async () => { + const wrapper = mountView(); + await flushPromises(); + + const securityTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Security")); + await securityTab?.trigger("click"); + await flushPromises(); + + const summaryValues = wrapper.findAll(".summary-value"); + const protectedPathsCount = summaryValues.find((v) => + v.element.parentElement?.textContent?.includes("Protected Paths"), + ); + expect(protectedPathsCount?.text()).toBe("0"); + }); + }); + + describe("Header actions", () => { + it("displays start button", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.find(".btn-success").text()).toContain("Start"); + }); + + it("displays stop button", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.find(".btn-warning").text()).toContain("Stop"); + }); + + it("displays restart button", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.find(".btn-info").text()).toContain("Restart"); + }); + + it("displays delete button", async () => { + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.find(".btn-danger").text()).toContain("Delete"); + }); + }); + + describe("Data fetching", () => { + it("fetches deployment on mount", async () => { + const { deploymentsApi } = await import("@/services/api"); + mountView(); + await flushPromises(); + expect(deploymentsApi.get).toHaveBeenCalledWith("test-app"); + }); + + it("fetches environment variables on mount", async () => { + const { deploymentsApi } = await import("@/services/api"); + mountView(); + await flushPromises(); + expect(deploymentsApi.getEnvVars).toHaveBeenCalledWith("test-app"); + }); + }); +}); diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index 61acf50..676a7e1 100644 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -519,7 +519,9 @@
- {{ securityConfig.protected_paths.filter((p) => p.enabled).length }} + {{ + (securityConfig.protected_paths || []).filter((p) => p.enabled).length + }} Protected Paths
@@ -528,7 +530,9 @@
- {{ securityConfig.rate_limits.filter((r) => r.enabled).length }} + {{ + (securityConfig.rate_limits || []).filter((r) => r.enabled).length + }} Rate Limits
@@ -579,14 +583,14 @@
-
+

No protected paths

Click presets above or add custom paths
-
+

No rate limits

Add limits to protect against abuse
{ }; const isPathProtected = (pattern: string) => { - return securityConfig.value.protected_paths.some((p) => p.pattern === pattern); + return (securityConfig.value.protected_paths || []).some((p) => p.pattern === pattern); }; const toggleProtectedPath = (pattern: string) => { + if (!securityConfig.value.protected_paths) securityConfig.value.protected_paths = []; const index = securityConfig.value.protected_paths.findIndex((p) => p.pattern === pattern); if (index >= 0) { securityConfig.value.protected_paths.splice(index, 1); @@ -1439,6 +1444,7 @@ const toggleProtectedPath = (pattern: string) => { const addProtectedPath = () => { if (!newProtectedPath.value) return; + if (!securityConfig.value.protected_paths) securityConfig.value.protected_paths = []; if (!isPathProtected(newProtectedPath.value)) { securityConfig.value.protected_paths.push({ pattern: newProtectedPath.value, enabled: true }); saveSecurityConfig(); @@ -1447,12 +1453,14 @@ const addProtectedPath = () => { }; const removeProtectedPath = (index: number) => { + if (!securityConfig.value.protected_paths) return; securityConfig.value.protected_paths.splice(index, 1); saveSecurityConfig(); }; const addRateLimit = () => { if (!newRateLimit.value.path) return; + if (!securityConfig.value.rate_limits) securityConfig.value.rate_limits = []; securityConfig.value.rate_limits.push({ path: newRateLimit.value.path, rate: newRateLimit.value.rate || 10, @@ -1464,6 +1472,7 @@ const addRateLimit = () => { }; const removeRateLimit = (index: number) => { + if (!securityConfig.value.rate_limits) return; securityConfig.value.rate_limits.splice(index, 1); saveSecurityConfig(); };