* 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>
183 lines
6.3 KiB
TypeScript
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;
|
|
}
|