ReviewOS

also looking at this

loghqorg/loghq

test(e2e): isolate mutating runs and account cleanup

#12
Merged glennmichael123 wants to merge test/loghq-e2e-safety into main
4 files +209 -27
tests/e2e/README.mdadded+29-0
Changes to tests/e2e/README.md
@@ -0,0 +1,29 @@
1# Local E2E safety
2
3These mutating HTTP suites are disabled by default. A reachable development server
4does not opt a normal unit-test run into account creation or database cleanup.
5
6An 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
14Start a separate local application instance using that same disposable database.
15Do not point the test instance at real data. Disable outbound mail, notifications,
16webhooks and paid integrations in that instance before running these suites.
17The URL guard cannot prove which database or integrations a local server uses;
18the operator must configure the isolated server correctly.
19
20Cleanup opens only the explicitly named disposable SQLite file and only removes
21accounts whose exact email addresses were generated in this process. It never
22uses the application's default database connection or a wildcard email match.
23
24## Known coverage gap
25
26The auth and routing suites still contain expectations for the previous page-action
27login and readable `loghq_token` cookie. They need migration to the current JSON
28auth endpoints and HttpOnly `auth-token` contract before they can serve as a current
29auth release gate. This safety guard does not claim those legacy expectations pass.
tests/e2e/helpers.tsmodified+22-27
Changes to tests/e2e/helpers.ts
@@ -1,11 +1,9 @@
11/**
22 * Shared plumbing for the end-to-end suite.
33 *
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.
97 *
108 * What that does NOT cover is anything whose behaviour only exists after
119 * hydration: SPA navigation, the theme toggle, clipboard buttons, the delete
@@ -13,7 +11,10 @@
1311 * deliberately out of scope here rather than faked.
1412 */
1513
16const BASE = Bun.env.E2E_BASE_URL || 'http://localhost:3000'
14import { cleanupE2EAccounts, e2eUrl, validateE2ETarget } from './safety'
15
16const target = validateE2ETarget(Bun.env)
17const createdEmails = new Set<string>()
1718
1819/** A password that satisfies the app's 8-character minimum. */
1920export const TEST_PASSWORD = 'e2e-test-password'
@@ -21,14 +22,16 @@ export const TEST_PASSWORD = 'e2e-test-password'
2122/**
2223 * Whether a server is reachable.
2324 *
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`
2526 * stays green on a machine that has not started the app. A failing E2E suite
2627 * should mean the app is broken, not that the developer did not run `bun run
2728 * dev` — otherwise the signal gets ignored, which is worse than not having it.
2829 */
2930export async function serverIsUp(): Promise<boolean> {
31 if (!target)
32 return false
3033 try {
31 const res = await fetch(BASE, { signal: AbortSignal.timeout(3000) })
34 const res = await fetch(target.baseUrl, { redirect: 'manual', signal: AbortSignal.timeout(3000) })
3235 return res.ok
3336 }
3437 catch {
@@ -46,7 +49,7 @@ export async function serverIsUp(): Promise<boolean> {
4649export const SERVER_UP: boolean = await serverIsUp()
4750
4851export function url(path: string): string {
49 return BASE + path
52 return e2eUrl(target, path)
5053}
5154
5255/**
@@ -88,12 +91,13 @@ export function sessionCookie(res: Response): string {
8891/**
8992 * An email nobody else is using.
9093 *
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.
9496 */
9597export 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
97101}
98102
99103/** 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,
109113}
110114
111115/**
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.
118119 */
119120export 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()
128123}
tests/e2e/safety.tsadded+76-0
Changes to tests/e2e/safety.ts
@@ -0,0 +1,76 @@
1import { Database } from 'bun:sqlite'
2import { realpathSync, statSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { basename, dirname, isAbsolute } from 'node:path'
5
6interface E2EEnvironment {
7 E2E_RUN?: string
8 E2E_BASE_URL?: string
9 E2E_SQLITE_PATH?: string
10}
11
12export interface E2ETarget {
13 baseUrl: string
14 sqlitePath: string
15}
16
17export 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
26export 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. */
46export 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}
tests/unit/e2e-safety.test.tsadded+82-0
Changes to tests/unit/e2e-safety.test.ts
@@ -0,0 +1,82 @@
1import { Database } from 'bun:sqlite'
2import { afterAll, describe, expect, test } from 'bun:test'
3import { mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { cleanupE2EAccounts, e2eUrl, validateE2ETarget } from '../e2e/safety'
7
8const directory = mkdtempSync(join(tmpdir(), 'loghq-e2e-safety-'))
9const sqlitePath = join(directory, 'database.sqlite')
10const fixture = new Database(sqlitePath)
11fixture.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT); CREATE TABLE projects (owner_id INTEGER); CREATE TABLE oauth_access_tokens (user_id INTEGER)')
12fixture.close()
13afterAll(() => rmSync(directory, { recursive: true, force: true }))
14
15describe('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})