evOo-local/packages/testkit/src/onboarding-discovery.postgres.test.ts
Claude adb4dff052
Merge the updated legacy cleanup (#109, with main) into the Phase 2 integration branch
The onboarding scan keeps the consent-first rewrite. Since main's folder
check moved out of wireProjectWorkspace, applying a scan now checks that
each chosen project is still a folder (not only inside the scan folder)
before anything is written; a discovery test covers a project replaced by
a file after the scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:51:57 +00:00

390 lines
15 KiB
TypeScript

import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ComposioEmulator,
PipedreamConnector,
ThirdPartyConnectorEmulator,
} from "@rakazo/adapters";
import type {
OnboardingScanApplyResult,
OnboardingScanStatus,
SpaceNavigation,
UserProfileState,
} from "@rakazo/contracts";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { createApp } from "../../../apps/api/src/app.ts";
import { sessionCookieHeader } from "./index.js";
import { writeOnboardingScanFixture } from "./onboarding-scan-fixture.js";
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
type Actor = { userId: string; spaceId: string };
process.env.WAKEUP_DRIVER = "memory";
process.env.SANDBOX_PROVIDER = "fake";
process.env.AGENT_RUNTIME = "scripted";
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
const describeWithDatabase = hasDb ? describe : describe.skip;
const TEST_ENCRYPTION_KEY = "offline-discovery-test-encryption-key";
describeWithDatabase("onboarding discovery through the API", () => {
let handles: AppHandles;
let app: App;
let composio: ComposioEmulator;
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-discovery-"));
const home = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-home-")));
const outside = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-outside-")));
beforeAll(async () => {
await writeOnboardingScanFixture(home);
const { createApp } = await import("../../../apps/api/src/app.ts");
composio = new ComposioEmulator();
const thirdParties = new ThirdPartyConnectorEmulator();
handles = await createApp({
databaseUrl: process.env.DATABASE_URL!,
dataDir,
sandboxProvider: "fake",
agentRuntime: "scripted",
wakeupDriver: "memory",
signupsEnabled: "true",
encryptionKey: TEST_ENCRYPTION_KEY,
onboardingScanHome: home,
composio,
pipedream: new PipedreamConnector(
{
clientId: "fake-client-id",
clientSecret: "fake-client-secret",
projectId: "fake-project-id",
environment: "development",
identitySecret: TEST_ENCRYPTION_KEY,
},
{ fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname },
),
remoteConnectors: {
fetch: thirdParties.fetch,
resolveHostname: thirdParties.resolveHostname,
},
});
app = handles.app;
});
afterAll(async () => {
await handles?.stop();
for (const dir of [dataDir, home, outside]) rmSync(dir, { recursive: true, force: true });
});
async function account(label: string): Promise<{ cookie: string; actor: Actor }> {
const cookie = await signup(app, `${label}-${stamp}@rakazo.test`, label);
return { cookie, actor: await rpc<Actor>(app, cookie, "me") };
}
async function makeDeploymentOwner(actor: Actor): Promise<void> {
await handles.prisma.deploymentSettings.update({
where: { id: "default" },
data: { ownerUserId: actor.userId },
});
}
async function finishedScan(cookie: string) {
const { scanId } = await rpc<{ scanId: string }>(app, cookie, "onboarding/scan");
const status = await vi.waitFor(
async () => {
const current = await rpc<OnboardingScanStatus>(app, cookie, "onboarding/scanStatus", {
scanId,
});
if (current.status === "scanning") throw new Error("still scanning");
return current;
},
{ timeout: 10_000, interval: 50 },
);
if (status.status !== "complete") throw new Error("scan failed");
return { scanId, ...status };
}
it("offers the scan only to the deployment owner and keeps it with them", async () => {
const owner = await account("discovery-owner");
const other = await account("discovery-other");
await makeDeploymentOwner(owner.actor);
await expect(rpc(app, other.cookie, "onboarding/scanTarget")).resolves.toEqual({
folder: null,
});
expect((await raw(app, other.cookie, "onboarding/scan")).status).toBe(403);
await expect(rpc(app, owner.cookie, "onboarding/scanTarget")).resolves.toEqual({
folder: home,
});
const scan = await finishedScan(owner.cookie);
expect(scan.projects).toEqual([
{ id: "project-0", name: "atlas", path: "code/atlas" },
{ id: "project-1", name: "garden", path: "code/garden" },
]);
expect(scan.facts.map((fact) => fact.label)).toEqual([
"Python",
"TypeScript",
"Docker",
"GitHub Actions",
]);
expect(
(await raw(app, other.cookie, "onboarding/scanStatus", { scanId: scan.scanId })).status,
).toBe(404);
expect(
(
await raw(app, other.cookie, "onboarding/applyScan", {
scanId: scan.scanId,
projectIds: ["project-0"],
factIds: ["language:Python"],
})
).status,
).toBe(404);
await expect(rpc<UserProfileState>(app, other.cookie, "profile/get")).resolves.toMatchObject({
facts: [],
});
});
it("creates the chosen projects in the owner's own spaces and saves only the chosen facts", async () => {
const owner = await account("discovery-creator");
const intruder = await account("discovery-intruder");
const atlasFolder = path.join(home, "code/atlas");
// Another tenant already linked a space with the same name to the same folder, back when
// they held the deployment (only the deployment owner can link host folders).
await makeDeploymentOwner(intruder.actor);
const foreign = await rpc<{ id: string }>(app, intruder.cookie, "spaces/create", {
name: "atlas",
workspacePath: atlasFolder,
});
await makeDeploymentOwner(owner.actor);
const scan = await finishedScan(owner.cookie);
const choice = {
scanId: scan.scanId,
projectIds: ["project-0"],
factIds: ["language:TypeScript", "tool:Docker"],
};
const applied = await rpc<OnboardingScanApplyResult>(
app,
owner.cookie,
"onboarding/applyScan",
choice,
);
expect(applied.factsAdded).toBe(2);
expect(applied.spaces).toEqual([{ id: expect.any(String), name: "atlas" }]);
const created = applied.spaces[0]!.id;
expect(created).not.toBe(foreign.id);
const ownerOrg = await handles.prisma.space.findUniqueOrThrow({
where: { id: owner.actor.spaceId },
select: { organizationId: true },
});
await expect(
handles.prisma.space.findUniqueOrThrow({
where: { id: created },
select: { organizationId: true, workspacePath: true },
}),
).resolves.toEqual({ organizationId: ownerOrg.organizationId, workspacePath: atlasFolder });
await expect(
handles.prisma.spaceMember.findMany({
where: { spaceId: { in: [created, foreign.id] } },
select: { spaceId: true, userId: true, role: true },
orderBy: { spaceId: "asc" },
}),
).resolves.toEqual(
expect.arrayContaining([
{ spaceId: created, userId: owner.actor.userId, role: "owner" },
{ spaceId: foreign.id, userId: intruder.actor.userId, role: "owner" },
]),
);
expect(
await handles.prisma.spaceMember.count({ where: { spaceId: { in: [created, foreign.id] } } }),
).toBe(2);
await expect(
handles.prisma.memoryDocument.count({
where: { spaceId: created, userId: owner.actor.userId, scope: "user" },
}),
).resolves.toBe(1);
const ownerSpaces = await rpc<SpaceNavigation>(app, owner.cookie, "spaces/list");
expect(ownerSpaces.spaces).toContainEqual(
expect.objectContaining({
id: created,
name: "atlas",
workspace: expect.objectContaining({ path: atlasFolder }),
}),
);
expect(ownerSpaces.spaces.map((space) => space.id)).not.toContain(foreign.id);
const intruderSpaces = await rpc<SpaceNavigation>(app, intruder.cookie, "spaces/list");
expect(intruderSpaces.spaces.map((space) => space.id)).not.toContain(created);
const profile = await rpc<UserProfileState>(app, owner.cookie, "profile/get");
expect(
profile.facts.map(({ category, value, source }) => ({ category, value, source })),
).toEqual(
expect.arrayContaining([
{ category: "work", value: "Codes in TypeScript", source: "onboarding" },
{ category: "tools", value: "Uses Docker", source: "onboarding" },
]),
);
expect(profile.facts).toHaveLength(2);
await expect(rpc<UserProfileState>(app, intruder.cookie, "profile/get")).resolves.toMatchObject(
{ facts: [] },
);
// Applying again reuses the space and the facts.
await expect(
rpc<OnboardingScanApplyResult>(app, owner.cookie, "onboarding/applyScan", choice),
).resolves.toEqual({ spaces: [{ id: created, name: "atlas" }], factsAdded: 0 });
expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe(
2,
);
});
it("refuses a chosen project that left the scan folder and writes nothing", async () => {
const owner = await account("discovery-escape");
await makeDeploymentOwner(owner.actor);
const scan = await finishedScan(owner.cookie);
await mkdir(path.join(outside, "elsewhere", ".git"), { recursive: true });
await rm(path.join(home, "code/garden"), { recursive: true, force: true });
symlinkSync(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
const response = await raw(app, owner.cookie, "onboarding/applyScan", {
scanId: scan.scanId,
projectIds: ["project-1"],
factIds: ["language:Python"],
});
expect(response.status).toBe(400);
expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe(
1,
);
await expect(rpc<UserProfileState>(app, owner.cookie, "profile/get")).resolves.toMatchObject({
facts: [],
});
});
it("refuses a chosen project whose folder became a file and writes nothing", async () => {
const owner = await account("discovery-file");
await makeDeploymentOwner(owner.actor);
const garden = path.join(home, "code/garden");
await rm(garden, { recursive: true, force: true });
await writeOnboardingScanFixture(home);
const scan = await finishedScan(owner.cookie);
await rm(garden, { recursive: true, force: true });
await writeFile(garden, "not a folder\n");
const response = await raw(app, owner.cookie, "onboarding/applyScan", {
scanId: scan.scanId,
projectIds: ["project-0", "project-1"],
factIds: ["language:Python"],
});
expect(response.status).toBe(400);
expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe(
1,
);
await expect(rpc<UserProfileState>(app, owner.cookie, "profile/get")).resolves.toMatchObject({
facts: [],
});
});
it("remembers each connected app once as a tools fact and keeps it after revoking", async () => {
const user = await account("discovery-apps");
const other = await account("discovery-apps-other");
// A featured app is named by its label, whatever the account is called.
const gmail = await rpc<{ connectionId: string }>(app, user.cookie, "connections/begin", {
connectorId: "composio",
provider: "GMAIL",
displayName: "Personal",
});
// Other apps take the first account's name; a second account adds nothing.
for (const displayName of ["GitHub", "GitHub 2"]) {
await rpc(app, user.cookie, "connections/begin", {
connectorId: "composio",
provider: "GITHUB",
displayName,
});
}
// Connected through the authorization round trip.
const linear = await rpc<{ connectionId: string; authorizationUrl: string | null }>(
app,
user.cookie,
"connections/begin",
{ connectorId: "pipedream", provider: "linear", displayName: "Linear" },
);
expect(linear.authorizationUrl).not.toBeNull();
await expect(
rpc<{ status: string }>(app, user.cookie, "connections/complete", {
connectionId: linear.connectionId,
}),
).resolves.toMatchObject({ status: "connected" });
// Connected remotely, then noticed when the catalog reconciles a pending row.
await handles.prisma.connection.create({
data: {
spaceId: user.actor.spaceId,
userId: user.actor.userId,
provider: "SLACK",
displayName: "SLACK",
status: "pending",
},
});
await composio.begin(
{ provider: "SLACK", redirectUrl: "http://127.0.0.1.invalid/callback" },
{
operationId: "discovery-test",
traceId: "discovery-test",
spaceId: user.actor.spaceId,
userId: user.actor.userId,
signal: new AbortController().signal,
},
);
await rpc(app, user.cookie, "connections/catalog", { connectorId: "composio" });
await rpc(app, user.cookie, "connections/revoke", { connectionId: gmail.connectionId });
const profile = await rpc<UserProfileState>(app, user.cookie, "profile/get");
expect(
profile.facts
.map(({ category, value, source }) => ({ category, value, source }))
.sort((left, right) => left.value.localeCompare(right.value)),
).toEqual([
{ category: "tools", value: "Uses GitHub", source: "connector" },
{ category: "tools", value: "Uses Gmail", source: "connector" },
{ category: "tools", value: "Uses Linear", source: "connector" },
{ category: "tools", value: "Uses Slack", source: "connector" },
]);
await expect(rpc<UserProfileState>(app, other.cookie, "profile/get")).resolves.toMatchObject({
facts: [],
});
});
});
async function signup(app: App, email: string, name: string) {
const response = await app.request("/api/auth/sign-up/email", {
method: "POST",
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
body: JSON.stringify({ email, password: "password12", name }),
});
if (response.status >= 400) {
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
}
return sessionCookieHeader(response);
}
async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) {
return app.request(`/rpc/${procedure}`, {
method: "POST",
headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" },
body: JSON.stringify({ json: body }),
});
}
async function rpc<T>(app: App, cookie: string, procedure: string, body: unknown = {}) {
const response = await raw(app, cookie, procedure, body);
const text = await response.text();
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
if (response.status >= 400 || payload.error) {
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
}
return payload.json as T;
}