test(runtime): cover HTTPS URL serialization and UI launch links (PAP-17158)

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:<port>`.

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"`.
This commit is contained in:
Dotta authored and Dotta committed 2026-08-16 19:26:39 +00:00
1 parent 2b21fe01bc
commit 51f726103d
3 files changed
+295

No files matched your search

@@ -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();
@@ -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<typeof createRuntimeService>) => {
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", () => {
@@ -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(
<WorkspaceServiceControlBar
services={[{
key: "paperclip-dev",
name: "paperclip-dev",
state: "running",
healthStatus: "healthy",
url,
port: 42_010,
exposureState,
}]}
onAction={() => {}}
/>,
);
});
};
await renderExposed(httpsUrl, "ready");
const links = Array.from(container.querySelectorAll<HTMLAnchorElement>("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<HTMLButtonElement>('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(