evOo-local/packages/testkit/src/account-delete-sandbox.postgres.test.ts
cynarAI 97787a0272
Production readiness phase 1: journey audit, fixes 1–4, E2E (#175)
* Production readiness phase 1: journeys doc, onboarding persistence, teardown, chat UX

- Add docs/PRODUCTION-READINESS.md with journey matrix and defect backlog
- Persist onboarding draft in session storage across reload
- Localize onboarding discovery main-bot role; German Chief of Staff string
- Destroy team sandboxes and home folders when deleting an account
- Keep composer/transcript visible during queued runs; extend send watchdog
- Add Playwright specs: reload persistence, DE intake, queued progress, console guard

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix CI: sync Lingui catalogs, onboarding name draft, E2E stability

- Localize Chief of Staff in discovery shell; sync all locale PO files
- Propagate Oo name to onboarding draft while typing
- Adjust DE intake, queued-run, and console-error E2E specs

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format chat-queued-progress E2E spec

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): scope account-delete sandbox teardown to owned spaces

Only destroy team sandboxes and homes for spaces the deleting user
owns with no remaining members or bots. Guard home removal under
dataDir/homes. Add unit and postgres journey coverage.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* chore: apply biome formatting

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 11:25:39 +02:00

183 lines
6.3 KiB
TypeScript

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<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
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<Space>(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<Bot>(app, owner, "bots/create", botPayload("Owner Bot"), shared.id);
await rpc<Bot>(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<Bot>(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<T>(
app: App,
cookie: string,
procedure: string,
input: unknown = {},
spaceId?: string,
): Promise<T> {
const headers: Record<string, string> = {
"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;
}