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
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:
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import 'dotenv/config'
|
||||
import { appUrl } from './tests/config.ts'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -21,12 +22,22 @@ export default defineConfig({
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: process.env.CI ? 'blob' : 'html',
|
||||
/* Nothing runs until the target is known not to be a live instance. */
|
||||
globalSetup: './tests/guard.ts',
|
||||
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. Defaults to the
|
||||
integrated stack (`make dev`), the only origin the API allows CORS from.
|
||||
Point PLAYWRIGHT_BASE_URL elsewhere to test another running server. */
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://app.localhost',
|
||||
Point PLAYWRIGHT_BASE_URL elsewhere to test another running server; the
|
||||
API origin follows it (see tests/config.ts). */
|
||||
baseURL: appUrl,
|
||||
|
||||
/* Chromium pins *.localhost to loopback whatever /etc/hosts says, so a
|
||||
containerised run maps the names here as well as through --add-host. */
|
||||
launchOptions: process.env.HOST_RESOLVER_RULES
|
||||
? { args: [`--host-resolver-rules=${process.env.HOST_RESOLVER_RULES}`] }
|
||||
: {},
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
@@ -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,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"))
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user