also looking at this
test(e2e): isolate mutating runs and account cleanup
#12
4 files
+209
-27
| @@ -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 | } | |