import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator, type FakeSandboxProvider } from "@rakazo/adapters"; import type { Bot, Space } from "@rakazo/contracts"; import { computerHomeKey, ensureComputerRecord } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { createApp } from "../../../apps/api/src/app.ts"; import { sessionCookieHeader } from "./index.js"; type App = { request: (input: string, init?: RequestInit) => Promise }; type AppHandles = Awaited>; const databaseAvailable = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); const fixtureOrigin = "http://127.0.0.1:5173"; describe.skipIf(!databaseAvailable)("account delete and team sandbox teardown", () => { let handles: AppHandles; let app: App; const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-account-delete-sandbox-")); beforeAll(async () => { const { createApp } = await import("../../../apps/api/src/app.ts"); handles = await createApp({ databaseUrl: process.env.DATABASE_URL!, dataDir, sandboxProvider: "fake", agentRuntime: "scripted", wakeupDriver: "memory", signupsEnabled: "true", composio: new ComposioEmulator(), }); app = handles.app; }); afterAll(async () => { await handles.stop?.(); }); async function seedTeamComputer(spaceId: string, userId: string) { const homeKey = computerHomeKey("team", spaceId); const computer = await ensureComputerRecord(handles.prisma, { mode: "team", spaceId, userId, kind: "fake", }); const homePath = path.join(dataDir, "homes", homeKey); mkdirSync(path.join(homePath, "marker"), { recursive: true }); const sandbox = handles.sandbox as FakeSandboxProvider; const ref = await sandbox.provision( { botId: homeKey, homePath }, { operationId: "account-delete-seed", traceId: "account-delete-seed", spaceId, userId, signal: AbortSignal.timeout(15_000), }, ); await handles.prisma.computer.update({ where: { id: computer.id }, data: { providerRef: ref.id, state: "running" }, }); return { homeKey, homePath, refId: ref.id, sandbox }; } async function deleteAccount(cookie: string) { const deleted = await app.request("/api/auth/delete-user", { method: "POST", headers: { "content-type": "application/json", cookie, origin: fixtureOrigin, }, body: JSON.stringify({ password: "password12" }), }); expect(deleted.status).toBe(200); } it("preserves shared team sandboxes when a non-owner member deletes their account", async () => { const owner = await signup(app, `shared-owner-${stamp}@rakazo.test`, "Shared Owner"); const member = await signup(app, `shared-member-${stamp}@rakazo.test`, "Shared Member"); const shared = await rpc(app, owner, "spaces/create", { name: "Shared teardown" }); const sharedRow = await handles.prisma.space.findUniqueOrThrow({ where: { id: shared.id }, select: { organizationId: true }, }); const memberActor = await rpc<{ userId: string }>(app, member, "me"); await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); await handles.prisma.member.create({ data: { id: `shared-org-member-${stamp}`, organizationId: sharedRow.organizationId, userId: memberActor.userId, role: "member", createdAt: new Date(), }, }); await handles.prisma.spaceMember.create({ data: { id: `shared-space-member-${stamp}`, spaceId: shared.id, organizationId: sharedRow.organizationId, userId: memberActor.userId, role: "member", createdAt: new Date(), }, }); await rpc(app, owner, "bots/create", botPayload("Owner Bot"), shared.id); await rpc(app, member, "bots/create", botPayload("Member Bot"), shared.id); const { homePath, refId, sandbox } = await seedTeamComputer(shared.id, memberActor.userId); await deleteAccount(member); expect(existsSync(homePath)).toBe(true); expect(sandbox.boxes.has(refId)).toBe(true); const computer = await handles.prisma.computer.findFirst({ where: { spaceId: shared.id, scope: "team" }, }); expect(computer?.providerRef).toBeTruthy(); }); it("tears down team sandboxes when the sole owner deletes their account", async () => { const cookie = await signup(app, `solo-delete-${stamp}@rakazo.test`, "Solo Delete"); const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); const bot = await rpc(app, cookie, "bots/create", botPayload("Temporary")); const { homePath, refId, sandbox } = await seedTeamComputer(bot.spaceId, me.userId); await deleteAccount(cookie); expect(existsSync(homePath)).toBe(false); expect(sandbox.boxes.has(refId)).toBe(false); }); }); function botPayload(name: string) { return { name, title: "", description: "", instructions: "", notifyOnFinish: true, }; } 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: fixtureOrigin }, body: JSON.stringify({ email, password: "password12", name }), }); expect(response.status).toBeLessThan(400); return sessionCookieHeader(response); } async function rpc( app: App, cookie: string, procedure: string, input: unknown = {}, spaceId?: string, ): Promise { const headers: Record = { "content-type": "application/json", cookie, origin: fixtureOrigin, }; if (spaceId) headers["x-rakazo-space-id"] = spaceId; const response = await app.request(`/rpc/${procedure}`, { method: "POST", headers, body: JSON.stringify({ json: input }), }); const body = (await response.json()) as { json?: T; error?: { message?: string } }; if (response.status >= 400 || body.error) { throw new Error(`${procedure}: ${body.error?.message ?? response.status}`); } return body.json as T; }