diff --git a/.env.example b/.env.example index 4961850..9ec96b5 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ LOOPWORKS_ALLOWED_GITHUB_ORGS="" LOOPWORKS_AGENT_READY_LOOP_ENABLED="true" LOOPWORKS_DEVELOPMENT_LOOP_ENABLED="true" LOOPWORKS_RESEARCH_LOOP_ENABLED="true" +LOOPWORKS_PORTAL_DATA_MODE="" LOG_LEVEL="info" DATABASE_URL="postgres://loopworks:loopworks@127.0.0.1:5432/loopworks" OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31a391a..4d9b923 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,39 @@ jobs: run: bun run test:e2e env: LOOPWORKS_AUTH_BYPASS: "true" + + seeded-postgres-e2e: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: loopworks_e2e + POSTGRES_PASSWORD: loopworks + POSTGRES_USER: loopworks + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U loopworks -d loopworks_e2e" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Seeded Postgres Playwright + run: bun run test:e2e:seeded + env: + DATABASE_URL: postgres://loopworks:loopworks@127.0.0.1:5432/loopworks_e2e diff --git a/README.md b/README.md index b825bdd..eae8f2a 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Copy `.env.example` to `.env.local` for local development. The fixture server on - `LOOPWORKS_AGENT_READY_LOOP_ENABLED` - `LOOPWORKS_DEVELOPMENT_LOOP_ENABLED` - `LOOPWORKS_RESEARCH_LOOP_ENABLED` +- `LOOPWORKS_PORTAL_DATA_MODE` - `LOG_LEVEL` - `DATABASE_URL` - `OTEL_EXPORTER_OTLP_PROTOCOL` @@ -87,6 +88,27 @@ bun run storybook:build bun run test:e2e ``` +`bun run test:e2e` owns a fresh development server with explicit non-production +fixture mode. It deterministically verifies the `Fixture fallback` path and +does not attach to an existing server. `LOOPWORKS_PORTAL_DATA_MODE=fixtures` +is ignored in production, where database failures continue to fail closed. + +The seeded Postgres browser lane is separate. It requires a local +`loopworks_e2e` database owned by the `loopworks` role: + +```bash +createdb --host 127.0.0.1 --username loopworks loopworks_e2e +DATABASE_URL="postgres://loopworks:loopworks@127.0.0.1:5432/loopworks_e2e" \ + bun run test:e2e:seeded +``` + +The seeded command refuses production runtimes, non-Postgres URLs, +non-loopback hosts, and database names other than `loopworks_e2e` before it +runs migrations. It then runs migrations, resets only the fixed-id demo rows, +and requires `Live database` browser evidence. Migration or seed failures name +the failed stage; confirm Postgres is running and that the local role and +database exist. + The aggregate command is: ```bash @@ -120,7 +142,10 @@ The pre-commit hook runs `bun run precommit`, which mirrors CI validation: Biome ## Database Seed Data -After the database bootstrap (`bun run db:migrate`), seed a demo dataset covering repos, loops, runs, run steps, artifacts, approvals, and Vercel deployment states in every status: +After the database bootstrap (`bun run db:migrate`), seed a demo dataset +covering repos, loops, runs, run steps, artifacts, approvals, and Vercel +deployment states in every status. `DATABASE_URL` must explicitly identify a +local Postgres database: ```bash bun run db:seed @@ -134,4 +159,10 @@ bun run db:seed:reset Reset only deletes the exact rows this script owns, not the whole table, so any other data in those tables is left untouched. -Add `-- --dry-run` to either command to print the planned row counts without writing. Per [ADR 0007](docs/adr/0007-explicit-seed-data-and-fixture-policy.md), both commands refuse to run when `NODE_ENV` or `VERCEL_ENV` is `production`, or when `DATABASE_URL` does not point at a loopback host (`localhost`/`127.0.0.1`/`::1`) — Loopworks demo data must never write into a database that isn't obviously local. +Add `-- --dry-run` to either command to print the planned row counts without +writing. Per +[ADR 0007](docs/adr/0007-explicit-seed-data-and-fixture-policy.md), both +commands refuse to run when `DATABASE_URL` is missing or malformed, when +`NODE_ENV` or `VERCEL_ENV` is `production`, or when the URL is not Postgres on +a loopback host (`localhost`/`127.0.0.1`/`::1`) — Loopworks demo data must never +write into a database that isn't explicitly and obviously local. diff --git a/docs/adr/0007-explicit-seed-data-and-fixture-policy.md b/docs/adr/0007-explicit-seed-data-and-fixture-policy.md index c1faa86..6bb3019 100644 --- a/docs/adr/0007-explicit-seed-data-and-fixture-policy.md +++ b/docs/adr/0007-explicit-seed-data-and-fixture-policy.md @@ -26,13 +26,19 @@ Seed data must never contain real tokens, private keys, customer data, or secret 3. Logs include fallback reasons without logging secrets or raw payloads. 4. Playwright and Storybook exercise fixture data intentionally. 5. Production environments reject unsafe local auth bypasses and unsupported in-memory stores. -6. `bun run db:seed` / `bun run db:seed:reset` refuse to run when either the - runtime looks like production (`NODE_ENV`/`VERCEL_ENV`) or `DATABASE_URL` - does not point at a loopback host, before touching the database. Portal +6. `bun run db:seed` / `bun run db:seed:reset` require an explicit Postgres + `DATABASE_URL` and refuse to run when either the runtime looks like + production (`NODE_ENV`/`VERCEL_ENV`) or the URL does not point at a loopback + host, before touching the database. Portal pages that are still fixture-only render an explicit degraded notice, and log a structured warning identifying the fixture-gate reason, instead of fixture data in production. See `tests/unit/scripts/seed-demo-data.test.ts`, `tests/unit/lib/runtime.test.ts`, and `tests/unit/portal/fixture-gated-page.test.tsx`. +7. Database-mutating test orchestration validates its target before migrations + as well as before seeding. The seeded Playwright lane requires the dedicated + loopback `loopworks_e2e` database; the normal Playwright lane enables an + explicit non-production fixture mode and proves fixture fallback. Production + ignores that mode and continues to fail closed. ## Follow-Ups @@ -50,8 +56,8 @@ Seed data must never contain real tokens, private keys, customer data, or secret Verified by `tests/unit/seed/demo-data.test.ts`. 3. **Done.** Tests that fixture fallback cannot run in production: `scripts/seed-demo-data.ts` checks `isProductionRuntime()` **and** that - `DATABASE_URL` resolves to a loopback host, before any database dependency - is touched, and refuses to seed if either check fails + `DATABASE_URL` is explicit Postgres and resolves to a loopback host, before + any database dependency is touched, and refuses to seed if either check fails (`tests/unit/scripts/seed-demo-data.test.ts`). The `DATABASE_URL` check exists because a runtime-label check alone is not sufficient: an operator's local shell can have `DATABASE_URL` pointed at a real Postgres @@ -80,3 +86,9 @@ Seed data must never contain real tokens, private keys, customer data, or secret `src/components/ui/status-badge.tsx` if it introduces a new status value. - Add or update the relevant Storybook story so the state is reviewable in isolation, per `src/components/AGENTS.md`. +5. **Done.** Seeded Postgres Playwright coverage uses a separate command and CI + job. Its preparation runner validates the dedicated `loopworks_e2e` target + before migrations, then migrates, resets the fixed-id demo data, and runs + browser assertions that require live database labels and representative + rows across dashboard, catalog, loops, approvals, and settings. The fixture + Playwright command remains separate and deterministic. diff --git a/package.json b/package.json index c957fd3..fb6f07a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "test:e2e:seeded": "bun run scripts/test-seeded-postgres.ts", "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", "review:ui": "bun run scripts/review-ui.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index ab55f2a..a563212 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,8 +14,11 @@ export default defineConfig({ webServer: { command: "bun run dev:fixture", url: "http://127.0.0.1:3000", - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 120_000, + env: { + LOOPWORKS_PORTAL_DATA_MODE: "fixtures", + }, }, projects: [ { diff --git a/playwright.seeded.config.ts b/playwright.seeded.config.ts new file mode 100644 index 0000000..3e318d1 --- /dev/null +++ b/playwright.seeded.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://127.0.0.1:3100", + trace: "on-first-retry", + }, + webServer: { + command: "bun run dev:fixture -- -p 3100", + url: "http://127.0.0.1:3100", + reuseExistingServer: false, + timeout: 120_000, + env: { + DATABASE_URL: process.env.DATABASE_URL ?? "", + LOOPWORKS_PORTAL_DATA_MODE: "", + }, + }, + projects: [ + { + name: "seeded-postgres", + use: { ...devices["Desktop Chrome"] }, + testMatch: /seeded-postgres\.spec\.ts/, + }, + ], +}); diff --git a/scripts/local-database-safety.ts b/scripts/local-database-safety.ts new file mode 100644 index 0000000..58e77d2 --- /dev/null +++ b/scripts/local-database-safety.ts @@ -0,0 +1,70 @@ +import { isIP } from "node:net"; + +import { isProductionRuntime } from "@/lib/runtime"; + +export type LocalDatabaseSafetyOptions = { + requiredDatabaseName?: string; + requireExplicitUrl?: boolean; +}; + +function isLoopbackHostname(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname === "::1" || + hostname === "[::1]" || + (isIP(hostname) === 4 && hostname.split(".")[0] === "127") + ); +} + +/** + * Returns a sanitized reason when a database target is unsafe for local + * mutation. The URL itself is never included because it may contain credentials. + */ +export function getLocalDatabaseSafetyError( + env: Partial, + options: LocalDatabaseSafetyOptions = {}, +): string | null { + if (isProductionRuntime(env)) { + return ( + "Refusing local database mutation: this looks like a production environment " + + "(NODE_ENV or VERCEL_ENV is 'production'), per ADR 0007." + ); + } + + const value = env.DATABASE_URL; + if (!value) { + return options.requireExplicitUrl + ? "Refusing local database mutation: DATABASE_URL must explicitly identify a local Postgres database." + : null; + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return "Refusing local database mutation: DATABASE_URL is not a valid URL."; + } + + if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { + return "Refusing local database mutation: DATABASE_URL must use the postgres or postgresql scheme."; + } + + if (!isLoopbackHostname(parsed.hostname)) { + return ( + "Refusing local database mutation: DATABASE_URL must point at a loopback host " + + "(localhost/127.0.0.1/::1), per ADR 0007." + ); + } + + if (options.requiredDatabaseName) { + const databaseName = parsed.pathname.replace(/^\//, ""); + if (databaseName.includes("%")) { + return "Refusing local database mutation: the database name must not use percent-encoding."; + } + if (databaseName !== options.requiredDatabaseName) { + return `Refusing local database mutation: this command requires the dedicated ${options.requiredDatabaseName} database.`; + } + } + + return null; +} diff --git a/scripts/seed-demo-data.ts b/scripts/seed-demo-data.ts index 12aa2ca..b57b8fb 100644 --- a/scripts/seed-demo-data.ts +++ b/scripts/seed-demo-data.ts @@ -1,39 +1,13 @@ #!/usr/bin/env bun -import { isIP } from "node:net"; - import { db } from "@/db/client"; -import { isProductionRuntime } from "@/lib/runtime"; import { buildDemoSeedData, type SeedCounts, type SeedDatabase, seedDemoData, } from "@/lib/seed/demo-data"; - -const defaultLocalDatabaseUrl = "postgres://loopworks:loopworks@127.0.0.1:5432/loopworks"; - -/** - * Mirrors `isLoopbackWebhookUrl` in `scripts/github-webhook-fixture.ts`. The - * production-runtime check alone is not enough: an operator's shell can have - * `DATABASE_URL` pointed at a real (non-production-labeled) Postgres host - * while `NODE_ENV`/`VERCEL_ENV` are unset. Seeding must refuse that too. - */ -function isLoopbackDatabaseUrl(value: string): boolean { - let parsed: URL; - try { - parsed = new URL(value); - } catch { - return false; - } - - return ( - parsed.hostname === "localhost" || - parsed.hostname === "::1" || - parsed.hostname === "[::1]" || - (isIP(parsed.hostname) === 4 && parsed.hostname.split(".")[0] === "127") - ); -} +import { getLocalDatabaseSafetyError } from "./local-database-safety"; export type RunSeedCliDependencies = { database: SeedDatabase; @@ -97,22 +71,9 @@ export async function runSeedCli( return 1; } - if (isProductionRuntime(env)) { - console.error( - "Refusing to seed demo data: this looks like a production environment " + - "(NODE_ENV or VERCEL_ENV is 'production'). Loopworks demo seed data must " + - "never run against production, per ADR 0007.", - ); - return 1; - } - - if (!isLoopbackDatabaseUrl(env.DATABASE_URL ?? defaultLocalDatabaseUrl)) { - console.error( - "Refusing to seed demo data: DATABASE_URL does not point at a loopback " + - "host (localhost/127.0.0.1/::1). Loopworks demo seed data must only run " + - "against a local Postgres instance, per ADR 0007 - a production-labeled " + - "runtime is not the only way this could write into a real database.", - ); + const safetyError = getLocalDatabaseSafetyError(env, { requireExplicitUrl: true }); + if (safetyError) { + console.error(safetyError); return 1; } diff --git a/scripts/test-seeded-postgres.ts b/scripts/test-seeded-postgres.ts new file mode 100644 index 0000000..ed3284e --- /dev/null +++ b/scripts/test-seeded-postgres.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env bun + +import { getLocalDatabaseSafetyError } from "./local-database-safety"; + +type SeededPostgresCommand = readonly string[]; + +export type SeededPostgresE2eDependencies = { + env?: Partial; + error?: (message: string) => void; + runCommand?: ( + command: SeededPostgresCommand, + env: Partial, + ) => Promise | number; +}; + +async function runCommand( + command: SeededPostgresCommand, + env: Partial, +): Promise { + const child = Bun.spawn([...command], { + cwd: process.cwd(), + env: { ...process.env, ...env }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + + return await child.exited; +} + +const stages = [ + { + command: ["bun", "run", "db:migrate"], + failure: + "Seeded Postgres migration stage failed. Confirm local Postgres is running and the loopworks role and loopworks_e2e database exist.", + }, + { + command: ["bun", "run", "db:seed:reset"], + failure: + "Seeded Postgres seed stage failed. Confirm migrations completed and the local role can write to loopworks_e2e.", + }, + { + command: ["bunx", "playwright", "test", "--config=playwright.seeded.config.ts"], + failure: "Seeded Postgres Playwright stage failed. Review the browser assertions above.", + }, +] as const; + +export async function runSeededPostgresE2e( + dependencies: SeededPostgresE2eDependencies = {}, +): Promise { + const env = dependencies.env ?? process.env; + const reportError = dependencies.error ?? console.error; + const execute = dependencies.runCommand ?? runCommand; + const safetyError = getLocalDatabaseSafetyError(env, { + requiredDatabaseName: "loopworks_e2e", + requireExplicitUrl: true, + }); + + if (safetyError) { + reportError(safetyError); + return 1; + } + + for (const stage of stages) { + const exitCode = await execute(stage.command, env); + if (exitCode !== 0) { + reportError(stage.failure); + return 1; + } + } + + return 0; +} + +if (import.meta.main) { + process.exitCode = await runSeededPostgresE2e(); +} diff --git a/src/components/portal/dashboard-view.tsx b/src/components/portal/dashboard-view.tsx index 9e47b94..f4acfcc 100644 --- a/src/components/portal/dashboard-view.tsx +++ b/src/components/portal/dashboard-view.tsx @@ -291,7 +291,7 @@ export function DashboardView({
- +
Workflow lens diff --git a/src/lib/portal/records.ts b/src/lib/portal/records.ts index e63a4f3..ea9cee2 100644 --- a/src/lib/portal/records.ts +++ b/src/lib/portal/records.ts @@ -466,13 +466,28 @@ export async function getPortalRecordsForPortal(input: { logger?: LoopworksLogger; now?: Date; }): Promise { + const env = input.env ?? process.env; + if (!isProductionRuntime(env) && env.LOOPWORKS_PORTAL_DATA_MODE === "fixtures") { + input.logger?.warn( + { fallbackReason: "explicit_fixture_mode" }, + "portal_records_fixture_mode_enabled", + ); + + return { + fallbackReason: "explicit_fixture_mode", + records: fixturePortalRecords(), + source: "fixtures", + usedFallback: true, + }; + } + try { const result = await readPortalRecords({ database: input.database, now: input.now, }); - if (isProductionRuntime(input.env) && !hasRequiredPortalData(result.records)) { + if (isProductionRuntime(env) && !hasRequiredPortalData(result.records)) { input.logger?.warn( { approvalCount: result.records.approval ? 1 : 0, @@ -496,7 +511,7 @@ export async function getPortalRecordsForPortal(input: { "portal_records_read_failed", ); - if (isProductionRuntime(input.env)) { + if (isProductionRuntime(env)) { return unavailableResult(); } diff --git a/src/lib/runs/run-record.ts b/src/lib/runs/run-record.ts index 5fa3fed..835dd14 100644 --- a/src/lib/runs/run-record.ts +++ b/src/lib/runs/run-record.ts @@ -474,6 +474,21 @@ export async function getRunRecordsForPortal(input: { logger?: LoopworksLogger; now?: Date; }): Promise { + const env = input.env ?? process.env; + if (!isProductionRuntime(env) && env.LOOPWORKS_PORTAL_DATA_MODE === "fixtures") { + input.logger?.warn( + { fallbackReason: "explicit_fixture_mode" }, + "run_records_fixture_mode_enabled", + ); + + return { + fallbackReason: "explicit_fixture_mode", + runs: input.fixtureRuns, + source: "fixtures", + usedFallback: true, + }; + } + try { return await readRunRecords({ database: input.database, @@ -487,7 +502,7 @@ export async function getRunRecordsForPortal(input: { "run_records_read_failed", ); - if (isProductionRuntime(input.env)) { + if (isProductionRuntime(env)) { return { error: "Run data store unavailable.", runs: [], diff --git a/tests/e2e/portal.spec.ts b/tests/e2e/portal.spec.ts index 55202a3..5915223 100644 --- a/tests/e2e/portal.spec.ts +++ b/tests/e2e/portal.spec.ts @@ -23,7 +23,7 @@ const portalRoutes = [ }, ] as const; -const portalSourceLabel = /Live database|Fixture fallback/; +const portalSourceLabel = "Fixture fallback"; const dbBackedPortalPaths = ["/", "/catalog", "/loops", "/approvals", "/settings"] as const; test.describe("Loopworks portal", () => { @@ -72,7 +72,7 @@ test.describe("Loopworks portal", () => { test("shows explicit portal data source labels on database-backed pages", async ({ page }) => { for (const path of dbBackedPortalPaths) { await page.goto(path); - await expect(page.getByText(portalSourceLabel).first()).toBeVisible(); + await expect(page.getByText(portalSourceLabel, { exact: true }).first()).toBeVisible(); } }); @@ -341,7 +341,7 @@ test.describe("Loopworks portal", () => { for (const path of dbBackedPortalPaths) { await page.goto(path); - await expect(page.getByText(portalSourceLabel).first()).toBeVisible(); + await expect(page.getByText(portalSourceLabel, { exact: true }).first()).toBeVisible(); const viewportWidth = await page.evaluate(() => document.documentElement.clientWidth); const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); diff --git a/tests/e2e/seeded-postgres.spec.ts b/tests/e2e/seeded-postgres.spec.ts new file mode 100644 index 0000000..ec70824 --- /dev/null +++ b/tests/e2e/seeded-postgres.spec.ts @@ -0,0 +1,64 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +const dbBackedPortalPaths = ["/", "/catalog", "/loops", "/approvals", "/settings"] as const; + +test.describe("seeded Postgres portal", () => { + test("renders representative seeded records from the live database", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Live database", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("ncolesummers/loopworks-web").first()).toBeVisible(); + + await page.goto("/catalog"); + await expect(page.getByText("Live database", { exact: true }).first()).toBeVisible(); + const loopworksRow = page.getByRole("row", { name: /ncolesummers\/loopworks-web/ }); + await expect(loopworksRow).toBeVisible(); + await expect(loopworksRow.getByText("prj_demo_loopworks_web")).toBeVisible(); + + await page.goto("/loops"); + await expect(page.getByText("Live database", { exact: true }).first()).toBeVisible(); + await expect(page.getByRole("switch", { name: "Intake new repo requests" })).toBeChecked(); + + await page.goto("/approvals"); + await expect(page.getByText("Live database", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Owner morgan-dev")).toBeVisible(); + await expect(page.getByText("Scope deploy-preview")).toBeVisible(); + + await page.goto("/settings"); + await expect(page.getByText("Live database", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("GitHub app connected")).toBeVisible(); + await page.getByRole("tab", { name: "Scoping" }).click(); + await expect(page.getByText("8 synced issue loops are visible.")).toBeVisible(); + }); + + test("keeps every database-backed page inside the mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + + for (const path of dbBackedPortalPaths) { + await page.goto(path); + await expect(page.getByText("Live database", { exact: true }).first()).toBeVisible(); + + const viewportWidth = await page.evaluate(() => document.documentElement.clientWidth); + const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); + + expect(scrollWidth, `${path} should not create horizontal page overflow`).toBeLessThanOrEqual( + viewportWidth + 1, + ); + } + }); + + for (const colorScheme of ["light", "dark"] as const) { + test.describe(`color scheme: ${colorScheme}`, () => { + test.use({ colorScheme }); + + test("has no a11y violations on database-backed pages", async ({ page }) => { + for (const path of dbBackedPortalPaths) { + await page.goto(path); + await expect(page.getByText("Live database", { exact: true }).first()).toBeVisible(); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations, `${colorScheme} ${path}`).toEqual([]); + } + }); + }); + } +}); diff --git a/tests/unit/portal/portal-records.test.ts b/tests/unit/portal/portal-records.test.ts index 074664f..0095d9c 100644 --- a/tests/unit/portal/portal-records.test.ts +++ b/tests/unit/portal/portal-records.test.ts @@ -136,6 +136,59 @@ describe("portal records (pglite integration)", () => { expect(result.records.loops).toEqual(portalFixture.loops); }); + it("uses explicit non-production fixture mode without reading the database", async () => { + const database = { + select: vi.fn(() => { + throw new Error("database should not be read"); + }), + }; + const logger = { + warn: vi.fn(), + }; + + const result = await getPortalRecordsForPortal({ + database: database as never, + env: { + LOOPWORKS_PORTAL_DATA_MODE: "fixtures", + NODE_ENV: "development", + }, + logger: logger as never, + }); + + expect(database.select).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + fallbackReason: "explicit_fixture_mode", + source: "fixtures", + usedFallback: true, + }); + expect(logger.warn).toHaveBeenCalledWith( + { fallbackReason: "explicit_fixture_mode" }, + "portal_records_fixture_mode_enabled", + ); + }); + + it("never honors explicit fixture mode in production", async () => { + const unavailableDatabase = { + select: vi.fn(() => { + throw new Error("database unavailable"); + }), + }; + + const result = await getPortalRecordsForPortal({ + database: unavailableDatabase as never, + env: { + LOOPWORKS_PORTAL_DATA_MODE: "fixtures", + NODE_ENV: "production", + }, + }); + + expect(unavailableDatabase.select).toHaveBeenCalled(); + expect(result).toMatchObject({ + source: "unavailable", + usedFallback: false, + }); + }); + it("never returns fixtures for unavailable production database reads", async () => { const unavailableDatabase = { select() { diff --git a/tests/unit/runs/run-record.test.ts b/tests/unit/runs/run-record.test.ts index cf371d4..a90f4cf 100644 --- a/tests/unit/runs/run-record.test.ts +++ b/tests/unit/runs/run-record.test.ts @@ -6,7 +6,11 @@ import { createValidationReportArtifactMetadata, type ValidationReportV1, } from "@/lib/loops/validation-report"; -import { readRunRecords, getRunRecordsForResult } from "@/lib/runs/run-record"; +import { + getRunRecordsForPortal, + getRunRecordsForResult, + readRunRecords, +} from "@/lib/runs/run-record"; import { buildRunFixtureRecords } from "@/lib/runs/fixtures"; import { demoSeedIds, seedDemoData, type SeedDatabase } from "@/lib/seed/demo-data"; @@ -372,4 +376,54 @@ describe("run records (pglite integration)", () => { ), ).toEqual([]); }); + + it("uses explicit non-production fixture mode without reading run records", async () => { + const fixtureRuns = buildRunFixtureRecords(); + const database = { + select: vi.fn(() => { + throw new Error("database should not be read"); + }), + }; + + const result = await getRunRecordsForPortal({ + database: database as never, + env: { + LOOPWORKS_PORTAL_DATA_MODE: "fixtures", + NODE_ENV: "development", + }, + fixtureRuns, + }); + + expect(database.select).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + fallbackReason: "explicit_fixture_mode", + runs: fixtureRuns, + source: "fixtures", + usedFallback: true, + }); + }); + + it("never honors explicit run fixture mode in production", async () => { + const fixtureRuns = buildRunFixtureRecords(); + const database = { + select: vi.fn(() => { + throw new Error("database unavailable"); + }), + }; + + const result = await getRunRecordsForPortal({ + database: database as never, + env: { + LOOPWORKS_PORTAL_DATA_MODE: "fixtures", + NODE_ENV: "production", + }, + fixtureRuns, + }); + + expect(database.select).toHaveBeenCalled(); + expect(result).toMatchObject({ + source: "unavailable", + usedFallback: false, + }); + }); }); diff --git a/tests/unit/scripts/seed-demo-data.test.ts b/tests/unit/scripts/seed-demo-data.test.ts index b7c9c16..fbec47c 100644 --- a/tests/unit/scripts/seed-demo-data.test.ts +++ b/tests/unit/scripts/seed-demo-data.test.ts @@ -124,13 +124,34 @@ describe("seed-demo-data CLI", () => { } }); + it("requires an explicit DATABASE_URL before seeding", async () => { + const seedSpy = vi.fn(async () => emptyCounts()); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runSeedCli( + [], + { NODE_ENV: "development" }, + { + seedDemoData: seedSpy, + database: fakeDatabase, + }, + ); + + expect(exitCode).toBe(1); + expect(seedSpy).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.flat().join(" ")).not.toContain("DATABASE_URL="); + }); + it("seeds successfully in a non-production environment", async () => { const seedSpy = vi.fn(async () => ({ ...emptyCounts(), repositories: 4 })); vi.spyOn(console, "log").mockImplementation(() => {}); const exitCode = await runSeedCli( [], - { NODE_ENV: "development" }, + { + NODE_ENV: "development", + DATABASE_URL: "postgres://loopworks:loopworks@127.0.0.1:5432/loopworks", + }, { seedDemoData: seedSpy, database: fakeDatabase, @@ -147,7 +168,10 @@ describe("seed-demo-data CLI", () => { const exitCode = await runSeedCli( ["--reset"], - { NODE_ENV: "development" }, + { + NODE_ENV: "development", + DATABASE_URL: "postgres://loopworks:loopworks@127.0.0.1:5432/loopworks", + }, { seedDemoData: seedSpy, database: fakeDatabase, @@ -164,7 +188,10 @@ describe("seed-demo-data CLI", () => { const exitCode = await runSeedCli( ["--dry-run"], - { NODE_ENV: "development" }, + { + NODE_ENV: "development", + DATABASE_URL: "postgres://loopworks:loopworks@127.0.0.1:5432/loopworks", + }, { seedDemoData: seedSpy, database: fakeDatabase, diff --git a/tests/unit/scripts/seeded-postgres-e2e.test.ts b/tests/unit/scripts/seeded-postgres-e2e.test.ts new file mode 100644 index 0000000..b913ca4 --- /dev/null +++ b/tests/unit/scripts/seeded-postgres-e2e.test.ts @@ -0,0 +1,145 @@ +import { readFileSync } from "node:fs"; + +import fallbackPlaywrightConfig from "../../../playwright.config"; +import seededPlaywrightConfig from "../../../playwright.seeded.config"; +import { getLocalDatabaseSafetyError } from "../../../scripts/local-database-safety"; +import { runSeededPostgresE2e } from "../../../scripts/test-seeded-postgres"; + +const seededDatabaseUrl = "postgres://loopworks:loopworks@127.0.0.1:5432/loopworks_e2e"; + +describe("seeded Postgres e2e orchestration", () => { + it("is exposed as a separate package script", () => { + const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { + scripts?: Record; + }; + + expect(packageJson.scripts?.["test:e2e"]).toBe("playwright test"); + expect(packageJson.scripts?.["test:e2e:seeded"]).toBe( + "bun run scripts/test-seeded-postgres.ts", + ); + }); + + it("runs migration, reset seed, and seeded Playwright in order", async () => { + const commands: string[][] = []; + + const exitCode = await runSeededPostgresE2e({ + env: { DATABASE_URL: seededDatabaseUrl, NODE_ENV: "development" }, + runCommand: async (command) => { + commands.push([...command]); + return 0; + }, + }); + + expect(exitCode).toBe(0); + expect(commands).toEqual([ + ["bun", "run", "db:migrate"], + ["bun", "run", "db:seed:reset"], + ["bunx", "playwright", "test", "--config=playwright.seeded.config.ts"], + ]); + }); + + it.each([ + ["missing DATABASE_URL", { NODE_ENV: "development" }], + ["production runtime", { DATABASE_URL: seededDatabaseUrl, NODE_ENV: "production" }], + ["malformed URL", { DATABASE_URL: "not a url", NODE_ENV: "development" }], + ["wrong scheme", { DATABASE_URL: "https://127.0.0.1/loopworks_e2e", NODE_ENV: "development" }], + [ + "non-loopback host", + { + DATABASE_URL: "postgres://admin:hunter2@prod-db.example.com/loopworks_e2e", + NODE_ENV: "development", + }, + ], + [ + "wrong database", + { + DATABASE_URL: "postgres://admin:hunter2@127.0.0.1:5432/loopworks", + NODE_ENV: "development", + }, + ], + [ + "percent-encoded database alias", + { + DATABASE_URL: "postgres://admin:hunter2@127.0.0.1:5432/loopworks%5fe2e", + NODE_ENV: "development", + }, + ], + [ + "malformed percent-encoding", + { + DATABASE_URL: "postgres://admin:hunter2@127.0.0.1:5432/%E0%A4%A", + NODE_ENV: "development", + }, + ], + ] satisfies [ + string, + Partial, + ][])("rejects %s before running commands without leaking credentials", async (_label, env) => { + const runCommand = vi.fn(async () => 0); + const errors: string[] = []; + + const exitCode = await runSeededPostgresE2e({ + env, + error: (message) => errors.push(message), + runCommand, + }); + + expect(exitCode).toBe(1); + expect(runCommand).not.toHaveBeenCalled(); + expect(errors.join(" ")).not.toContain("hunter2"); + expect(errors.join(" ")).not.toContain("admin:"); + }); + + it.each([ + ["migration", [1], 1], + ["seed", [0, 1], 2], + ["Playwright", [0, 0, 1], 3], + ])("stops after a %s stage failure", async (stage, results, expectedCalls) => { + const errors: string[] = []; + const runCommand = vi.fn(async () => results[runCommand.mock.calls.length - 1] ?? 0); + + const exitCode = await runSeededPostgresE2e({ + env: { DATABASE_URL: seededDatabaseUrl, NODE_ENV: "development" }, + error: (message) => errors.push(message), + runCommand, + }); + + expect(exitCode).toBe(1); + expect(runCommand).toHaveBeenCalledTimes(expectedCalls); + expect(errors.join(" ")).toContain(stage); + expect(errors.join(" ")).not.toContain(seededDatabaseUrl); + }); + + it("keeps the generic seed guard local without requiring the e2e database name", () => { + expect( + getLocalDatabaseSafetyError( + { + DATABASE_URL: "postgres://loopworks:loopworks@localhost:5432/loopworks", + NODE_ENV: "development", + }, + { requireExplicitUrl: true }, + ), + ).toBeNull(); + }); + + it("keeps fallback and seeded Playwright servers isolated", () => { + const fallbackServer = fallbackPlaywrightConfig.webServer; + const seededServer = seededPlaywrightConfig.webServer; + + expect(Array.isArray(fallbackServer)).toBe(false); + expect(Array.isArray(seededServer)).toBe(false); + expect(fallbackServer).toMatchObject({ + reuseExistingServer: false, + env: { + LOOPWORKS_PORTAL_DATA_MODE: "fixtures", + }, + }); + expect(seededServer).toMatchObject({ + reuseExistingServer: false, + env: { + DATABASE_URL: process.env.DATABASE_URL ?? "", + LOOPWORKS_PORTAL_DATA_MODE: "", + }, + }); + }); +});