The e2e suite names its own origins, and refuses a live instance
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

`tests/utils/api.ts` took the API origin from `VITE_API_URL`, which
`tests/config.ts` loads out of `app/.env`. In a checkout configured for a
deployment that names the deployment — so the browser went to the local stack
while every setup and teardown call, `deleteAll` included, went to the live
one. `privateApi.ts` had the same reading, and it creates users.

Both origins now come from one place: `PLAYWRIGHT_BASE_URL`, with the API
derived from it (`app.<domain>` → `api.<domain>`) or named outright by
`PLAYWRIGHT_API_URL`, which is what CI and the compose service set. Nothing in
the suite reads `VITE_API_URL` any more.

Belt and braces, since a stack served under a real domain answers to the same
names its production instance does: a global setup resolves both origins and
refuses anything that is not loopback or a private range, before a test runs.
`PLAYWRIGHT_ALLOW_PUBLIC=1` says you meant it.

`make test-frontend` is now that safe run — the Playwright image on the proxy
network with both names mapped onto Traefik by address, as the host user so it
does not leave root-owned results behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
This commit is contained in:
2026-08-20 20:46:04 +02:00
co-authored by Claude Opus 5
parent 032c2e3ae6
commit a2e11b61cf
10 changed files with 142 additions and 12 deletions
+28
View File
@@ -17,3 +17,31 @@ function getEnvVar(name: string): string {
export const firstSuperuser = getEnvVar("FIRST_SUPERUSER")
export const firstSuperuserPassword = getEnvVar("FIRST_SUPERUSER_PASSWORD")
/**
* The two origins a run talks to: the app in the browser, and the API for the
* setup and teardown around a spec.
*
* Deliberately *not* taken from `VITE_API_URL`, even though `../../.env` above
* has one. That variable belongs to the app build, and in a checkout configured
* for a deployment it names the deployment — so a suite reading it would drive
* a browser at the local stack while sending its `DELETE`s to the live
* instance. Both origins come from the same place instead: whatever the browser
* is pointed at is what teardown may write to.
*/
export const appUrl = process.env.PLAYWRIGHT_BASE_URL || "http://app.localhost"
export const apiUrl = process.env.PLAYWRIGHT_API_URL || apiOrigin(appUrl)
/** `app.<domain>` serves the SPA, `api.<domain>` serves its API. */
function apiOrigin(app: string): string {
const url = new URL(app)
if (!url.hostname.startsWith("app.")) {
throw new Error(
`Cannot derive the API origin from PLAYWRIGHT_BASE_URL=${app}. ` +
"Set PLAYWRIGHT_API_URL to name it.",
)
}
url.hostname = `api.${url.hostname.slice("app.".length)}`
return url.origin
}
+1 -2
View File
@@ -1,4 +1,5 @@
import { expect, type Page, test } from "@playwright/test"
import { apiUrl } from "./config.ts"
import { deleteAll } from "./utils/api"
/**
@@ -17,8 +18,6 @@ test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/flows/${flowName}`])
})
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
/** Write a node's source through the API; typing code is not what we test. */
async function setNodeSource(page: Page, nodeId: string, code: string) {
const token = await page.evaluate(() => localStorage.getItem("access_token"))
+60
View File
@@ -0,0 +1,60 @@
import { lookup } from "node:dns/promises"
import { apiUrl, appUrl } from "./config.ts"
/**
* Refuse to run against a machine on the internet.
*
* The suite creates flows, dashboards and users and deletes them again, so the
* cost of pointing it at the wrong instance is somebody's data. That is not a
* hypothetical: a stack served under a real domain answers to the same names
* its production instance does, and the only thing telling them apart is which
* address those names resolve to here.
*
* So that is what is checked. Loopback and the private ranges are a stack on
* this machine or on its docker network; anything else is refused by name and
* address, before a single test runs. `PLAYWRIGHT_ALLOW_PUBLIC=1` is the way
* to say you meant it.
*/
const PRIVATE_V4 = [
/^127\./,
/^10\./,
/^192\.168\./,
/^169\.254\./,
/^172\.(1[6-9]|2\d|3[01])\./,
]
function isPrivate(address: string): boolean {
if (address === "::1" || address === "::") return true
// ::ffff:172.18.0.2 and friends.
const mapped = address.replace(/^::ffff:/i, "")
if (mapped !== address) return isPrivate(mapped)
if (/^f[cd][0-9a-f]{2}:/i.test(address)) return true // unique-local
if (/^fe80:/i.test(address)) return true // link-local
return PRIVATE_V4.some((range) => range.test(address))
}
export default async function guardTheTarget(): Promise<void> {
if (process.env.PLAYWRIGHT_ALLOW_PUBLIC === "1") return
for (const target of new Set([appUrl, apiUrl])) {
const { hostname } = new URL(target)
let address: string
try {
address = (await lookup(hostname)).address
} catch {
// Unresolvable is the run's own problem to report; it cannot be a live
// instance, which is all this guard is here for.
continue
}
if (isPrivate(address)) continue
throw new Error(
`Refusing to run: ${target} resolves to ${address}, which is not this ` +
"machine or its docker network. This suite creates and deletes flows, " +
"dashboards and users.\n" +
"Run it against the local stack — `make -C app test-frontend` maps " +
"the names onto Traefik — or set PLAYWRIGHT_ALLOW_PUBLIC=1 if you " +
"really mean this one.",
)
}
}
+2 -1
View File
@@ -8,8 +8,9 @@ import type { Browser, Page } from "@playwright/test"
* database. What a spec leaves behind is in someone's flow list tomorrow.
*/
import { apiUrl } from "../config.ts"
const authFile = "playwright/.auth/user.json"
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
/** Call the API as the logged-in user of *page*. */
export async function api(
+4 -1
View File
@@ -1,8 +1,11 @@
// Note: the `PrivateService` is only available when generating the client
// for local environments
import { OpenAPI, PrivateService } from "../../src/client"
import { apiUrl } from "../config.ts"
OpenAPI.BASE = `${process.env.VITE_API_URL}`
// The same origin the rest of the suite writes to, for the same reason: this
// one creates users.
OpenAPI.BASE = apiUrl
export const createUser = async ({
email,