mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
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:
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(
|
||||
|
||||
Reference in new issue
Block a user