1286 lines
49 KiB
TypeScript
1286 lines
49 KiB
TypeScript
#!/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<string, string | undefined>;
|
|
|
|
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<string, unknown> };
|
|
};
|
|
|
|
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 [
|
|
'<main data-e2e-plugin-settings-fixture="ready">',
|
|
"<h1>E2E Plugin Settings</h1>",
|
|
'<button type="button" id="e2e-plugin-settings-save">Save</button>',
|
|
"</main>",
|
|
"<script>",
|
|
"(function () {",
|
|
" var pluginId = document.querySelector('meta[name=\"plugin-id\"]')?.getAttribute('content') || '';",
|
|
" var channelId = document.querySelector('meta[name=\"plugin-settings-channel\"]')?.getAttribute('content') || '';",
|
|
" var protocolVersion = Number(document.querySelector('meta[name=\"plugin-settings-protocol\"]')?.getAttribute('content') || '1');",
|
|
" function postReady() {",
|
|
" if (!pluginId || !channelId) return;",
|
|
" parent.postMessage({",
|
|
" type: 'plugin.settings.ready',",
|
|
" version: protocolVersion,",
|
|
" pluginId: pluginId,",
|
|
" channelId: channelId,",
|
|
" requestId: 'seed-fixture-ready',",
|
|
" }, '*');",
|
|
" }",
|
|
" postReady();",
|
|
" document.getElementById('e2e-plugin-settings-save')?.addEventListener('click', function () {",
|
|
" if (!pluginId || !channelId) return;",
|
|
" parent.postMessage({",
|
|
" type: 'plugin.settings.set',",
|
|
" version: protocolVersion,",
|
|
" pluginId: pluginId,",
|
|
" channelId: channelId,",
|
|
" requestId: 'seed-fixture-save',",
|
|
" key: 'theme',",
|
|
" value: 'ready',",
|
|
" }, '*');",
|
|
" });",
|
|
"}());",
|
|
"</script>",
|
|
"",
|
|
].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<string, string> {
|
|
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, string>): 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<string, { id: string }>();
|
|
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;
|
|
});
|
|
}
|