|
| 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