also looking at this
test(e2e): isolate mutating runs and account cleanup
#12
4 files
+209
-27
| @@ -0,0 +1,29 @@ | ||
| 1 | # Local E2E safety | |
| 2 | ||
| 3 | These mutating HTTP suites are disabled by default. A reachable development server | |
| 4 | does not opt a normal unit-test run into account creation or database cleanup. | |
| 5 | ||
| 6 | An intentional run requires all three variables: | |
| 7 | ||
| 8 | - `E2E_RUN=1` | |
| 9 | - `E2E_BASE_URL`: an HTTP numeric-loopback origin with an explicit port, such as | |
| 10 | `http://127.0.0.1:3100`. Remote hosts, redirects and URL credentials are not allowed. | |
| 11 | - `E2E_SQLITE_PATH`: an absolute path to an existing `database.sqlite` inside a | |
| 12 | freshly created `loghq-e2e-*` directory directly under the system temp directory. | |
| 13 | ||
| 14 | Start a separate local application instance using that same disposable database. | |
| 15 | Do not point the test instance at real data. Disable outbound mail, notifications, | |
| 16 | webhooks and paid integrations in that instance before running these suites. | |
| 17 | The URL guard cannot prove which database or integrations a local server uses; | |
| 18 | the operator must configure the isolated server correctly. | |
| 19 | ||
| 20 | Cleanup opens only the explicitly named disposable SQLite file and only removes | |
| 21 | accounts whose exact email addresses were generated in this process. It never | |
| 22 | uses the application's default database connection or a wildcard email match. | |
| 23 | ||
| 24 | ## Known coverage gap | |
| 25 | ||
| 26 | The auth and routing suites still contain expectations for the previous page-action | |
| 27 | login and readable `loghq_token` cookie. They need migration to the current JSON | |
| 28 | auth endpoints and HttpOnly `auth-token` contract before they can serve as a current | |
| 29 | auth release gate. This safety guard does not claim those legacy expectations pass. | |
| @@ -1,11 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Shared plumbing for the end-to-end suite. |
| 3 | 3 | * |
| 4 | * These tests drive the app over HTTP against a running dev server. That is | |
| 5 | * possible at all because every form in the app handles its own POST now | |
| 6 | * (stx#1847, see resources/views/login.stx) — sign-in, sign-up, password reset | |
| 7 | * and project creation are ordinary form submissions, so a browser is not | |
| 8 | * needed to exercise them and this suite carries no browser dependency. | |
| 4 | * These tests drive an explicitly opted-in, isolated local server over HTTP. | |
| 5 | * See README.md for the disposable database requirement and the legacy auth | |
| 6 | * expectations that still need migrating to the current JSON API. | |
| 9 | 7 | * |
| 10 | 8 | * What that does NOT cover is anything whose behaviour only exists after |
| 11 | 9 | * hydration: SPA navigation, the theme toggle, clipboard buttons, the delete |
| @@ -13,7 +11,10 @@ | ||
| 13 | 11 | * deliberately out of scope here rather than faked. |
| 14 | 12 | */ |
| 15 | 13 | |
| 16 | const BASE = Bun.env.E2E_BASE_URL || 'http://localhost:3000' | |
| 14 | import { cleanupE2EAccounts, e2eUrl, validateE2ETarget } from './safety' | |
| 15 | ||
| 16 | const target = validateE2ETarget(Bun.env) | |
| 17 | const createdEmails = new Set<string>() | |
| 17 | 18 | |
| 18 | 19 | /** A password that satisfies the app's 8-character minimum. */ |
| 19 | 20 | export const TEST_PASSWORD = 'e2e-test-password' |
| @@ -21,14 +22,16 @@ export const TEST_PASSWORD = 'e2e-test-password' | ||
| 21 | 22 | /** |
| 22 | 23 | * Whether a server is reachable. |
| 23 | 24 | * |
| 24 | * The suite SKIPS rather than fails when nothing is listening, so `bun test` | |
| 25 | * The suite SKIPS unless explicitly enabled, and when nothing is listening, so `bun test` | |
| 25 | 26 | * stays green on a machine that has not started the app. A failing E2E suite |
| 26 | 27 | * should mean the app is broken, not that the developer did not run `bun run |
| 27 | 28 | * dev` — otherwise the signal gets ignored, which is worse than not having it. |
| 28 | 29 | */ |
| 29 | 30 | export async function serverIsUp(): Promise<boolean> { |
| 31 | if (!target) | |
| 32 | return false | |
| 30 | 33 | try { |
| 31 | const res = await fetch(BASE, { signal: AbortSignal.timeout(3000) }) | |
| 34 | const res = await fetch(target.baseUrl, { redirect: 'manual', signal: AbortSignal.timeout(3000) }) | |
| 32 | 35 | return res.ok |
| 33 | 36 | } |
| 34 | 37 | catch { |
| @@ -46,7 +49,7 @@ export async function serverIsUp(): Promise<boolean> { | ||
| 46 | 49 | export const SERVER_UP: boolean = await serverIsUp() |
| 47 | 50 | |
| 48 | 51 | export function url(path: string): string { |
| 49 | return BASE + path | |
| 52 | return e2eUrl(target, path) | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | /** |
| @@ -88,12 +91,13 @@ export function sessionCookie(res: Response): string { | ||
| 88 | 91 | /** |
| 89 | 92 | * An email nobody else is using. |
| 90 | 93 | * |
| 91 | * Unique per call so a rerun cannot collide with its own leftovers, and | |
| 92 | * prefixed so the cleanup below can find every account this suite ever made — | |
| 93 | * including from a run that crashed before its own teardown. | |
| 94 | * Unique per call so a rerun cannot collide with its own leftovers. Track the | |
| 95 | * exact address for this run's cleanup; the prefix is only a readable QA label. | |
| 94 | 96 | */ |
| 95 | 97 | export function freshEmail(label: string): string { |
| 96 | return `e2e-${label}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}@loghq.test` | |
| 98 | const email = `e2e-${label}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}@loghq.test` | |
| 99 | createdEmails.add(email) | |
| 100 | return email | |
| 97 | 101 | } |
| 98 | 102 | |
| 99 | 103 | /** Register through the real form and return the session cookie it mints. */ |
| @@ -109,20 +113,11 @@ export async function registerAndSignIn(label: string): Promise<{ email: string, | ||
| 109 | 113 | } |
| 110 | 114 | |
| 111 | 115 | /** |
| 112 | * Remove every account this suite has ever created, and anything hanging off | |
| 113 | * one. | |
| 114 | * | |
| 115 | * Matches on the `e2e-%@loghq.test` prefix rather than tracking ids, so a run | |
| 116 | * that died before teardown is cleaned up by the next one. Deletes children | |
| 117 | * first — projects and tokens reference users. | |
| 116 | * Remove only this process's generated accounts from the explicit disposable | |
| 117 | * SQLite file. Never import the app's default database connection for cleanup. | |
| 118 | * Failed-run leftovers remain confined to the disposable test database. | |
| 118 | 119 | */ |
| 119 | 120 | export async function cleanupTestAccounts(): Promise<void> { |
| 120 | const { db } = await import('@stacksjs/database') | |
| 121 | await db.unsafe( | |
| 122 | `DELETE FROM projects WHERE owner_id IN (SELECT id FROM users WHERE email LIKE 'e2e-%@loghq.test')`, | |
| 123 | ).execute() | |
| 124 | await db.unsafe( | |
| 125 | `DELETE FROM oauth_access_tokens WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'e2e-%@loghq.test')`, | |
| 126 | ).execute() | |
| 127 | await db.unsafe(`DELETE FROM users WHERE email LIKE 'e2e-%@loghq.test'`).execute() | |
| 121 | cleanupE2EAccounts(target, createdEmails) | |
| 122 | createdEmails.clear() | |
| 128 | 123 | } |
| @@ -0,0 +1,76 @@ | ||
| 1 | import { Database } from 'bun:sqlite' | |
| 2 | import { realpathSync, statSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { basename, dirname, isAbsolute } from 'node:path' | |
| 5 | ||
| 6 | interface E2EEnvironment { | |
| 7 | E2E_RUN?: string | |
| 8 | E2E_BASE_URL?: string | |
| 9 | E2E_SQLITE_PATH?: string | |
| 10 | } | |
| 11 | ||
| 12 | export interface E2ETarget { | |
| 13 | baseUrl: string | |
| 14 | sqlitePath: string | |
| 15 | } | |
| 16 | ||
| 17 | export function e2eUrl(target: E2ETarget | null, path: string): string { | |
| 18 | if (!target) | |
| 19 | throw new Error('Mutating E2E tests are disabled. See tests/e2e/README.md.') | |
| 20 | const resolved = new URL(path, target.baseUrl) | |
| 21 | if (resolved.origin !== target.baseUrl) | |
| 22 | throw new Error('E2E requests cannot leave the configured loopback origin.') | |
| 23 | return resolved.href | |
| 24 | } | |
| 25 | ||
| 26 | export function cleanupE2EAccounts(target: E2ETarget | null, emails: ReadonlySet<string>): void { | |
| 27 | if (!target || emails.size === 0) | |
| 28 | return | |
| 29 | const checked = validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: target.baseUrl, E2E_SQLITE_PATH: target.sqlitePath })! | |
| 30 | const db = new Database(checked.sqlitePath, { readwrite: true, create: false }) | |
| 31 | try { | |
| 32 | db.transaction(() => { | |
| 33 | for (const email of emails) { | |
| 34 | db.query('DELETE FROM projects WHERE owner_id IN (SELECT id FROM users WHERE email = ?)').run(email) | |
| 35 | db.query('DELETE FROM oauth_access_tokens WHERE user_id IN (SELECT id FROM users WHERE email = ?)').run(email) | |
| 36 | db.query('DELETE FROM users WHERE email = ?').run(email) | |
| 37 | } | |
| 38 | })() | |
| 39 | } | |
| 40 | finally { | |
| 41 | db.close() | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | /** Never infer permission to mutate from an unrelated server being reachable. */ | |
| 46 | export function validateE2ETarget(env: E2EEnvironment): E2ETarget | null { | |
| 47 | if (env.E2E_RUN !== '1') | |
| 48 | return null | |
| 49 | ||
| 50 | let base: URL | |
| 51 | try { | |
| 52 | base = new URL(env.E2E_BASE_URL || '') | |
| 53 | } | |
| 54 | catch { | |
| 55 | throw new Error('E2E_RUN requires an explicit numeric-loopback HTTP origin.') | |
| 56 | } | |
| 57 | if (base.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(base.hostname) | |
| 58 | || !base.port || base.username || base.password || base.pathname !== '/' || base.search || base.hash) { | |
| 59 | throw new Error('Mutating E2E tests only accept a numeric-loopback HTTP origin with an explicit port.') | |
| 60 | } | |
| 61 | ||
| 62 | const path = env.E2E_SQLITE_PATH || '' | |
| 63 | if (!isAbsolute(path) || basename(path) !== 'database.sqlite') | |
| 64 | throw new Error('E2E_SQLITE_PATH must name an existing disposable database.sqlite file.') | |
| 65 | ||
| 66 | // Resolve symlinks before checking containment. /tmp is /private/tmp on macOS. | |
| 67 | const sqlitePath = realpathSync(path) | |
| 68 | const directory = realpathSync(dirname(path)) | |
| 69 | const roots = new Set([realpathSync(tmpdir()), realpathSync('/tmp')]) | |
| 70 | if (dirname(sqlitePath) !== directory || basename(sqlitePath) !== 'database.sqlite' | |
| 71 | || !basename(directory).startsWith('loghq-e2e-') | |
| 72 | || !roots.has(dirname(directory)) || !statSync(sqlitePath).isFile()) { | |
| 73 | throw new Error('E2E database must be a regular file inside a loghq-e2e-* directory directly under the system temp directory.') | |
| 74 | } | |
| 75 | return { baseUrl: base.origin, sqlitePath } | |
| 76 | } | |
| @@ -0,0 +1,82 @@ | ||
| 1 | import { Database } from 'bun:sqlite' | |
| 2 | import { afterAll, describe, expect, test } from 'bun:test' | |
| 3 | import { mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { cleanupE2EAccounts, e2eUrl, validateE2ETarget } from '../e2e/safety' | |
| 7 | ||
| 8 | const directory = mkdtempSync(join(tmpdir(), 'loghq-e2e-safety-')) | |
| 9 | const sqlitePath = join(directory, 'database.sqlite') | |
| 10 | const fixture = new Database(sqlitePath) | |
| 11 | fixture.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT); CREATE TABLE projects (owner_id INTEGER); CREATE TABLE oauth_access_tokens (user_id INTEGER)') | |
| 12 | fixture.close() | |
| 13 | afterAll(() => rmSync(directory, { recursive: true, force: true })) | |
| 14 | ||
| 15 | describe('mutating E2E target safety', () => { | |
| 16 | test('is disabled by default, even when a server URL is present', () => { | |
| 17 | expect(validateE2ETarget({})).toBeNull() | |
| 18 | expect(validateE2ETarget({ E2E_BASE_URL: 'https://loghq.org' })).toBeNull() | |
| 19 | }) | |
| 20 | ||
| 21 | test('requires an explicit local target and disposable database', () => { | |
| 22 | expect(() => validateE2ETarget({ E2E_RUN: '1' })).toThrow() | |
| 23 | expect(() => validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: 'http://127.0.0.1:3100' })).toThrow() | |
| 24 | }) | |
| 25 | ||
| 26 | test('rejects remote hosts, credentials, paths and non-HTTP targets', () => { | |
| 27 | for (const target of [ | |
| 28 | 'https://loghq.org', | |
| 29 | 'http://localhost.example.com:3100', | |
| 30 | 'http://localhost:3100', | |
| 31 | 'http://user:password@127.0.0.1:3100', | |
| 32 | 'http://127.0.0.1:3100/api', | |
| 33 | 'http://127.0.0.1:3100/?redirect=production', | |
| 34 | 'http://127.0.0.1:3100/#fragment', | |
| 35 | 'ftp://127.0.0.1:3100', | |
| 36 | ]) { | |
| 37 | expect(() => validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: target, E2E_SQLITE_PATH: '/tmp/loghq-e2e-example/test.sqlite' })).toThrow() | |
| 38 | } | |
| 39 | }) | |
| 40 | ||
| 41 | test('accepts an explicit loopback origin with a real disposable file', () => { | |
| 42 | for (const baseUrl of ['http://127.0.0.1:3100', 'http://[::1]:3100']) { | |
| 43 | expect(validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: baseUrl, E2E_SQLITE_PATH: sqlitePath })) | |
| 44 | .toEqual({ baseUrl, sqlitePath: realpathSync(sqlitePath) }) | |
| 45 | } | |
| 46 | }) | |
| 47 | ||
| 48 | test('rejects a symlink to a differently named file', () => { | |
| 49 | const linkDirectory = mkdtempSync(join(tmpdir(), 'loghq-e2e-link-')) | |
| 50 | const otherFile = join(directory, 'not-disposable.sqlite') | |
| 51 | new Database(otherFile).close() | |
| 52 | symlinkSync(otherFile, join(linkDirectory, 'database.sqlite')) | |
| 53 | try { | |
| 54 | expect(() => validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: 'http://127.0.0.1:3100', E2E_SQLITE_PATH: join(linkDirectory, 'database.sqlite') })).toThrow() | |
| 55 | } | |
| 56 | finally { | |
| 57 | rmSync(linkDirectory, { recursive: true, force: true }) | |
| 58 | } | |
| 59 | }) | |
| 60 | ||
| 61 | test('cleans only this run in the disposable file and rejects cross-origin requests', () => { | |
| 62 | const target = validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: 'http://127.0.0.1:3100', E2E_SQLITE_PATH: sqlitePath })! | |
| 63 | expect(() => e2eUrl(null, '/login')).toThrow() | |
| 64 | expect(() => e2eUrl(target, 'https://loghq.org/logout')).toThrow() | |
| 65 | expect(() => e2eUrl(target, '//example.com/logout')).toThrow() | |
| 66 | expect(e2eUrl(target, '/login')).toBe('http://127.0.0.1:3100/login') | |
| 67 | const email = 'e2e-current-run@loghq.test' | |
| 68 | const db = new Database(sqlitePath) | |
| 69 | try { | |
| 70 | db.query('INSERT INTO users (id, email) VALUES (?, ?)').run(1, email) | |
| 71 | db.query('INSERT INTO users (id, email) VALUES (?, ?)').run(2, 'e2e-previous-run@loghq.test') | |
| 72 | db.exec('INSERT INTO projects VALUES (1), (2); INSERT INTO oauth_access_tokens VALUES (1), (2)') | |
| 73 | cleanupE2EAccounts(target, new Set([email])) | |
| 74 | expect(db.query('SELECT email FROM users').all()).toEqual([{ email: 'e2e-previous-run@loghq.test' }]) | |
| 75 | expect(db.query('SELECT owner_id FROM projects').all()).toEqual([{ owner_id: 2 }]) | |
| 76 | expect(db.query('SELECT user_id FROM oauth_access_tokens').all()).toEqual([{ user_id: 2 }]) | |
| 77 | } | |
| 78 | finally { | |
| 79 | db.close() | |
| 80 | } | |
| 81 | }) | |
| 82 | }) | |