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 }; type AppHandles = Awaited>; 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(app, cookie, "me") }; } async function makeDeploymentOwner(actor: Actor): Promise { 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(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(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( 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(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(app, intruder.cookie, "spaces/list"); expect(intruderSpaces.spaces.map((space) => space.id)).not.toContain(created); const profile = await rpc(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(app, intruder.cookie, "profile/get")).resolves.toMatchObject( { facts: [] }, ); // Applying again reuses the space and the facts. await expect( rpc(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(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(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(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(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(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; }