Skip to content

Commit 8001bbd

Browse files
authored
feat(e2e): refuse the shared instance unless the run names it (#714)
* feat(e2e): refuse the shared instance unless the run names it The box runs one Nextcloud on localhost:8080 that bind-mounts everybody's checkout and holds data colleagues are working on. This suite seeds registers, schemas and objects, so a run aimed there edits someone else's environment. No default is not the same as no accident: an explicit PLAYWRIGHT_BASE_URL can name the shared container too. tests/e2e/shared-instance.ts refuses loopback port 80 or 8080 unless the run set LARPINQ_E2E_ALLOW_SHARED_INSTANCE or E2E_ALLOW_SHARED_INSTANCE to that same origin. CI is exempt, where 8080 is the runner's own php -S instance, so the existing CI fallback still works. The guard sits on the resolved value in resolveBaseURL(), the one function playwright.config.ts calls for its baseURL. The unit test lives under tests/vitest/ because the vitest config excludes tests/e2e/** on purpose; there it is actually collected. * style(e2e): format the shared-instance guard the way the format check wants The format leg runs prettier --check and it read both new files as dirty, eslint green or not. They are two separate checks. Both are re-copied from the corrected fleet template, which is prettier-clean at source. tests/e2e/_base-url.ts, the third file this branch touches, already passed. No file this branch does not touch was reformatted. Ten unit cases still pass, with and without CI set, and the resolver still refuses localhost:8080 without the flag.
1 parent 1daddb5 commit 8001bbd

3 files changed

Lines changed: 461 additions & 1 deletion

File tree

‎tests/e2e/_base-url.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,18 @@
4747
* runner's own loopback, and there is no shared instance to corrupt. So the
4848
* fallback is allowed there and NOWHERE else — gated on `CI` /
4949
* `GITHUB_ACTIONS`, not on "the variable happens to be missing".
50+
*
51+
* NAMING THE SHARED INSTANCE
52+
* --------------------------
53+
* Setting a variable TO `http://localhost:8080` off CI is still refused unless
54+
* the run also sets LARPINQ_E2E_ALLOW_SHARED_INSTANCE (or the fleet-wide
55+
* E2E_ALLOW_SHARED_INSTANCE) to that same origin. No default is not the same
56+
* as no accident: an explicit value can name the shared container too. See
57+
* tests/e2e/shared-instance.ts.
5058
*/
5159

60+
import { assertInstancePermitted } from './shared-instance.ts'
61+
5262
/** Environment variables consulted, in precedence order. */
5363
const BASE_URL_VARS = [
5464
'PLAYWRIGHT_BASE_URL',
@@ -79,7 +89,7 @@ export function resolveBaseURL(): string {
7989
for (const name of BASE_URL_VARS) {
8090
const value = process.env[name]
8191
if (value && value.trim().length > 0) {
82-
return value.trim().replace(/\/+$/, '')
92+
return assertInstancePermitted(value.trim().replace(/\/+$/, ''))
8393
}
8494
}
8595
if (isCI()) {

‎tests/e2e/shared-instance.ts‎

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Larpinq Contributors
3+
* SPDX-License-Identifier: EUPL-1.2
4+
*
5+
* Whether this e2e run is allowed to touch the instance it is aimed at.
6+
*
7+
* GENERATED FROM hydra/templates/e2e/shared-instance.ts.tmpl. The only thing
8+
* that differs per app is `APP_ID` below. Edit the template, not the copies.
9+
*
10+
* The problem
11+
* -----------
12+
* Every Conduction dev box runs one shared Nextcloud on port 8080 (or 80 when
13+
* it is published without a port). It bind-mounts the host checkouts under
14+
* `apps-extra/`, so it serves whatever everybody on the box is editing, and it
15+
* carries data colleagues are working on. An e2e suite seeds OpenRegister
16+
* objects and then deletes them again. Pointed at that instance it is not
17+
* running tests, it is editing someone else's environment. Two apps were
18+
* caught doing exactly this, by default, because their base URL fell back to
19+
* `http://localhost:8080` when nothing was set.
20+
*
21+
* The rule
22+
* --------
23+
* Aiming at a shared instance is allowed. Aiming at one BY ACCIDENT is not.
24+
* So the target has to be named twice: once as the base URL, and once in an
25+
* opt-in variable that holds the origin you permit.
26+
*
27+
* E2E_ALLOW_SHARED_INSTANCE=http://localhost:8080 \
28+
* PLAYWRIGHT_BASE_URL=http://localhost:8080 \
29+
* npx playwright test
30+
*
31+
* The flag holds an ORIGIN, not `1`. A bare `1` left in a shell profile goes
32+
* on permitting every shared instance the suite ever meets. An origin permits
33+
* the one you typed and nothing else, so aiming somewhere new makes you type
34+
* the new one.
35+
*
36+
* Two spellings are accepted and they mean the same thing:
37+
*
38+
* E2E_ALLOW_SHARED_INSTANCE fleet-wide, permits any app's suite
39+
* LARPINQ_E2E_ALLOW_SHARED_INSTANCE this app only
40+
*
41+
* The app-specific spelling is the safer one to leave in a profile, because it
42+
* stops at this app. The fleet-wide one is for a session that deliberately
43+
* drives several suites at one instance.
44+
*
45+
* CI is exempt
46+
* ------------
47+
* On a GitHub runner `localhost:8080` is the runner's own throwaway Nextcloud,
48+
* started by the shared ConductionNL/.github workflow and destroyed with the
49+
* job. Nothing there is shared with anybody. Treating it as shared would break
50+
* every e2e run in the fleet, so `isCI()` short-circuits the whole rule.
51+
*
52+
* This is one of three guards, and they do not overlap
53+
* ---------------------------------------------------
54+
* 1. An age bound on the cross-run residue sweep stops one session deleting
55+
* another session's LIVE fixtures.
56+
* 2. A run ledger stops a run's own teardown reaching rows it never created,
57+
* at any age. Content matching (`JSON.stringify(row).includes(prefix)`) is
58+
* not ownership: it deletes anything that happens to carry the string.
59+
* 3. This flag stops the cross-run sweep deleting anything at all on a shared
60+
* box, because there it cannot tell your leftovers from a colleague's.
61+
*
62+
* Guard 3 is the only portable one, which is why it is the one in this
63+
* template. Guard 2 needs a single create funnel and is written per app.
64+
*/
65+
66+
/** The app this copy belongs to. Substituted when the template is rendered. */
67+
export const APP_ID = 'larpinq'
68+
69+
/** The app-specific opt-in variable. Holds an origin, not a boolean. */
70+
export const SHARED_INSTANCE_FLAG = 'LARPINQ_E2E_ALLOW_SHARED_INSTANCE'
71+
72+
/** The fleet-wide opt-in variable. Same contract, wider blast radius. */
73+
export const FLEET_INSTANCE_FLAG = 'E2E_ALLOW_SHARED_INSTANCE'
74+
75+
/**
76+
* Both spellings, app-specific first so it wins a disagreement.
77+
*/
78+
export const SHARED_INSTANCE_FLAGS = [
79+
SHARED_INSTANCE_FLAG,
80+
FLEET_INSTANCE_FLAG,
81+
] as const
82+
83+
/**
84+
* Origins that belong to the shared Conduction development stack.
85+
*
86+
* Port 8080 is the `nextcloud` container every dev box runs, and port 80 is
87+
* the same stack published without a port. A disposable rig gets its own high
88+
* port (8095, 8614, 8731 and so on), never matches this list, and needs no
89+
* flag.
90+
*/
91+
const SHARED_PORTS = new Set(['80', '8080'])
92+
93+
/** Loopback spellings that all name the same host. */
94+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'])
95+
96+
/** A URL split into the three things this module compares. */
97+
interface OriginParts {
98+
/** `http:` or `https:`, including the colon. */
99+
protocol: string
100+
/** Hostname, with every loopback spelling folded onto `localhost`. */
101+
host: string
102+
/** Port as a string, with the protocol default made explicit. */
103+
port: string
104+
}
105+
106+
/**
107+
* Split a URL into protocol, folded host and explicit port.
108+
*
109+
* The explicit port is the whole point. `new URL('http://127.0.0.1').port` is
110+
* the empty string, not `80`, so a comparison against a port list silently
111+
* misses every shared origin written without one. That is the failure this
112+
* helper exists to make impossible, by being the only place a port is read.
113+
*
114+
* @param value A base URL.
115+
* @return The parts, or null when the value will not parse.
116+
*/
117+
function splitOrigin(value: string): OriginParts | null {
118+
let url: URL
119+
try {
120+
url = new URL(value)
121+
} catch {
122+
return null
123+
}
124+
return {
125+
protocol: url.protocol,
126+
host: LOOPBACK.has(url.hostname) ? 'localhost' : url.hostname,
127+
port: url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80',
128+
}
129+
}
130+
131+
/**
132+
* Reduce a URL to `scheme://host:port`, with loopback spellings folded onto
133+
* `localhost` and the default port made explicit.
134+
*
135+
* Returns the trimmed input when it will not parse, so a malformed value fails
136+
* later on its own HTTP probe with a message about the real problem rather
137+
* than here.
138+
*
139+
* @param value A base URL.
140+
* @return The normalised origin.
141+
*/
142+
export function normaliseOrigin(value: string): string {
143+
const parts = splitOrigin(value)
144+
if (parts === null) return value.trim().replace(/\/+$/, '')
145+
return `${parts.protocol}//${parts.host}:${parts.port}`
146+
}
147+
148+
/**
149+
* Whether this URL names an instance shared with other people.
150+
*
151+
* Shared means loopback on port 80 or 8080. A remote host is somebody's
152+
* deployment and is out of scope here: this guard is about the box you are
153+
* sitting at.
154+
*
155+
* @param value A base URL.
156+
* @return True when the origin is the shared development stack.
157+
*/
158+
export function isSharedOrigin(value: string): boolean {
159+
const parts = splitOrigin(value)
160+
if (parts === null) return false
161+
return parts.host === 'localhost' && SHARED_PORTS.has(parts.port)
162+
}
163+
164+
/**
165+
* Whether this process runs on a CI runner.
166+
*
167+
* @return True on GitHub Actions, or any CI that exports `CI`.
168+
*/
169+
export function isCI(): boolean {
170+
return Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS)
171+
}
172+
173+
/**
174+
* The flag value that permits this origin, if any flag does.
175+
*
176+
* @param target The base URL being aimed at.
177+
* @param env The environment to read.
178+
* @return The variable name and its value, or null when none matches.
179+
*/
180+
export function permittingFlag(
181+
target: string,
182+
env: NodeJS.ProcessEnv = process.env,
183+
): { name: string; value: string } | null {
184+
const wanted = normaliseOrigin(target)
185+
for (const name of SHARED_INSTANCE_FLAGS) {
186+
const value = (env[name] ?? '').trim()
187+
if (value === '') continue
188+
if (normaliseOrigin(value) === wanted) return { name, value }
189+
}
190+
return null
191+
}
192+
193+
/**
194+
* Whether this run deliberately targets an instance shared with other people.
195+
*
196+
* False on CI even at `localhost:8080`, and false for a rig on its own port.
197+
* Every safety rule in a suite keys off this one answer: teardown deletes only
198+
* recorded ids, the cross-run residue sweep reports instead of deleting, and
199+
* specs that would change instance-wide settings refuse to run.
200+
*
201+
* @param target The base URL under test.
202+
* @return True when the target is shared and this run said so.
203+
*/
204+
export function isSharedInstance(target: string): boolean {
205+
return isCI() === false && isSharedOrigin(target)
206+
}
207+
208+
/**
209+
* The message an operator reads when they aimed at a shared instance without
210+
* saying so.
211+
*
212+
* @param target The base URL they asked for.
213+
* @param env The environment to read.
214+
* @return The full error text.
215+
*/
216+
function refusalMessage(
217+
target: string,
218+
env: NodeJS.ProcessEnv = process.env,
219+
): string {
220+
const origin = normaliseOrigin(target)
221+
const setButWrong = SHARED_INSTANCE_FLAGS.map((name) => {
222+
const value = (env[name] ?? '').trim()
223+
if (value === '') return ''
224+
return (
225+
`${name} is set to "${value}", which normalises to `
226+
+ `${normaliseOrigin(value)} and does not match ${origin}.\n`
227+
)
228+
}).join('')
229+
230+
return (
231+
`[${APP_ID} e2e] ${target} is the SHARED development instance, and this run `
232+
+ 'did not say it meant to go there.\n'
233+
+ setButWrong
234+
+ 'That instance bind-mounts host checkouts and holds data your colleagues '
235+
+ 'are working on. This suite seeds and deletes objects.\n\n'
236+
+ 'Point the suite at your own disposable rig:\n\n'
237+
+ ' PLAYWRIGHT_BASE_URL=http://localhost:8095 npx playwright test\n\n'
238+
+ 'Or aim at the shared instance on purpose, naming the origin you permit:\n\n'
239+
+ ` ${SHARED_INSTANCE_FLAG}=${origin} \\\n`
240+
+ ` PLAYWRIGHT_BASE_URL=${target} \\\n`
241+
+ ' npx playwright test\n\n'
242+
+ `Read the header of tests/e2e/shared-instance.ts first. The flag changes `
243+
+ 'what teardown may delete and what the residue sweep may remove.'
244+
)
245+
}
246+
247+
/**
248+
* Refuse the run when it aims at a shared instance without the opt-in.
249+
*
250+
* Call this from the module that resolves the base URL, on the resolved value,
251+
* so there is one place a target can enter the suite.
252+
*
253+
* @param target The base URL under test.
254+
* @param env The environment to read.
255+
* @return The target, unchanged, so the call can be inlined.
256+
* @throws when the target is shared and no flag names it.
257+
*/
258+
export function assertInstancePermitted(
259+
target: string,
260+
env: NodeJS.ProcessEnv = process.env,
261+
): string {
262+
if (isCI()) return target
263+
if (isSharedOrigin(target) === false) return target
264+
if (permittingFlag(target, env) !== null) return target
265+
throw new Error(refusalMessage(target, env))
266+
}
267+
268+
/**
269+
* Refuse one capability on a shared instance, even when the flag permitted the
270+
* suite itself.
271+
*
272+
* For work that changes instance-wide state: enabling a workflow engine,
273+
* flipping an app setting, purging a register. Call it from `test.beforeAll`
274+
* so the refusal is reported as a failure with its reason. A skip reads as
275+
* "nothing to see here", which is the opposite of the message.
276+
*
277+
* @param target The base URL under test.
278+
* @param what The spec or capability being refused.
279+
* @param reason Why it must not run on a shared instance.
280+
* @throws when this run targets a shared instance.
281+
*/
282+
export function refuseOnSharedInstance(
283+
target: string,
284+
what: string,
285+
reason: string,
286+
): void {
287+
if (isSharedInstance(target) === false) return
288+
throw new Error(
289+
`[${APP_ID} e2e] ${what} must not run on ${target}.\n`
290+
+ `${reason}\n`
291+
+ `${SHARED_INSTANCE_FLAG} permits the suite on a shared instance. It does `
292+
+ 'not permit this.\n'
293+
+ 'Start a disposable rig and point the suite at that instead:\n\n'
294+
+ ' PLAYWRIGHT_BASE_URL=http://localhost:8095 npx playwright test\n',
295+
)
296+
}
297+
298+
/**
299+
* The occ invocation for the instance under test, as a command prefix.
300+
*
301+
* The binding matters on a shared box: `php occ` run from this checkout talks
302+
* to whatever server root sits two directories up, which on CI is the instance
303+
* under test and on a dev box may be a different one entirely. Naming the
304+
* container is how the two are tied together.
305+
*
306+
* Resolution order:
307+
* 1. `LARPINQ_E2E_OCC`, a complete prefix, for any rig shape the guesses
308+
* below do not cover.
309+
* 2. `LARPINQ_E2E_CONTAINER` or `NEXTCLOUD_CONTAINER`, a container name,
310+
* turned into `docker exec -u www-data <name> php occ`.
311+
* 3. `php occ` from the server root, which is the CI case.
312+
*
313+
* @param env The environment to read.
314+
* @return The argv prefix, already split, for `execFile` rather than a shell.
315+
*/
316+
export function occPrefix(env: NodeJS.ProcessEnv = process.env): string[] {
317+
const explicit = (env.LARPINQ_E2E_OCC ?? '').trim()
318+
if (explicit !== '') return explicit.split(/\s+/).filter((p) => p !== '')
319+
320+
const container = (
321+
env.LARPINQ_E2E_CONTAINER
322+
?? env.NEXTCLOUD_CONTAINER
323+
?? ''
324+
).trim()
325+
if (container !== '') {
326+
return ['docker', 'exec', '-u', 'www-data', container, 'php', 'occ']
327+
}
328+
329+
return ['php', 'occ']
330+
}

0 commit comments

Comments
 (0)