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
390 lines
15 KiB
TypeScript
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;
|
|
}
|