From 51f726103d142ad9ae93e6a412423ff4d735b420 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 12 Aug 2026 16:33:58 +0000 Subject: [PATCH] test(runtime): cover HTTPS URL serialization and UI launch links (PAP-17158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exposure lifecycle was covered end to end, but nothing asserted the two surfaces a user actually sees: what the API serializes as a runtime service's canonical `url`, and what the control bar renders as a launch link. Both are where a regression would silently reintroduce `http://paperclip-dev:`. Serialization (`svc.list` and `svc.listOverview`, the workspace overview that feeds the launch links): a `ready` exposure serializes the tailnet HTTPS URL on both the runtime service and the overview's `primaryService`, and a `pending` one serializes `url: null` even though the row still records its loopback `backendUrl` — the HTTP backend must never appear in a serialized DTO. Also asserts the server-private lease handle is not serialized. UI: a ready managed runtime renders every anchor at the tailnet HTTPS URL with the port still visible (the port is what makes a preview reachable), copies that same URL to the clipboard, and advertises no `http://` URL in any href, title, or visible text; a pending exposure renders no anchor at all. Negative-controlled by swapping in `http://paperclip-dev:45439`, which the guard catches. The `http://` guard is scoped to hrefs, titles, and text rather than raw innerHTML, because every inline SVG carries an `xmlns="http://www.w3.org/2000/svg"`. --- .../execution-workspaces-service.test.ts | 180 ++++++++++++++++++ .../WorkspaceRuntimeControls.test.tsx | 62 ++++++ .../WorkspaceServiceControlBar.test.tsx | 53 ++++++ 3 files changed, 295 insertions(+) diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 5c5a239897b..44747d85280 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -3948,6 +3948,186 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { }); }); + it("serializes the verified HTTPS URL as canonical and never falls back to the HTTP backend", async () => { + // PAP-17158: the UI's workspace/project/issue launch links read `url` off the + // serialized runtime service. Two things have to hold for a managed HTTPS + // runtime: once exposure is `ready` the canonical `url` is the HTTPS public + // URL, and while exposure is *not* ready the canonical `url` stays null even + // though the row still knows its loopback `backendUrl`. Serializing that + // backend URL would put `http://…` back into a launch link, which is exactly + // the fail-closed contract this feature exists to enforce. + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const readyWorkspaceId = randomUUID(); + const provisioningWorkspaceId = randomUUID(); + const readyServiceId = randomUUID(); + const provisioningServiceId = randomUUID(); + const hostname = "paperclip-dev.tail29c1aa.ts.net"; + const httpsUrl = `https://${hostname}:42010`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "HTTPS URL serialization", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + isPrimary: true, + cwd: "/tmp/https-url-serialization", + metadata: { + runtimeConfig: { + workspaceRuntime: { services: [{ name: "paperclip-dev", command: "pnpm dev" }] }, + desiredState: "running", + }, + }, + }); + await db.insert(executionWorkspaces).values([ + { + id: readyWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "dedicated_worktree", + strategyType: "git_worktree", + name: "Exposed workspace", + status: "idle", + providerType: "local_fs", + cwd: "/tmp/https-url-serialization/ready", + }, + { + id: provisioningWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "dedicated_worktree", + strategyType: "git_worktree", + name: "Provisioning workspace", + status: "idle", + providerType: "local_fs", + cwd: "/tmp/https-url-serialization/provisioning", + }, + ]); + await db.insert(workspaceRuntimeServices).values([ + { + id: readyServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: readyWorkspaceId, + scopeType: "execution_workspace", + scopeId: readyWorkspaceId, + serviceName: "paperclip-dev", + status: "running", + lifecycle: "shared", + reuseKey: "ready-dev", + command: "pnpm dev", + cwd: "/tmp/https-url-serialization/ready", + port: 42_010, + url: httpsUrl, + // The loopback backend is still recorded; it must never be serialized. + backendUrl: "http://127.0.0.1:42010", + provider: "local_process", + healthStatus: "healthy", + exposure: { + provider: "tailscale_https", + state: "ready", + publicUrl: httpsUrl, + hostname, + listeners: [ + { purpose: "app", publicPort: 42_010, targetPort: 42_010 }, + { purpose: "vite_hmr", publicPort: 52_010, targetPort: 52_010 }, + ], + brokerRef: "broker-ref-1", + lastError: null, + updatedAt: "2026-08-12T10:00:00.000Z", + }, + updatedAt: new Date("2026-08-12T10:00:00.000Z"), + }, + { + id: provisioningServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: provisioningWorkspaceId, + scopeType: "execution_workspace", + scopeId: provisioningWorkspaceId, + serviceName: "paperclip-dev", + status: "running", + lifecycle: "shared", + reuseKey: "provisioning-dev", + command: "pnpm dev", + cwd: "/tmp/https-url-serialization/provisioning", + port: 42_020, + url: null, + backendUrl: "http://127.0.0.1:42020", + provider: "local_process", + healthStatus: "healthy", + exposure: { + provider: "tailscale_https", + state: "pending", + publicUrl: null, + hostname, + listeners: [], + brokerRef: null, + lastError: null, + updatedAt: "2026-08-12T10:00:00.000Z", + }, + updatedAt: new Date("2026-08-12T10:00:00.000Z"), + }, + ]); + + const workspaces = await svc.list(companyId); + const readyService = workspaces + .find((workspace) => workspace.id === readyWorkspaceId) + ?.runtimeServices.find((service) => service.id === readyServiceId); + expect(readyService).toMatchObject({ + url: httpsUrl, + port: 42_010, + exposure: { provider: "tailscale_https", state: "ready", publicUrl: httpsUrl, hostname }, + }); + // Lease handles are server-private and must never reach a serialized DTO. + expect(readyService).not.toHaveProperty("exposureHandle"); + + const provisioningService = workspaces + .find((workspace) => workspace.id === provisioningWorkspaceId) + ?.runtimeServices.find((service) => service.id === provisioningServiceId); + expect(provisioningService?.url).toBeNull(); + expect(provisioningService?.exposure).toMatchObject({ state: "pending", publicUrl: null }); + expect(JSON.stringify(provisioningService)).not.toContain("http://127.0.0.1:42020"); + + // The overview feeds the workspace list launch links. + const overview = await svc.listOverview(companyId, { limit: 10, offset: 0 }); + const readyItem = overview.items.find((item) => item.workspaceId === readyWorkspaceId); + expect(readyItem?.primaryService).toMatchObject({ + id: readyServiceId, + status: "running", + url: httpsUrl, + exposure: { state: "ready", publicUrl: httpsUrl }, + }); + expect(new URL(readyItem!.primaryService!.url!).protocol).toBe("https:"); + + const provisioningItem = overview.items.find((item) => item.workspaceId === provisioningWorkspaceId); + expect(provisioningItem?.primaryService).toMatchObject({ + id: provisioningServiceId, + status: "running", + url: null, + exposure: { state: "pending" }, + }); + expect(JSON.stringify(provisioningItem)).not.toContain("http://127.0.0.1:42020"); + }, 30_000); + it("returns a bounded company-scoped workspace overview with service and linked issue summaries", async () => { const companyId = randomUUID(); const otherCompanyId = randomUUID(); diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx index baa1c5e0190..784f8cc4917 100644 --- a/ui/src/components/WorkspaceRuntimeControls.test.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -658,6 +658,68 @@ describe("buildWorkspaceServiceControlEntries", () => { expect(entry.exposureDetail).toMatch(/^HTTPS unavailable/); expect(entry.exposureDetail).not.toContain("cli_error"); }); + + it("carries the verified HTTPS URL into the launch entry, and no HTTP fallback while pending", () => { + // PAP-17158: the workspace/project/issue launch links are rendered from these + // entries, so the tailnet HTTPS URL has to survive the mapping intact — and a + // service whose exposure is not yet verified must offer no URL at all rather + // than the loopback backend it is really listening on. + const httpsUrl = "https://paperclip-dev.tail29c1aa.ts.net:42010"; + const buildEntry = (service: ReturnType) => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev" }] }, + runtimeServices: [service], + canStartServices: true, + }); + return buildWorkspaceServiceControlEntries({ sections, runtimeServices: [service] })[0]; + }; + + const ready = buildEntry(createRuntimeService({ + status: "running", + healthStatus: "healthy", + port: 42_010, + url: httpsUrl, + exposure: { + provider: "tailscale_https", + state: "ready", + publicUrl: httpsUrl, + hostname: "paperclip-dev.tail29c1aa.ts.net", + listeners: [ + { purpose: "app", publicPort: 42_010, targetPort: 42_010 }, + { purpose: "vite_hmr", publicPort: 52_010, targetPort: 52_010 }, + ], + brokerRef: "service-1", + lastError: null, + updatedAt: "2026-08-12T00:00:00.000Z", + }, + })); + expect(ready.state).toBe("running"); + expect(ready.url).toBe(httpsUrl); + expect(ready.exposureState).toBe("ready"); + // A ready exposure reports plainly and never as a remediation prompt. + expect(ready.exposureDetail).toBe("HTTPS ready"); + expect(ready.exposureDetail).not.toMatch(/unavailable|cleanup|Check the/i); + + const pending = buildEntry(createRuntimeService({ + status: "running", + healthStatus: "healthy", + port: 42_020, + url: null, + exposure: { + provider: "tailscale_https", + state: "pending", + publicUrl: null, + hostname: "paperclip-dev.tail29c1aa.ts.net", + listeners: [], + brokerRef: null, + lastError: null, + updatedAt: "2026-08-12T00:00:00.000Z", + }, + })); + expect(pending.url ?? null).toBeNull(); + expect(pending.exposureDetail).toBe("Provisioning HTTPS…"); + expect(JSON.stringify(pending)).not.toContain("http://"); + }); }); describe("resolveWorkspaceServiceControlRequests", () => { diff --git a/ui/src/components/WorkspaceServiceControlBar.test.tsx b/ui/src/components/WorkspaceServiceControlBar.test.tsx index 3f3617da950..f3e077aefbb 100644 --- a/ui/src/components/WorkspaceServiceControlBar.test.tsx +++ b/ui/src/components/WorkspaceServiceControlBar.test.tsx @@ -117,6 +117,59 @@ describe("WorkspaceServiceControlBar", () => { expect(runningSegment?.className).toBe(stoppedSegment?.className); }); + it("links a managed HTTPS runtime at its tailnet URL and offers no link before it is verified", async () => { + // PAP-17158: this bar renders the launch link on the workspace, project, and + // issue surfaces. The href must be the verified tailnet HTTPS URL including + // its non-standard port, and an unverified exposure must render no anchor at + // all — an `http://` fallback link is the failure this feature prevents. + const httpsUrl = "https://paperclip-dev.tail29c1aa.ts.net:42010"; + const renderExposed = async (url: string | null, exposureState: "ready" | "pending") => { + await act(() => { + root.render( + {}} + />, + ); + }); + }; + + await renderExposed(httpsUrl, "ready"); + const links = Array.from(container.querySelectorAll("a[href]")); + expect(links.length).toBeGreaterThan(0); + for (const link of links) expect(link.getAttribute("href")).toBe(httpsUrl); + // The port is what makes a preview reachable, so it must stay visible. + expect(container.textContent).toContain("paperclip-dev.tail29c1aa.ts.net:42010"); + // Scoped to hrefs, titles, and text: an SVG `xmlns` is not a launch link. + const advertisedUrls = () => [ + ...Array.from(container.querySelectorAll("a[href]"), (el) => el.getAttribute("href")), + ...Array.from(container.querySelectorAll("[title]"), (el) => el.getAttribute("title")), + container.textContent, + ].join(" "); + expect(advertisedUrls()).not.toContain("http://"); + + const copyButton = container.querySelector('button[aria-label="Copy URL"]')!; + await act(async () => { + copyButton.click(); + await Promise.resolve(); + }); + expect(writeText).toHaveBeenCalledWith(httpsUrl); + + // Exposure still pending: the backend is up but has no publishable URL yet. + await renderExposed(null, "pending"); + expect(container.querySelectorAll("a[href]")).toHaveLength(0); + expect(advertisedUrls()).not.toContain("http://"); + expect(container.textContent).toContain(":42010"); + }); + it("renders HTTPS cleanup separately from process running state", async () => { await act(() => { root.render(