01f8974314
CI / Architecture Guardrails (pull_request) Successful in 2m59s
CI / Typecheck (pull_request) Successful in 6m41s
CI / Lint (pull_request) Successful in 4m18s
CI / Assistant Split Regression (pull_request) Successful in 5m6s
CI / Unit Tests (pull_request) Successful in 7m21s
CI / Build (pull_request) Successful in 5m21s
CI / Fresh-Linux Docker Deploy (pull_request) Failing after 38s
CI / E2E Tests (pull_request) Successful in 3m28s
CI / Release Images (pull_request) Has been skipped
- docker-compose.yml / .prod.yml / .ci.yml: project names, POSTGRES_DB/USER, pg_isready, DATABASE_URL, volume names (nexus_pgdata, nexus_prod_*) - .github/workflows/ci.yml: POSTGRES_PASSWORD, pg_isready, psql credentials, GRANT statements, POSTGRES_PASSWORD=nexus_dev for Docker Deploy job - scripts/db-target-guard.mjs: expectedDatabase default, NEXUS_EXPECTED_DB_NAME - scripts/prisma-with-env.mjs, e2e/test-server.mjs: env-var rename - packages/db/src/safe-destructive-env.ts + reset-dispo-import.ts: DB name set - packages/db/src/destructive-db-guard.ts: PROTECTED_DATABASE_NAMES → "nexus" - packages/db/src/destructive-db-guard.test.ts: all fixture DB names + comments - .env.example, tooling/deploy/deploy.env.example: DATABASE_URL, image refs - packages/api: Redis channel/key prefixes (rbac-invalidate, sse, ratelimit), logger service name, app-base-url log prefix - E2E: DB container names, localStorage/sessionStorage keys, email domains - scripts: architecture-guardrails filter, export/import-dev-seed defaults, harden-postgres defaults, start.sh pg_isready, worktree-hygiene fixture - tooling/migrate/rename-to-nexus.sh: new maintenance-window cutover script Only intentional capakraken survivor: anonymization.ts DEFAULT_ANONYMIZATION_SEED (functional cryptographic constant — changing it would invalidate stored aliases). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
276 lines
8.4 KiB
TypeScript
276 lines
8.4 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
const ADMIN_EMAIL = "admin@nexus.dev";
|
|
const ADMIN_PASSWORD = "admin123";
|
|
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
|
|
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
|
|
const WEB_ENV_PATHS = [
|
|
resolve(process.cwd(), ".env.local"),
|
|
resolve(process.cwd(), "apps/web/.env.local"),
|
|
];
|
|
|
|
function resolveDatabaseUrl(): string {
|
|
const explicitPlaywrightUrl = process.env["PLAYWRIGHT_DATABASE_URL"];
|
|
if (explicitPlaywrightUrl) {
|
|
return explicitPlaywrightUrl;
|
|
}
|
|
|
|
for (const envPath of WEB_ENV_PATHS) {
|
|
if (!existsSync(envPath)) {
|
|
continue;
|
|
}
|
|
|
|
const envFile = readFileSync(envPath, "utf8");
|
|
for (const rawLine of envFile.split(/\r?\n/u)) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith("#")) {
|
|
continue;
|
|
}
|
|
if (line.startsWith("PLAYWRIGHT_DATABASE_URL=")) {
|
|
return line.slice("PLAYWRIGHT_DATABASE_URL=".length);
|
|
}
|
|
if (line.startsWith("DATABASE_URL_TEST=")) {
|
|
return line.slice("DATABASE_URL_TEST=".length);
|
|
}
|
|
if (line.startsWith("DATABASE_URL=")) {
|
|
return line.slice("DATABASE_URL=".length);
|
|
}
|
|
}
|
|
}
|
|
|
|
const fallbackTestUrl = process.env["DATABASE_URL_TEST"];
|
|
if (fallbackTestUrl) {
|
|
return fallbackTestUrl;
|
|
}
|
|
|
|
const fallback = process.env["DATABASE_URL"];
|
|
if (fallback) {
|
|
return fallback;
|
|
}
|
|
|
|
throw new Error("DATABASE_URL is not available for the Playwright assistant approval test.");
|
|
}
|
|
|
|
function runDbJson<T>(body: string): T {
|
|
const script = `
|
|
import { PrismaClient } from "@prisma/client";
|
|
const prisma = new PrismaClient({
|
|
datasources: {
|
|
db: {
|
|
url: process.env.DATABASE_URL,
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
${body}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
`;
|
|
|
|
const output = execFileSync("node", ["--input-type=module", "-e", script], {
|
|
cwd: DB_WORKDIR,
|
|
env: {
|
|
...process.env,
|
|
DATABASE_URL: resolveDatabaseUrl(),
|
|
},
|
|
encoding: "utf8",
|
|
}).trim();
|
|
|
|
return JSON.parse(output) as T;
|
|
}
|
|
|
|
function runDb(body: string): void {
|
|
runDbJson<null>(`${body}\nconsole.log("null");`);
|
|
}
|
|
|
|
async function signInAsAdmin(page: Page) {
|
|
await page.goto("/auth/signin");
|
|
await page.fill('input[type="email"]', ADMIN_EMAIL);
|
|
await page.fill('input[type="password"]', ADMIN_PASSWORD);
|
|
await page.click('button[type="submit"]');
|
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
|
}
|
|
|
|
test.describe("Assistant approvals", () => {
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.addInitScript((conversationId) => {
|
|
window.sessionStorage.setItem("nexus-chat-conversation-id", conversationId);
|
|
}, CURRENT_CONVERSATION_ID);
|
|
|
|
runDb(`
|
|
await prisma.systemSettings.upsert({
|
|
where: { id: "singleton" },
|
|
update: {
|
|
aiProvider: "openai",
|
|
azureOpenAiApiKey: "e2e-dummy-key",
|
|
azureOpenAiDeployment: "gpt-4o-mini",
|
|
},
|
|
create: {
|
|
id: "singleton",
|
|
aiProvider: "openai",
|
|
azureOpenAiApiKey: "e2e-dummy-key",
|
|
azureOpenAiDeployment: "gpt-4o-mini",
|
|
},
|
|
});
|
|
|
|
const admin = await prisma.user.findUniqueOrThrow({
|
|
where: { email: ${JSON.stringify(ADMIN_EMAIL)} },
|
|
select: { id: true },
|
|
});
|
|
|
|
await prisma.assistantApproval.deleteMany({
|
|
where: { userId: admin.id },
|
|
});
|
|
|
|
await prisma.client.deleteMany({
|
|
where: {
|
|
name: {
|
|
startsWith: "E2E Approval Client",
|
|
},
|
|
},
|
|
});
|
|
`);
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
runDb(`
|
|
await prisma.assistantApproval.deleteMany({
|
|
where: {
|
|
conversationId: {
|
|
startsWith: "assistant-e2e-",
|
|
},
|
|
},
|
|
});
|
|
|
|
await prisma.client.deleteMany({
|
|
where: {
|
|
name: {
|
|
startsWith: "E2E Approval Client",
|
|
},
|
|
},
|
|
});
|
|
`);
|
|
});
|
|
|
|
test("renders the pending approval inbox and handles cross-conversation actions", async ({
|
|
page,
|
|
}) => {
|
|
const suffix = Date.now();
|
|
const currentClientName = `E2E Approval Client Current ${suffix}`;
|
|
const otherClientName = `E2E Approval Client Other ${suffix}`;
|
|
const otherConversationId = `assistant-e2e-other-${suffix}`;
|
|
|
|
const { currentApproval, otherApproval } = runDbJson<{
|
|
currentApproval: { id: string; summary: string };
|
|
otherApproval: { id: string; summary: string };
|
|
}>(`
|
|
const admin = await prisma.user.findUniqueOrThrow({
|
|
where: { email: ${JSON.stringify(ADMIN_EMAIL)} },
|
|
select: { id: true },
|
|
});
|
|
|
|
const currentApproval = await prisma.assistantApproval.create({
|
|
data: {
|
|
userId: admin.id,
|
|
conversationId: ${JSON.stringify(CURRENT_CONVERSATION_ID)},
|
|
toolName: "create_client",
|
|
toolArguments: ${JSON.stringify(JSON.stringify({ name: currentClientName }))},
|
|
summary: ${JSON.stringify(`create client (name=${currentClientName})`)},
|
|
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
|
},
|
|
select: { id: true, summary: true },
|
|
});
|
|
|
|
const otherApproval = await prisma.assistantApproval.create({
|
|
data: {
|
|
userId: admin.id,
|
|
conversationId: ${JSON.stringify(otherConversationId)},
|
|
toolName: "create_client",
|
|
toolArguments: ${JSON.stringify(JSON.stringify({ name: otherClientName }))},
|
|
summary: ${JSON.stringify(`create client (name=${otherClientName})`)},
|
|
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
|
},
|
|
select: { id: true, summary: true },
|
|
});
|
|
|
|
console.log(JSON.stringify({ currentApproval, otherApproval }));
|
|
`);
|
|
|
|
await signInAsAdmin(page);
|
|
await page.goto("/dashboard");
|
|
await page.getByTestId("assistant-open-button").click();
|
|
|
|
await expect(page.getByRole("heading", { name: "HartBOT" })).toBeVisible();
|
|
await expect(page.getByTestId("assistant-open-approvals")).toBeVisible();
|
|
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
|
await expect(page.getByText(otherApproval.summary)).toBeVisible();
|
|
|
|
const currentCard = page
|
|
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]')
|
|
.first();
|
|
const otherCard = page
|
|
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]')
|
|
.first();
|
|
await expect(currentCard).toContainText("This chat");
|
|
await expect(otherCard).toContainText("Other chat");
|
|
|
|
await otherCard.getByTestId("assistant-approval-cancel").click();
|
|
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
|
|
await expect(
|
|
page.locator(
|
|
`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`,
|
|
),
|
|
).toHaveCount(0);
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return runDbJson<string | null>(`
|
|
const approval = await prisma.assistantApproval.findUnique({
|
|
where: { id: ${JSON.stringify(otherApproval.id)} },
|
|
select: { status: true },
|
|
});
|
|
console.log(JSON.stringify(approval?.status ?? null));
|
|
`);
|
|
})
|
|
.toBe("CANCELLED");
|
|
|
|
await currentCard.getByTestId("assistant-approval-confirm").click();
|
|
|
|
await expect(page.getByText(`Ausgeführt: Created client: ${currentClientName}`)).toBeVisible();
|
|
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return runDbJson<string | null>(`
|
|
const approval = await prisma.assistantApproval.findUnique({
|
|
where: { id: ${JSON.stringify(currentApproval.id)} },
|
|
select: { status: true },
|
|
});
|
|
console.log(JSON.stringify(approval?.status ?? null));
|
|
`);
|
|
})
|
|
.toBe("APPROVED");
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return runDbJson<"created" | "missing">(`
|
|
const client = await prisma.client.findFirst({
|
|
where: { name: ${JSON.stringify(currentClientName)} },
|
|
select: { id: true },
|
|
});
|
|
console.log(JSON.stringify(client ? "created" : "missing"));
|
|
`);
|
|
})
|
|
.toBe("created");
|
|
|
|
await expect(page.getByTestId("assistant-approval-card")).toHaveCount(0);
|
|
});
|
|
});
|