#!/usr/bin/env tsx import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import bcrypt from "bcryptjs"; import { entitlementPlanIds } from "@toolctrl/entitlements"; import { syncDefaultRolesAndPermissions } from "@toolctrl/db/permissions-seed.server"; import { getDbClient, type DbClient } from "@toolctrl/db/client.server"; import { deriveLegacyApiKeyHashMetadata } from "@toolctrl/db/api-keys.server"; import type { PluginManifest } from "@toolctrl/plugin-runtime"; import type { SequenceStep } from "@toolctrl/email-sequences"; export const E2E_READINESS_USER_EMAIL = "e2e.readiness@example.test"; export const E2E_READINESS_USER_PASSWORD = "ReadinessPass123!"; export const E2E_READINESS_NO_TENANT_EMAIL = "e2e.no-tenant@example.test"; export const E2E_READINESS_NO_TENANT_PASSWORD = "NoTenantPass123!"; export const E2E_READINESS_PLUGIN_READONLY_EMAIL = "e2e.plugin-readonly@example.test"; export const E2E_READINESS_PLUGIN_READONLY_PASSWORD = "PluginReadOnlyPass123!"; export const E2E_READINESS_PLUGIN_READONLY_ROLE_NAME = "User"; // Dedicated operator-admin user for admin-host pages (email sequences, etc.). // The shared tenant user must NOT be an operator admin (it would redirect to the // admin host on login and break every tenant-app login flow), so admin-host // tests log in as this separate user instead. It gets an AdminUser row (so the // admin layout's `user.admin` gate passes) plus the global SuperAdmin role (which // grants `admin.marketing.leads.view`). No TOTP is enrolled, so admin MFA // enforcement allows it straight through. export const E2E_READINESS_ADMIN_EMAIL = "e2e.admin@example.test"; export const E2E_READINESS_ADMIN_PASSWORD = E2E_READINESS_USER_PASSWORD; export const E2E_READINESS_TENANT_SLUG = "e2e-readiness"; export const E2E_READINESS_TENANT_NAME = "E2E Readiness"; export const E2E_READINESS_WORKFLOW_ID = "wf_123"; export const E2E_READINESS_WORKFLOW_NAME = "E2E Readiness Manual Workflow"; export const E2E_READINESS_WORKFLOW_EXECUTION_ID = "wfe_123"; export const E2E_READINESS_WORKFLOW_LOG_BLOCK_EXECUTION_ID = "wfbe_log_123"; export const E2E_READINESS_WEBHOOK_WORKFLOW_ID = "wf_webhook_123"; export const E2E_READINESS_WEBHOOK_WORKFLOW_NAME = "E2E Readiness Webhook Workflow"; export const E2E_READINESS_WAITING_WORKFLOW_ID = "wf_waiting_123"; export const E2E_READINESS_WAITING_WORKFLOW_NAME = "E2E Readiness Waiting Workflow"; export const E2E_READINESS_WAITING_EXECUTION_ID = "wfe_waiting_123"; export const E2E_READINESS_WAITING_MANUAL_BLOCK_EXECUTION_ID = "wfbe_waiting_manual_123"; export const E2E_READINESS_PLUGIN_ID = "com.toolctrl.e2e-readiness"; export const E2E_READINESS_PLUGIN_VERSION = "1.0.0"; export const E2E_READINESS_LIFECYCLE_PLUGIN_ID = "com.toolctrl.e2e-lifecycle"; export const E2E_READINESS_LIFECYCLE_PLUGIN_VERSION = "1.0.0"; // Must match WORKSPACE_ROLES_FEATURE_FLAG in workspaceRoles.server.ts. export const E2E_READINESS_WORKSPACE_ROLES_FLAG = "workspace-roles"; export const E2E_READINESS_API_KEY_ALIAS = "E2E Readiness API Key"; export const E2E_READINESS_API_LOG_ENDPOINT = "/v1/e2e-readiness"; export const E2E_READINESS_API_LOG_METHOD = "GET"; export const E2E_READINESS_PORTAL_SUBDOMAIN = "e2e-readiness"; export const E2E_READINESS_PORTAL_TITLE = "E2E Readiness Portal"; export const E2E_READINESS_SUBSCRIPTION_PLAN_STRIPE_ID = "e2e_readiness_plan"; export const E2E_READINESS_SUBSCRIPTION_PRICE_STRIPE_ID = "e2e_readiness_price_monthly"; export const E2E_READINESS_UPGRADE_PLAN_STRIPE_ID = "e2e_readiness_upgrade_plan"; export const E2E_READINESS_UPGRADE_PRICE_STRIPE_ID = "e2e_readiness_upgrade_price_monthly"; // Entitlement plan granted to the seeded tenant's active subscription product so // per-feature gates (workflow engine, API, advanced builder, ...) resolve as allowed // instead of redirecting to /plan-required. "business" grants the widest feature set. export const E2E_READINESS_SUBSCRIPTION_ENTITLEMENT_PLAN_ID = entitlementPlanIds.business; export const E2E_READINESS_EMAIL_SEQUENCE_IDS = { welcome: "seq_e2e_welcome", trialExpiring: "seq_e2e_trial_expiring", staleLead: "seq_e2e_stale_lead", customerWelcome: "seq_e2e_customer_welcome", } as const; export const E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_ASSERTION = "operator-gated"; export const E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_BLOCKER = "Workflow outbox-drain readiness is operator-gated: set E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1 only when the general worker is running and the seeded manual workflow can reach the expected success execution status."; type EnvRecord = Record; type ReadinessFixture = { tenantSlug: string; workflowId: string; webhookWorkflowId: string; waitingWorkflowId: string; waitingExecutionId: string; pluginBasePath: string; }; export type E2eWorkflowBlockDefinition = { key: string; type: string; description: string; isTrigger: boolean; isBlock: boolean; input: unknown; }; export type E2eWorkflowDefinition = { id: string; name: string; description: string; blocks: E2eWorkflowBlockDefinition[]; edges: { from: string; to: string }[]; inputExample: { title: string; input: Record }; }; export type E2eEmailSequenceDefinition = { id: string; name: string; description: string; tenantId: string | null; triggerEvent: string; steps: SequenceStep[]; conditions: null; isEnabled: boolean; }; export function assertCanRunE2eReadinessSeed(env: EnvRecord = process.env): void { if (env.NODE_ENV === "production" && env.E2E_SEED_ALLOW_PRODUCTION !== "1") { throw new Error( "Refusing to run the E2E readiness seed in production. Set E2E_SEED_ALLOW_PRODUCTION=1 to explicitly allow seeding a production-like validation environment. The seed is fixture-scoped: it only creates/refreshes the dedicated e2e-readiness tenant and its test users, plugins, and workflows." ); } if (env.E2E_LOCAL_READINESS_SEED !== "1") { throw new Error("Set E2E_LOCAL_READINESS_SEED=1 to seed local E2E readiness fixtures."); } if (!env.DATABASE_URL?.trim()) { throw new Error("DATABASE_URL is required for the E2E readiness seed."); } } export function appFixturePath(tenantSlug: string, routePath: string): string { return `/${tenantSlug}/${routePath.replace(/^\/+/, "")}`; } export function buildE2eReadinessPluginManifest(): PluginManifest { return { schemaVersion: 1, id: E2E_READINESS_PLUGIN_ID, version: E2E_READINESS_PLUGIN_VERSION, name: "E2E Readiness Plugin", description: "Local deterministic plugin fixture for E2E readiness checks.", entry: "plugin.wasm", wasmSha256: "0".repeat(64), signature: "1".repeat(128), signedBy: "2".repeat(64), allowedHosts: [], capabilities: [{ name: "log" }, { name: "audit" }], hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }], ui: { settings: { entry: "settings/index.html", sha256: createHash("sha256").update(buildE2eReadinessPluginSettingsHtml()).digest("hex"), }, }, }; } export function buildE2eReadinessPluginSettingsHtml(): string { return [ '
', "

E2E Plugin Settings

", '', "
", "", "", ].join("\n"); } export function buildE2eReadinessLifecyclePluginManifest(): PluginManifest { return { schemaVersion: 1, id: E2E_READINESS_LIFECYCLE_PLUGIN_ID, version: E2E_READINESS_LIFECYCLE_PLUGIN_VERSION, name: "E2E Lifecycle Plugin", description: "Local deterministic plugin fixture for real enable/disable E2E checks.", entry: "plugin.wasm", wasmSha256: "3".repeat(64), signature: "4".repeat(128), signedBy: "5".repeat(64), allowedHosts: [], capabilities: [{ name: "log" }, { name: "audit" }], // The manifest schema requires at least one hook (manifest.ts: hooks.min(1)). // An empty array stores an invalid manifest, which makes listPlugins throw on // parse and crashes the whole plugins settings page with a 500. hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }], }; } export function buildE2eReadinessEnv(fixture: ReadinessFixture): Record { const workflowBase = appFixturePath(fixture.tenantSlug, `workflow-engine/workflows/${fixture.workflowId}`); const webhookWorkflowBase = appFixturePath(fixture.tenantSlug, `workflow-engine/workflows/${fixture.webhookWorkflowId}`); const waitingWorkflowBase = appFixturePath(fixture.tenantSlug, `workflow-engine/workflows/${fixture.waitingWorkflowId}`); return { E2E_TENANT_EMAIL: E2E_READINESS_USER_EMAIL, E2E_TENANT_PASSWORD: E2E_READINESS_USER_PASSWORD, E2E_NO_TENANT_EMAIL: E2E_READINESS_NO_TENANT_EMAIL, E2E_NO_TENANT_PASSWORD: E2E_READINESS_NO_TENANT_PASSWORD, E2E_PLUGIN_READONLY_EMAIL: E2E_READINESS_PLUGIN_READONLY_EMAIL, E2E_PLUGIN_READONLY_PASSWORD: E2E_READINESS_PLUGIN_READONLY_PASSWORD, E2E_ADMIN_EMAIL: E2E_READINESS_ADMIN_EMAIL, E2E_ADMIN_PASSWORD: E2E_READINESS_ADMIN_PASSWORD, E2E_TENANT_ACCOUNT_NAME: E2E_READINESS_TENANT_NAME, E2E_TENANT_ACCOUNT_SLUG: fixture.tenantSlug, E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "0", E2E_TENANT_DASHBOARD_PATH: appFixturePath(fixture.tenantSlug, "dashboard"), E2E_TENANT_SETTINGS_ACCOUNT_PATH: appFixturePath(fixture.tenantSlug, "settings/account"), E2E_TENANT_SETTINGS_PROFILE_PATH: appFixturePath(fixture.tenantSlug, "settings/profile"), E2E_TENANT_SUBSCRIPTION_PATH: appFixturePath(fixture.tenantSlug, "settings/subscription"), E2E_TENANT_MEMBERS_PATH: appFixturePath(fixture.tenantSlug, "settings/members"), E2E_TENANT_ROLES_AND_PERMISSIONS_PATH: appFixturePath(fixture.tenantSlug, "settings/roles-and-permissions"), E2E_TENANT_NOTIFICATIONS_PATH: appFixturePath(fixture.tenantSlug, "settings/notifications"), E2E_TENANT_LOGS_PATH: appFixturePath(fixture.tenantSlug, "settings/logs"), E2E_TENANT_LOG_EVENTS_PATH: appFixturePath(fixture.tenantSlug, "settings/logs/events"), E2E_TENANT_AUDIT_EXPORT_PATH: appFixturePath(fixture.tenantSlug, "settings/audit-export"), E2E_TENANT_PRIVACY_PATH: appFixturePath(fixture.tenantSlug, "settings/privacy"), E2E_TENANT_CHATBOT_PATH: appFixturePath(fixture.tenantSlug, "settings/chatbot"), E2E_TENANT_AI_USAGE_PATH: appFixturePath(fixture.tenantSlug, "settings/ai-usage"), E2E_TENANT_API_PATH: appFixturePath(fixture.tenantSlug, "settings/api"), E2E_TENANT_API_KEYS_PATH: appFixturePath(fixture.tenantSlug, "settings/api/keys"), E2E_TENANT_API_DOCS_PATH: appFixturePath(fixture.tenantSlug, "settings/api/docs"), E2E_TENANT_API_LOGS_PATH: appFixturePath(fixture.tenantSlug, "settings/api/logs"), E2E_TENANT_POLICIES_PATH: appFixturePath(fixture.tenantSlug, "settings/policies"), E2E_TENANT_PORTAL_ANALYTICS_PATH: appFixturePath(fixture.tenantSlug, `portals/${E2E_READINESS_PORTAL_SUBDOMAIN}/analytics`), E2E_TENANT_PORTAL_SETTINGS_PATH: appFixturePath(fixture.tenantSlug, `portals/${E2E_READINESS_PORTAL_SUBDOMAIN}/settings`), // Host-aware deployments serve admin pages on the admin host without the // `/admin` prefix; the app host 308-redirects `/email-sequences` to the admin // host, which page.goto follows (the readiness session cookie spans the // app-base domain). The literal `/admin/email-sequences` is a 404 on the app host. E2E_ADMIN_EMAIL_SEQUENCES_PATH: "/email-sequences", E2E_PLUGIN_SETTINGS_PATH: appFixturePath(fixture.tenantSlug, "settings/plugins"), E2E_WORKFLOW_ENGINE_PATH: appFixturePath(fixture.tenantSlug, "workflow-engine/workflows"), E2E_WORKFLOW_BUILDER_PATH: workflowBase, E2E_WORKFLOW_RUN_API_PATH: `${workflowBase}/run/api`, E2E_WORKFLOW_RUN_MANUAL_PATH: `${workflowBase}/run/manual`, E2E_WORKFLOW_WEBHOOK_BUILDER_PATH: webhookWorkflowBase, E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH: `${webhookWorkflowBase}/run/manual`, E2E_WORKFLOW_WAITING_BUILDER_PATH: waitingWorkflowBase, E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH: `${waitingWorkflowBase}/run/manual`, E2E_WORKFLOW_WAITING_RUN_API_PATH: `${waitingWorkflowBase}/run/api`, E2E_WORKFLOW_WAITING_EXECUTIONS_PATH: `${waitingWorkflowBase}/executions`, E2E_WORKFLOW_WAITING_EXECUTION_ID: fixture.waitingExecutionId, E2E_ALLOW_WORKFLOW_RUN: "1", E2E_ALLOW_WORKFLOW_WEBHOOK_RUN: "0", E2E_WORKFLOW_WEBHOOK_TARGET_URL: "", E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN: "0", E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS: "success", E2E_WORKFLOW_OUTBOX_DRAIN_ASSERTION: E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_ASSERTION, E2E_WORKFLOW_OUTBOX_DRAIN_BLOCKER: E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_BLOCKER, PLUGIN_BINARY_BASE_PATH: fixture.pluginBasePath, }; } export function renderE2eReadinessEnvFile(values: Record): string { return `${Object.entries(values) .map(([key, value]) => `${key}=${JSON.stringify(value)}`) .join("\n")}\n`; } export function buildE2eReadinessManualWorkflowDefinition(): E2eWorkflowDefinition { return { id: E2E_READINESS_WORKFLOW_ID, name: E2E_READINESS_WORKFLOW_NAME, description: "Local E2E readiness workflow with a manual trigger and log block.", blocks: [ { key: "manual", type: "manual", description: "Manual E2E readiness trigger", isTrigger: true, isBlock: false, input: {}, }, { key: "log", type: "log", description: "Record E2E readiness input", isTrigger: false, isBlock: true, input: { message: "E2E readiness {{$params.message}}" }, }, ], edges: [{ from: "manual", to: "log" }], inputExample: { title: "Readiness payload", input: { message: "ready" } }, }; } export function buildE2eReadinessWebhookWorkflowDefinition(): E2eWorkflowDefinition { return { id: E2E_READINESS_WEBHOOK_WORKFLOW_ID, name: E2E_READINESS_WEBHOOK_WORKFLOW_NAME, description: "Local E2E readiness workflow for manual webhook/HTTP request coverage.", blocks: [ { key: "manual", type: "manual", description: "Manual E2E webhook trigger", isTrigger: true, isBlock: false, input: {}, }, { key: "httpRequest", type: "httpRequest", description: "Send readiness webhook", isTrigger: false, isBlock: true, input: { url: "{{$params.webhookUrl}}", method: "POST", headers: { "content-type": "application/json" }, body: '{"message":"{{$params.message}}"}', throwsError: true, }, }, { key: "log", type: "log", description: "Record webhook readiness response", isTrigger: false, isBlock: true, input: { message: "Webhook readiness status {{$httpRequest.statusCode}}" }, }, ], edges: [ { from: "manual", to: "httpRequest" }, { from: "httpRequest", to: "log" }, ], inputExample: { title: "Webhook readiness payload", input: { webhookUrl: "http://127.0.0.1:1/e2e-readiness-webhook", message: "ready" }, }, }; } export function buildE2eReadinessWaitingWorkflowDefinition(): E2eWorkflowDefinition { return { id: E2E_READINESS_WAITING_WORKFLOW_ID, name: E2E_READINESS_WAITING_WORKFLOW_NAME, description: "Local E2E readiness workflow that pauses on wait-for-input.", blocks: [ { key: "manual", type: "manual", description: "Manual E2E waiting trigger", isTrigger: true, isBlock: false, input: {}, }, { key: "wait", type: "waitForInput", description: "E2E approval input", isTrigger: false, isBlock: true, input: { title: "E2E approval", placeholder: "Type approval" }, }, { key: "variable", type: "variable", description: "Store E2E approval input", isTrigger: false, isBlock: true, input: { variableName: "approvalMessage", value: "approved {{waitForInput.input.approved}}" }, }, { key: "log", type: "log", description: "Record E2E approval continuation", isTrigger: false, isBlock: true, input: { message: "continued {{$vars.approvalMessage}}" }, }, ], edges: [ { from: "manual", to: "wait" }, { from: "wait", to: "variable" }, { from: "variable", to: "log" }, ], inputExample: { title: "Waiting readiness payload", input: { input: { approved: false } } }, }; } export function buildE2eReadinessEmailSequenceDefinitions(tenantId: string | null = null): E2eEmailSequenceDefinition[] { return [ { id: E2E_READINESS_EMAIL_SEQUENCE_IDS.welcome, name: "E2E Welcome Sequence", description: "Local readiness sequence for tenant.signup trigger coverage.", tenantId, triggerEvent: "tenant.signup", isEnabled: true, conditions: null, steps: [ { stepIndex: 0, delayHours: 0, template: "welcome", subject: "Welcome to E2E Readiness", variables: { headline: "Your workspace is ready", bodyParagraphs: ["This deterministic fixture keeps the sequence list, editor, and runtime parser testable."], ctaLabel: "Open dashboard", ctaUrl: "/e2e-readiness/dashboard", }, }, ], }, { id: E2E_READINESS_EMAIL_SEQUENCE_IDS.trialExpiring, name: "E2E Trial Expiring Sequence", description: "Local readiness sequence for tenant.trial_expiring trigger coverage.", tenantId, triggerEvent: "tenant.trial_expiring", isEnabled: true, conditions: null, steps: [ { stepIndex: 0, delayHours: 0, template: "trial-reminder", subject: "Your E2E readiness trial ends soon", variables: { headline: "Trial reminder", bodyParagraphs: ["This fixture exercises the trial-expiring sequence path without live provider secrets."], ctaLabel: "Review subscription", ctaUrl: "/e2e-readiness/settings/subscription", }, }, ], }, { id: E2E_READINESS_EMAIL_SEQUENCE_IDS.staleLead, name: "E2E Stale Lead Sequence", description: "Local readiness sequence for lead.stale_60d trigger coverage.", tenantId, triggerEvent: "lead.stale_60d", isEnabled: true, conditions: null, steps: [ { stepIndex: 0, delayHours: 0, template: "re-activation", subject: "Still interested in E2E Readiness?", variables: { headline: "Let's pick this back up", bodyParagraphs: ["This fixture keeps stale-lead trigger handling inspectable in local readiness runs."], ctaLabel: "Resume setup", ctaUrl: "/e2e-readiness/dashboard", }, }, ], }, { id: E2E_READINESS_EMAIL_SEQUENCE_IDS.customerWelcome, name: "E2E Customer Welcome Sequence", description: "Local readiness sequence for lead.converted_to_customer trigger coverage.", tenantId, triggerEvent: "lead.converted_to_customer", isEnabled: true, conditions: null, steps: [ { stepIndex: 0, delayHours: 0, template: "generic-step", subject: "E2E Readiness customer setup", variables: { headline: "Customer setup", bodyParagraphs: ["This fixture gives the admin sequence dashboard stable conversion-sequence data."], ctaLabel: "Open account", ctaUrl: "/e2e-readiness/settings/account", }, }, ], }, ]; } async function upsertReadinessUser(db: DbClient, input: { email: string; password: string; firstName: string; lastName: string }) { const passwordHash = await bcrypt.hash(input.password, 10); return db.user.upsert({ where: { email: input.email }, create: { email: input.email, passwordHash, firstName: input.firstName, lastName: input.lastName, active: true, locale: "en", }, update: { passwordHash, firstName: input.firstName, lastName: input.lastName, active: true, locale: "en", }, }); } async function seedUserAndTenant(db: DbClient) { const user = await upsertReadinessUser(db, { email: E2E_READINESS_USER_EMAIL, password: E2E_READINESS_USER_PASSWORD, firstName: "E2E", lastName: "Readiness", }); const tenant = await db.tenant.upsert({ where: { slug: E2E_READINESS_TENANT_SLUG }, create: { slug: E2E_READINESS_TENANT_SLUG, name: E2E_READINESS_TENANT_NAME, active: true, }, update: { name: E2E_READINESS_TENANT_NAME, active: true, deactivatedReason: null, }, }); await ensureTenantMembership(db, user.id, tenant.id); // This tenant user must NOT be an operator admin: operator admins are redirected to // the admin host on login, which breaks every tenant-app login flow (auth-flow, // registration) and adds admin-only chrome (a low-contrast "Switch to admin" row) to // the roles page. A prior seed iteration granted it AdminUser + the global SuperAdmin // role, so actively REMOVE both here (stopping the grant is not enough — the rows // persist). Admin-host pages need a separate dedicated admin user. await db.adminUser.deleteMany({ where: { userId: user.id } }); const globalSuperAdminRole = await db.role.findFirst({ where: { tenantId: null, name: "SuperAdmin" }, select: { id: true } }); if (globalSuperAdminRole) { await db.userRole.deleteMany({ where: { userId: user.id, tenantId: null, roleId: globalSuperAdminRole.id } }); } return { user, tenant }; } async function pruneLeakedE2eAccounts(db: DbClient, readinessUserId: string, readinessTenantId: string) { // The E2E_ALLOW_*_CREATE registration/account-create tests create real tenants // owned by the readiness + no-tenant users and never delete them, so the users // accumulate memberships and the app renders the multi-account picker. A clean // fixture has the readiness user in exactly the readiness tenant and the no-tenant // user in none. Delete every other tenant these users belong to (cascade clears // memberships) plus the throwaway registration users + their tenants, so a reseed // heals the leak. const noTenantUser = await db.user.findUnique({ where: { email: E2E_READINESS_NO_TENANT_EMAIL }, select: { id: true } }); const userIds = [readinessUserId, ...(noTenantUser ? [noTenantUser.id] : [])]; const memberships = await db.tenantUser.findMany({ where: { userId: { in: userIds }, tenantId: { not: readinessTenantId } }, select: { tenantId: true }, }); const leakedTenantIds = [...new Set(memberships.map((m) => m.tenantId))]; if (leakedTenantIds.length > 0) { await db.tenant.deleteMany({ where: { id: { in: leakedTenantIds } } }); } const throwawayUsers = await db.user.findMany({ where: { OR: [{ email: { startsWith: "registration-provisioning-" } }, { email: { startsWith: "registration-verification-" } }], }, select: { id: true }, }); const throwawayIds = throwawayUsers.map((entry) => entry.id); if (throwawayIds.length > 0) { const owned = await db.tenantUser.findMany({ where: { userId: { in: throwawayIds } }, select: { tenantId: true } }); const ownedTenantIds = [...new Set(owned.map((entry) => entry.tenantId))].filter((id) => id !== readinessTenantId); if (ownedTenantIds.length > 0) { await db.tenant.deleteMany({ where: { id: { in: ownedTenantIds } } }); } await db.user.deleteMany({ where: { id: { in: throwawayIds } } }); } } async function seedNoTenantUser(db: DbClient) { const user = await upsertReadinessUser(db, { email: E2E_READINESS_NO_TENANT_EMAIL, password: E2E_READINESS_NO_TENANT_PASSWORD, firstName: "E2E No Tenant", lastName: "Readiness", }); await db.user.update({ where: { id: user.id }, data: { defaultTenantId: null }, }); await db.userRole.deleteMany({ where: { userId: user.id } }); await db.tenantUser.deleteMany({ where: { userId: user.id } }); return user; } async function seedAdminUser(db: DbClient) { // Roles/permissions must exist before we can resolve + grant the global // SuperAdmin role; syncDefaultRolesAndPermissions is idempotent. await syncDefaultRolesAndPermissions({ client: db }); const user = await upsertReadinessUser(db, { email: E2E_READINESS_ADMIN_EMAIL, password: E2E_READINESS_ADMIN_PASSWORD, firstName: "E2E", lastName: "Admin", }); // Operator admins are global (no tenant). Keep this user tenant-less so it does // not accumulate the multi-account picker or interfere with the tenant fixtures. await db.user.update({ where: { id: user.id }, data: { defaultTenantId: null } }); await db.tenantUser.deleteMany({ where: { userId: user.id } }); // AdminUser row makes the admin layout's `user.admin` gate pass. await db.adminUser.upsert({ where: { userId: user.id }, create: { userId: user.id }, update: {}, }); // Global SuperAdmin role grants admin permissions (e.g. admin.marketing.leads.view // for the email-sequences pages). const superAdminRole = await db.role.findFirst({ where: { tenantId: null, name: "SuperAdmin" }, select: { id: true } }); if (!superAdminRole) { throw new Error("Global SuperAdmin role is required for the E2E admin fixture."); } const existing = await db.userRole.findFirst({ where: { userId: user.id, tenantId: null, roleId: superAdminRole.id }, select: { id: true }, }); if (!existing) { await db.userRole.create({ data: { userId: user.id, tenantId: null, roleId: superAdminRole.id } }); } return user; } async function ensureTenantMembership(db: DbClient, userId: string, tenantId: string) { await db.user.update({ where: { id: userId }, data: { defaultTenantId: tenantId }, }); const membership = await db.tenantUser.findFirst({ where: { tenantId, userId }, select: { id: true }, }); if (membership) { await db.tenantUser.update({ where: { id: membership.id }, data: { type: 0, joined: 0, status: 2 }, }); } else { await db.tenantUser.create({ data: { tenantId, userId, type: 0, joined: 0, status: 2 }, }); } } async function seedRoles(db: DbClient, userId: string, tenantId: string) { await syncDefaultRolesAndPermissions({ client: db }); const roles = await db.role.findMany({ where: { tenantId: null, name: { in: ["SuperUser", "Admin", "Billing Admin", "User"] } }, select: { id: true }, }); for (const role of roles) { const existing = await db.userRole.findFirst({ where: { userId, tenantId, roleId: role.id }, select: { id: true }, }); if (!existing) { await db.userRole.create({ data: { userId, tenantId, roleId: role.id } }); } } // NOTE: do NOT grant the global SuperAdmin admin role here — it pairs with the // AdminUser operator grant (see seedUserAndTenant) which redirects this user to the // admin host on login and breaks tenant-app login flows. Admin-host tests need a // dedicated admin user instead. } async function seedFeatureFlags(db: DbClient) { // The workspace roles builder (/settings/roles-and-permissions/roles) is gated // behind the "workspace-roles" feature flag (see workspaceRoles.server.ts); // without an enabled global flag the roles sub-route throws a 404. Seed it // enabled so the e2e tenant can reach the roles tab. const existing = await db.featureFlag.findFirst({ where: { name: E2E_READINESS_WORKSPACE_ROLES_FLAG } }); if (existing) { if (!existing.enabled) { await db.featureFlag.update({ where: { id: existing.id }, data: { enabled: true } }); } return; } await db.featureFlag.create({ data: { name: E2E_READINESS_WORKSPACE_ROLES_FLAG, key: E2E_READINESS_WORKSPACE_ROLES_FLAG, description: "E2E readiness: enable the workspace roles builder.", scope: "global", enabled: true, }, }); } async function seedPluginReadonlyUser(db: DbClient, tenantId: string) { await syncDefaultRolesAndPermissions({ client: db }); const user = await upsertReadinessUser(db, { email: E2E_READINESS_PLUGIN_READONLY_EMAIL, password: E2E_READINESS_PLUGIN_READONLY_PASSWORD, firstName: "E2E Plugin", lastName: "Readonly", }); await ensureTenantMembership(db, user.id, tenantId); const role = await db.role.findFirst({ where: { tenantId: null, name: E2E_READINESS_PLUGIN_READONLY_ROLE_NAME }, select: { id: true }, }); if (!role) { throw new Error(`Default ${E2E_READINESS_PLUGIN_READONLY_ROLE_NAME} role is required for the E2E plugin read-only fixture.`); } await db.userRole.deleteMany({ where: { userId: user.id, tenantId } }); await db.userRole.create({ data: { userId: user.id, tenantId, roleId: role.id } }); return user; } async function seedSubscription(db: DbClient, tenantId: string) { const now = new Date(); const periodEnd = new Date(now); periodEnd.setUTCMonth(periodEnd.getUTCMonth() + 1); const product = (await db.subscriptionProduct.findFirst({ where: { stripeId: E2E_READINESS_SUBSCRIPTION_PLAN_STRIPE_ID } })) ?? (await db.subscriptionProduct.create({ data: { stripeId: E2E_READINESS_SUBSCRIPTION_PLAN_STRIPE_ID, order: 1, title: "E2E Readiness Plan", active: true, model: 0, public: true, description: "Local E2E readiness subscription plan.", billingAddressCollection: "auto", hasQuantity: false, canBuyAgain: false, }, })); await db.subscriptionProduct.update({ where: { id: product.id }, data: { active: true, public: true, title: "E2E Readiness Plan", description: "Local E2E readiness subscription plan." }, }); // Grant an unlimited "users" plan feature. members/new wraps its invite form in // CheckPlanFeatureLimit, which hides the form when the Users feature resolves to // NOT_INCLUDED (no feature row). type 4 = SubscriptionFeatureLimitType.UNLIMITED. const existingUsersFeature = await db.subscriptionFeature.findFirst({ where: { subscriptionProductId: product.id, name: "users" } }); if (!existingUsersFeature) { await db.subscriptionFeature.create({ data: { subscriptionProductId: product.id, order: 1, title: "Users", name: "users", type: 4, value: 0 }, }); } const price = (await db.subscriptionPrice.findFirst({ where: { subscriptionProductId: product.id, stripeId: E2E_READINESS_SUBSCRIPTION_PRICE_STRIPE_ID } })) ?? (await db.subscriptionPrice.create({ data: { subscriptionProductId: product.id, stripeId: E2E_READINESS_SUBSCRIPTION_PRICE_STRIPE_ID, type: 0, billingPeriod: 3, price: "19", currency: "usd", trialDays: 0, active: true, }, })); await db.subscriptionPrice.update({ where: { id: price.id }, data: { price: "19", currency: "usd", billingPeriod: 3, active: true }, }); const upgradeProduct = (await db.subscriptionProduct.findFirst({ where: { stripeId: E2E_READINESS_UPGRADE_PLAN_STRIPE_ID } })) ?? (await db.subscriptionProduct.create({ data: { stripeId: E2E_READINESS_UPGRADE_PLAN_STRIPE_ID, order: 2, title: "E2E Readiness Upgrade Plan", active: true, model: 0, public: true, description: "Local E2E readiness checkout plan that stays unassigned to the seeded tenant.", billingAddressCollection: "auto", hasQuantity: false, canBuyAgain: false, }, })); await db.subscriptionProduct.update({ where: { id: upgradeProduct.id }, data: { active: true, public: true, order: 2, title: "E2E Readiness Upgrade Plan", description: "Local E2E readiness checkout plan that stays unassigned to the seeded tenant.", }, }); const upgradePrice = (await db.subscriptionPrice.findFirst({ where: { subscriptionProductId: upgradeProduct.id, stripeId: E2E_READINESS_UPGRADE_PRICE_STRIPE_ID }, })) ?? (await db.subscriptionPrice.create({ data: { subscriptionProductId: upgradeProduct.id, stripeId: E2E_READINESS_UPGRADE_PRICE_STRIPE_ID, type: 0, billingPeriod: 3, price: "29", currency: "usd", trialDays: 0, active: true, }, })); await db.subscriptionPrice.update({ where: { id: upgradePrice.id }, data: { price: "29", currency: "usd", billingPeriod: 3, active: true }, }); const tenantSubscription = await db.tenantSubscription.upsert({ where: { tenantId }, create: { tenantId, stripeCustomerId: null }, update: {}, }); await db.tenantSubscriptionProduct.deleteMany({ where: { tenantSubscriptionId: tenantSubscription.id, subscriptionProductId: upgradeProduct.id }, }); const tenantProduct = (await db.tenantSubscriptionProduct.findFirst({ where: { tenantSubscriptionId: tenantSubscription.id, subscriptionProductId: product.id, fromCheckoutSessionId: "e2e-readiness" }, })) ?? (await db.tenantSubscriptionProduct.create({ data: { tenantSubscriptionId: tenantSubscription.id, subscriptionProductId: product.id, customEntitlementPlanId: E2E_READINESS_SUBSCRIPTION_ENTITLEMENT_PLAN_ID, fromCheckoutSessionId: "e2e-readiness", quantity: 1, currentPeriodStart: now, currentPeriodEnd: periodEnd, }, })); await db.tenantSubscriptionProduct.update({ where: { id: tenantProduct.id }, data: { customEntitlementPlanId: E2E_READINESS_SUBSCRIPTION_ENTITLEMENT_PLAN_ID, cancelledAt: null, endsAt: null, quantity: 1, currentPeriodStart: now, currentPeriodEnd: periodEnd, }, }); const tenantPrice = await db.tenantSubscriptionProductPrice.findFirst({ where: { tenantSubscriptionProductId: tenantProduct.id, subscriptionPriceId: price.id }, select: { id: true }, }); if (!tenantPrice) { await db.tenantSubscriptionProductPrice.create({ data: { tenantSubscriptionProductId: tenantProduct.id, subscriptionPriceId: price.id }, }); } } async function seedApiReadiness(db: DbClient, tenantId: string, userId: string) { const key = "tc_e2e_readiness_api_key_not_for_auth"; const apiKey = await db.apiKey.upsert({ where: { tenantId_alias: { tenantId, alias: E2E_READINESS_API_KEY_ALIAS } }, create: { tenantId, createdByUserId: userId, alias: E2E_READINESS_API_KEY_ALIAS, key, ...deriveLegacyApiKeyHashMetadata(key, undefined, new Date("2026-05-01T00:00:00.000Z")), expires: null, active: true, }, update: { key, ...deriveLegacyApiKeyHashMetadata(key, undefined, new Date("2026-05-01T00:00:00.000Z")), expires: null, active: true, }, }); await db.apiKeyLog.deleteMany({ where: { tenantId, apiKeyId: apiKey.id, endpoint: E2E_READINESS_API_LOG_ENDPOINT, method: E2E_READINESS_API_LOG_METHOD, params: JSON.stringify({ source: "e2e-readiness" }), }, }); await db.apiKeyLog.create({ data: { tenantId, apiKeyId: apiKey.id, ip: "127.0.0.1", endpoint: E2E_READINESS_API_LOG_ENDPOINT, method: E2E_READINESS_API_LOG_METHOD, params: JSON.stringify({ source: "e2e-readiness" }), status: 200, duration: 42, alias: apiKey.alias, tenantName: E2E_READINESS_TENANT_NAME, type: "e2e-readiness", }, }); return apiKey; } async function seedPlugin(db: DbClient, tenantId: string, userId: string, pluginBasePath: string) { const manifest = buildE2eReadinessPluginManifest(); const pluginBundlePath = path.join(pluginBasePath, manifest.id, manifest.version, "settings"); await mkdir(pluginBundlePath, { recursive: true }); await writeFile(path.join(pluginBundlePath, "index.html"), buildE2eReadinessPluginSettingsHtml(), "utf8"); const plugin = await db.plugin.upsert({ where: { tenantId_pluginId: { tenantId, pluginId: manifest.id } }, create: { tenantId, pluginId: manifest.id, version: manifest.version, manifest: manifest as never, wasmSha256: manifest.wasmSha256, signaturePublicKey: manifest.signedBy, enabled: true, }, update: { version: manifest.version, manifest: manifest as never, wasmSha256: manifest.wasmSha256, signaturePublicKey: manifest.signedBy, enabled: true, }, }); await db.tenantPluginCapabilityGrant.upsert({ where: { tenantId_pluginId_capabilityName: { tenantId, pluginId: manifest.id, capabilityName: "log" } }, create: { tenantId, pluginId: manifest.id, capabilityName: "log", grantedByUserId: userId }, update: { scopeJson: null, grantedByUserId: userId, grantedAt: new Date() }, }); await db.tenantPluginCapabilityGrant.deleteMany({ where: { tenantId, pluginId: manifest.id, capabilityName: "audit" }, }); return plugin; } async function seedLifecyclePlugin(db: DbClient, tenantId: string, userId: string) { const manifest = buildE2eReadinessLifecyclePluginManifest(); const plugin = await db.plugin.upsert({ where: { tenantId_pluginId: { tenantId, pluginId: manifest.id } }, create: { tenantId, pluginId: manifest.id, version: manifest.version, manifest: manifest as never, wasmSha256: manifest.wasmSha256, signaturePublicKey: manifest.signedBy, enabled: true, }, update: { version: manifest.version, manifest: manifest as never, wasmSha256: manifest.wasmSha256, signaturePublicKey: manifest.signedBy, enabled: true, }, }); // Fully provision the lifecycle plugin (capabilities AND hook consents) so it has // no missing capabilities. A disabled plugin with any missing capability hides its // enable form (review-before-enable), which would trap the deactivate→re-enable // lifecycle tests with "lifecycle controls unavailable". const lifecycleCapabilityNames = [...manifest.capabilities.map((capability) => capability.name), ...(manifest.hooks ?? []).map((hook) => `hook:${hook.name}`)]; for (const capabilityName of lifecycleCapabilityNames) { await db.tenantPluginCapabilityGrant.upsert({ where: { tenantId_pluginId_capabilityName: { tenantId, pluginId: manifest.id, capabilityName } }, create: { tenantId, pluginId: manifest.id, capabilityName, grantedByUserId: userId }, update: { scopeJson: null, grantedByUserId: userId, grantedAt: new Date() }, }); } return plugin; } async function seedPortal(db: DbClient, tenantId: string, userId: string) { // The portals feature ships gated off (PORTALS_ENABLED) with its routes always // mounted; this fixture lets the readiness lane exercise the tenant portal // management surfaces (analytics + settings) once the env opt-in is set. A // minimal published portal is enough — the pages render their empty-state / // form without any Stripe/analytics data. await db.portal.upsert({ where: { subdomain: E2E_READINESS_PORTAL_SUBDOMAIN }, create: { tenantId, createdByUserId: userId, subdomain: E2E_READINESS_PORTAL_SUBDOMAIN, title: E2E_READINESS_PORTAL_TITLE, isPublished: true, }, update: { tenantId, createdByUserId: userId, title: E2E_READINESS_PORTAL_TITLE, isPublished: true, }, }); } async function seedWorkflowFromDefinition(db: DbClient, tenantId: string, userId: string, definition: E2eWorkflowDefinition) { await db.workflow.deleteMany({ where: { tenantId, OR: [{ id: definition.id }, { name: definition.name }] } }); const workflow = await db.workflow.create({ data: { id: definition.id, tenantId, createdByUserId: userId, name: definition.name, description: definition.description, status: "live", appliesToAllTenants: false, }, }); const createdBlocks = new Map(); for (const block of definition.blocks) { const createdBlock = await db.workflowBlock.create({ data: { workflowId: workflow.id, type: block.type, description: block.description, isTrigger: block.isTrigger, isBlock: block.isBlock, input: JSON.stringify(block.input), }, }); createdBlocks.set(block.key, createdBlock); } for (const edge of definition.edges) { const fromBlock = createdBlocks.get(edge.from); const toBlock = createdBlocks.get(edge.to); if (!fromBlock || !toBlock) { throw new Error(`Invalid E2E readiness workflow edge: ${edge.from} -> ${edge.to}`); } await db.workflowBlockToBlock.create({ data: { fromBlockId: fromBlock.id, toBlockId: toBlock.id } }); } await db.workflowInputExample.create({ data: { workflowId: workflow.id, title: definition.inputExample.title, input: JSON.stringify(definition.inputExample.input, null, 2), }, }); return { workflow, blocks: createdBlocks }; } async function seedWorkflow(db: DbClient, tenantId: string, userId: string) { return seedWorkflowFromDefinition(db, tenantId, userId, buildE2eReadinessManualWorkflowDefinition()); } async function seedWebhookWorkflow(db: DbClient, tenantId: string, userId: string) { return seedWorkflowFromDefinition(db, tenantId, userId, buildE2eReadinessWebhookWorkflowDefinition()); } async function seedWaitingWorkflow(db: DbClient, tenantId: string, userId: string) { return seedWorkflowFromDefinition(db, tenantId, userId, buildE2eReadinessWaitingWorkflowDefinition()); } async function seedManualWorkflowExecution(db: DbClient, tenantId: string, userId: string, workflowId: string, logBlockId: string) { const finishedAt = new Date(); const input = JSON.stringify({ message: "ready" }, null, 2); const output = JSON.stringify({ log: "E2E readiness ready" }, null, 2); await db.workflowExecution.deleteMany({ where: { id: E2E_READINESS_WORKFLOW_EXECUTION_ID } }); await db.workflowExecution.create({ data: { id: E2E_READINESS_WORKFLOW_EXECUTION_ID, workflowId, tenantId, type: "manual", status: "success", input, output, duration: 42, endedAt: finishedAt, createdByUserId: userId, appliesToAllTenants: false, blockRuns: { create: { id: E2E_READINESS_WORKFLOW_LOG_BLOCK_EXECUTION_ID, workflowBlockId: logBlockId, status: "success", startedAt: finishedAt, endedAt: finishedAt, duration: 42, input, output, }, }, }, }); } async function seedWaitingWorkflowExecution(db: DbClient, tenantId: string, userId: string, workflowId: string, manualBlockId: string, waitBlockId: string) { const startedAt = new Date(); const input = JSON.stringify({ input: { approved: false } }, null, 2); const output = JSON.stringify({ waitForInput: { input: { approved: false } } }, null, 2); await db.workflowExecution.deleteMany({ where: { id: E2E_READINESS_WAITING_EXECUTION_ID } }); await db.workflowExecution.create({ data: { id: E2E_READINESS_WAITING_EXECUTION_ID, workflowId, tenantId, type: "manual", status: "waitingBlock", input, output, duration: null, endedAt: null, waitingBlockId: waitBlockId, createdByUserId: userId, appliesToAllTenants: false, blockRuns: { create: { id: E2E_READINESS_WAITING_MANUAL_BLOCK_EXECUTION_ID, workflowBlockId: manualBlockId, status: "success", startedAt, endedAt: startedAt, duration: 1, input, output: JSON.stringify({ started: true }, null, 2), }, }, }, }); } function cloneJson(value: unknown) { return JSON.parse(JSON.stringify(value)); } async function seedEmailSequences(db: DbClient, tenantId: string) { for (const sequence of buildE2eReadinessEmailSequenceDefinitions(tenantId)) { await db.emailSequence.upsert({ where: { id: sequence.id }, create: { id: sequence.id, tenantId: sequence.tenantId, name: sequence.name, description: sequence.description, triggerEvent: sequence.triggerEvent, stepsJson: cloneJson(sequence.steps), conditionsJson: sequence.conditions, isEnabled: sequence.isEnabled, }, update: { tenantId: sequence.tenantId, name: sequence.name, description: sequence.description, triggerEvent: sequence.triggerEvent, stepsJson: cloneJson(sequence.steps), conditionsJson: sequence.conditions, isEnabled: sequence.isEnabled, }, }); } } export async function seedE2eReadiness(env: EnvRecord = process.env) { assertCanRunE2eReadinessSeed(env); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const pluginBasePath = path.resolve(env.PLUGIN_BINARY_BASE_PATH ?? path.join(repoRoot, ".cache/e2e-readiness/plugins")); const db = getDbClient(); try { const { user, tenant } = await seedUserAndTenant(db); await pruneLeakedE2eAccounts(db, user.id, tenant.id); await seedRoles(db, user.id, tenant.id); await seedFeatureFlags(db); await seedNoTenantUser(db); await seedAdminUser(db); await seedPluginReadonlyUser(db, tenant.id); await seedSubscription(db, tenant.id); await seedEmailSequences(db, tenant.id); await seedApiReadiness(db, tenant.id, user.id); await seedPlugin(db, tenant.id, user.id, pluginBasePath); await seedLifecyclePlugin(db, tenant.id, user.id); await seedPortal(db, tenant.id, user.id); const manualWorkflow = await seedWorkflow(db, tenant.id, user.id); const webhookWorkflow = await seedWebhookWorkflow(db, tenant.id, user.id); const waitingWorkflow = await seedWaitingWorkflow(db, tenant.id, user.id); const logBlock = manualWorkflow.blocks.get("log"); if (!logBlock) { throw new Error("E2E readiness manual workflow log block was not created."); } await seedManualWorkflowExecution(db, tenant.id, user.id, manualWorkflow.workflow.id, logBlock.id); const waitingManualBlock = waitingWorkflow.blocks.get("manual"); const waitingWaitBlock = waitingWorkflow.blocks.get("wait"); if (!waitingManualBlock || !waitingWaitBlock) { throw new Error("E2E readiness waiting workflow blocks were not created."); } await seedWaitingWorkflowExecution(db, tenant.id, user.id, waitingWorkflow.workflow.id, waitingManualBlock.id, waitingWaitBlock.id); const values = buildE2eReadinessEnv({ tenantSlug: tenant.slug, workflowId: manualWorkflow.workflow.id, webhookWorkflowId: webhookWorkflow.workflow.id, waitingWorkflowId: waitingWorkflow.workflow.id, waitingExecutionId: E2E_READINESS_WAITING_EXECUTION_ID, pluginBasePath, }); const envPath = path.join(repoRoot, ".env.e2e-readiness"); // Writing the local .env.e2e-readiness convenience file is best-effort: when // the seed runs in-cluster (e.g. a Coolify post-deploy command against a // production-like validation DB) the working directory is read-only, but the // DB fixtures have already been committed at this point. A failed env-file // write must not fail the seed; the fixture values are deterministic anyway. let envFileWritten = true; try { await writeFile(envPath, renderE2eReadinessEnvFile(values), "utf8"); } catch (error) { envFileWritten = false; process.stderr.write(`Skipped writing ${envPath}: ${error instanceof Error ? error.message : String(error)}\n`); } return { envPath, values, envFileWritten }; } finally { await db.$disconnect(); } } const isDirectExecution = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isDirectExecution) { seedE2eReadiness() .then(({ envPath, values }) => { process.stdout.write(`E2E readiness fixtures seeded.\n`); process.stdout.write(`Env file: ${envPath}\n`); process.stdout.write(renderE2eReadinessEnvFile(values)); }) .catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); }