Files
PaperClipAI/server/src/__tests__/managed-loopback-auth.test.ts
T
DottaandPaperclip b51112798f feat(apps): improve gateway and workspace connection UX (#12340)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - App connections must work in both the operator UI and agent tool
gateway.
> - The first stack layer adds secure remote connections.
> - Operators still need clear setup, test, and recovery states.
> - This pull request adds the gateway behavior and the workspace
connection experience.
> - The benefit is a connection flow that is easier to understand and
recover.

## Linked Issues or Issue Description

Refs #11965

This is stack 2 of 11. It depends on stack 1 and replaces another
reviewable part of #11965.

## What Changed

- Improve remote tool gateway connection behavior.
- Add clearer app setup, test, and recovery states.
- Add focused server and UI tests for the new paths.
- Keep the diff isolated from later identity and catalog work.
- Stabilize DNS-pinned remote HTTP protocol fixtures and the
managed-runtime public-origin fixture for this independently tested
layer.

## Verification

- `pnpm -r typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts` (150 passed)
- `pnpm test:run`
- `pnpm check:token-gates`
- `pnpm build`

## Risks

- Gateway errors now surface through new user-facing states.
- A stale connection can require a new setup attempt.
- The change does not add a database migration.
- The injected HTTP transport and public URL are test-only fixtures;
production DNS pinning and runtime behavior are unchanged.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-29 12:08:32 -05:00

102 lines
3.5 KiB
TypeScript

import { createDb } from "@paperclipai/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { Config } from "../config.js";
import { createBetterAuthInstance } from "../auth/better-auth.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping managed loopback auth tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function authRequest(origin: string, path: string, init?: RequestInit): Request {
return new Request(`${origin}/api/auth${path}`, {
...init,
headers: {
origin,
"content-type": "application/json",
...init?.headers,
},
});
}
function sessionCookie(response: Response): string {
const cookie = response.headers
.getSetCookie()
.find((value) => value.includes(".session_token="));
expect(cookie).toBeDefined();
return cookie!;
}
describeEmbeddedPostgres("managed runtime loopback auth cookies", () => {
const publicOrigin = "https://worktree.example.test";
const loopbackOrigin = "http://127.0.0.1:42013";
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-managed-loopback-auth-");
}, 20_000);
afterAll(async () => {
vi.unstubAllEnvs();
await tempDb?.cleanup();
});
it("uses a loopback sign-in cookie on the next request while keeping the public cookie secure", async () => {
vi.stubEnv("BETTER_AUTH_SECRET", "managed-loopback-auth-test-secret");
vi.stubEnv("PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", publicOrigin);
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
const db = createDb(tempDb!.connectionString);
const config = {
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "explicit",
authPublicBaseUrl: publicOrigin,
authDisableSignUp: false,
} as Config;
const auth = createBetterAuthInstance(db, config, [publicOrigin, loopbackOrigin]);
const credentials = {
name: "Loopback Operator",
email: "loopback-operator@example.test",
password: "correct-horse-battery-staple",
};
const signUpResponse = await auth.handler(authRequest(publicOrigin, "/sign-up/email", {
method: "POST",
body: JSON.stringify(credentials),
}));
expect(signUpResponse.status).toBe(200);
expect(sessionCookie(signUpResponse)).toMatch(/;\s*Secure(?:;|$)/i);
const signInResponse = await auth.handler(authRequest(loopbackOrigin, "/sign-in/email", {
method: "POST",
body: JSON.stringify({
email: credentials.email,
password: credentials.password,
}),
}));
expect(signInResponse.status).toBe(200);
const loopbackCookie = sessionCookie(signInResponse);
expect(loopbackCookie).not.toMatch(/;\s*Secure(?:;|$)/i);
const getSessionResponse = await auth.handler(authRequest(loopbackOrigin, "/get-session", {
method: "GET",
headers: {
cookie: loopbackCookie.split(";", 1)[0],
},
}));
expect(getSessionResponse.status).toBe(200);
await expect(getSessionResponse.json()).resolves.toMatchObject({
user: { email: credentials.email },
});
});
});