also looking at this
chore(release): promote main to production
#19
8 files
+325
-31
| @@ -100,4 +100,6 @@ BUGHQ_KEY="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBQ212TlNiUnNvcHNJ | ||
| 100 | 100 | ANALYTICSHQ_APP_ID="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBZC1VY3h6RnZGYlJFamlPR3pzdXNoV0w2aUZJTV9HZWZSaG5HaXBnUDJDTSIsInNhbHQiOiJQa3cwYU5uRnptaks4YlpTYnRBMFJRIiwibm9uY2UiOiJmcXpzLU1jSzB6YTFFZzVTIiwiY2lwaGVydGV4dCI6IkZ0UXhoYUhzVlRzU1p3cExIX1JuZmpvWk85XzdDeG82IiwidGFnIjoiSzhjVHk2eHE1OEtwc0c4UDgyYjBqUSJ9" |
| 101 | 101 | GITHUB_REDIRECT_URL="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBdFpORy1WcEtFU2pyUXI3dU9NamRMbW82enVlSVRpb0ZfZGxleWZGQlhIdyIsInNhbHQiOiJyZTljX3I3eXhIOWlUY0o0RGJwem5nIiwibm9uY2UiOiJOVk9CUmlnRldHd1BGVG15IiwiY2lwaGVydGV4dCI6InRNOE4zalluMktGVkFPSXd4WV9aSzdZTmhKSGt0YUcxQzVrVmJRRDBRcFRRSnkxVjhFd0kycHpMIiwidGFnIjoiX2V3Z1o1SHB5YWN2OFRFQUhMMEtxdyJ9" |
| 102 | 102 | GITHUB_CLIENT_SECRET="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBMTE3aHZGVmlyX1dQNXFmOVRaMzVQNUh6Q2tzbnk4MFhPNG8tODQyRm5GcyIsInNhbHQiOiJHWGU2ZG96T2hTLWIzWjYxOUVrNnNRIiwibm9uY2UiOiJ3UkFrUTBXNFU1ODdGSUxoIiwiY2lwaGVydGV4dCI6IkN6MmJJdk5EU25TcHNpUDh6WWtTUzFqWUpFUXd5b1p3dlhTU3dmeW5LY2lhNDJkQV91RVBQdyIsInRhZyI6InR0bFd0aG01Zi1hcGwzUEZwbkt3cXcifQ" |
| 103 | GITHUB_CLIENT_ID="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBUHFrbDF5dVdvTktIWHAwWmd6VkwyOWthQlc1djM4RkJITUV2RmF4Y1loSSIsInNhbHQiOiJlM0xzTHR5Sm01WlVVZ3VzZGc1YkFRIiwibm9uY2UiOiJ5d2d5TWNld2lkdG9HdVN1IiwiY2lwaGVydGV4dCI6Ijk3cEF6N3ZlMFNOMDNoY05UdzhmTXpWbWNBOCIsInRhZyI6InpKSTdJdHp3aEREM0ZEekVsUFdFZHcifQ"⧵ | |
| 103 | GITHUB_CLIENT_ID="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBUHFrbDF5dVdvTktIWHAwWmd6VkwyOWthQlc1djM4RkJITUV2RmF4Y1loSSIsInNhbHQiOiJlM0xzTHR5Sm01WlVVZ3VzZGc1YkFRIiwibm9uY2UiOiJ5d2d5TWNld2lkdG9HdVN1IiwiY2lwaGVydGV4dCI6Ijk3cEF6N3ZlMFNOMDNoY05UdzhmTXpWbWNBOCIsInRhZyI6InpKSTdJdHp3aEREM0ZEekVsUFdFZHcifQ" | |
| 104 | MAIL_PASSWORD_NOREPLY="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBdlNUcEtKZHFVQ0pzMW5hdXhTcFlTTV9lblVzNWFaR0JBX0ZNWDJyRzhqMCIsInNhbHQiOiJBX2o4TzgtZjNTOVhYZlJ1VTN5OGVnIiwibm9uY2UiOiJKWTI2V3J6dU43YXVKRGhpIiwiY2lwaGVydGV4dCI6IkhUUUJkM3BUTzlzVHMwUWFHZ0V1MVg1bm5na29CU0NIIiwidGFnIjoiM1JBUm05UndZVVI5VHdUd0taaW1qZyJ9" | |
| 105 | MAIL_PASSWORD="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBSkFlS21LaWlmc0NyZDBkZUk3UHhjRUljN2F3XzRPZVJZZ3czQ0RiMU1TcyIsInNhbHQiOiJPOC1kSTFreUFhcUQ5cUc2Ym8td1FBIiwibm9uY2UiOiJsd0x1WThFenhyVm1wT2dFIiwiY2lwaGVydGV4dCI6IkVmdl9qX1FfNGxWc2tDd2FoTXpqSXlyVmhmb3gtTHU1IiwidGFnIjoiZEpRMW9jWmoyelJkbEV0RV9NWjd5ZyJ9" | |
| @@ -31,9 +31,16 @@ export default { | ||
| 31 | 31 | * - Objects: [{ email: 'chris', password: '...' }] |
| 32 | 32 | */ |
| 33 | 33 | mailboxes: [ |
| 34 | // The mailbox this app authenticates as. Until now loghq.org had no mailbox | |
| 35 | // of any kind on the mail server, so MAIL_USERNAME named an account that did | |
| 36 | // not exist and SMTP auth answered 535 on every send. DNS and config were | |
| 37 | // both already correct; only the account was missing. | |
| 34 | 38 | 'noreply', |
| 35 | 'chris', | |
| 36 | 'hello', | |
| 39 | // 'chris' and 'hello' were declared here and never existed either. They are | |
| 40 | // left out rather than created: provisioning sets each declared mailbox's | |
| 41 | // password from MAIL_PASSWORD_<LOCALPART>, so declaring an address nobody | |
| 42 | // asked for mints a real, credentialed account for it. Add them back when | |
| 43 | // somebody actually wants to read mail at them. | |
| 37 | 44 | ], |
| 38 | 45 | |
| 39 | 46 | url: env.APP_URL || 'https://stacksjs.com', |
| @@ -127,7 +127,22 @@ export default { | ||
| 127 | 127 | // allowPatterns therefore stays EMPTY. |
| 128 | 128 | strict: { |
| 129 | 129 | enabled: true, |
| 130 | failOnViolation: false, | |
| 130 | // Hard-fail on a prohibited DOM call in a developer's own environment, | |
| 131 | // warn everywhere else. An ALLOWLIST deliberately, not `!== 'prod'`: | |
| 132 | // APP_ENV is typed 'local'|'dev'|'stage'|'prod' (config/env.ts) with no | |
| 133 | // 'production' member, and .env ships 'development', which is outside | |
| 134 | // that enum -- so any predicate written against 'production' is TRUE in | |
| 135 | // production, the exact inversion this guards against. An unset APP_ENV | |
| 136 | // falls through to warn-only for the same reason: a missing variable must | |
| 137 | // not be what turns a page into a 500. That covers the real production | |
| 138 | // path twice over: APP_ENV ships dotenvx-encrypted in .env.production, so | |
| 139 | // it arrives either decrypted as 'prod' or, if the key is missing, as the | |
| 140 | // literal 'encrypted:v2:...' string — and neither is in the list. | |
| 141 | // | |
| 142 | // A violation under this flag is a real HTTP 500 with the custom /500 page | |
| 143 | // (bun-plugin-stx serve.js:11111,11133), not a blank 200 -- verified | |
| 144 | // against the installed copy. | |
| 145 | failOnViolation: ['local', 'dev', 'development'].includes(process.env.APP_ENV ?? ''), | |
| 131 | 146 | allowPatterns: [], |
| 132 | 147 | }, |
| 133 | 148 | |
| @@ -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 | }) | |
| @@ -0,0 +1,88 @@ | ||
| 1 | /** | |
| 2 | * Rule 8 (stx-standards 08-no-vanilla-js): no vanilla DOM in a client script. | |
| 3 | * | |
| 4 | * config/ui.ts turns stx's strict mode on, but that is only a partial gate. | |
| 5 | * The renderer validates whatever reaches validateClientScript, and a | |
| 6 | * signal-using <script client> block is re-tagged data-stx-scoped by | |
| 7 | * processScriptSetup and then skipped by process.js's own skipAttrs -- so the | |
| 8 | * blocks most likely to touch the DOM are the ones it never sees. It also | |
| 9 | * only ever checks pages something actually rendered, which makes a | |
| 10 | * dev-server boot a sampling exercise rather than a check. | |
| 11 | * | |
| 12 | * This reads the source instead, where no block has been re-tagged yet, so it | |
| 13 | * sees every one of them. It calls stx's own exported validator rather than | |
| 14 | * re-listing the prohibited patterns, so the rules cannot drift from the | |
| 15 | * engine's, and honours `stx-strict-ignore` markers because that validator | |
| 16 | * parses them itself. | |
| 17 | */ | |
| 18 | import { describe, expect, it } from 'bun:test' | |
| 19 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 20 | import { join, relative } from 'node:path' | |
| 21 | import { validateClientScript } from '@stacksjs/stx' | |
| 22 | ||
| 23 | const root = process.cwd() | |
| 24 | ||
| 25 | /** Vendored or generated; their violations are not this repo's to fix. */ | |
| 26 | const SKIP = new Set(['node_modules', 'storage', 'pantry', '.git', 'dist', 'public', 'coverage']) | |
| 27 | ||
| 28 | /** | |
| 29 | * Not a client script: server blocks, external sources, and any explicit | |
| 30 | * non-JS type. Mirrors the skipAttrs process.js passes its scanner, minus the | |
| 31 | * two render-time markers (data-stx-scoped, data-stx-router) that by | |
| 32 | * definition cannot be in a source file -- excluding them here is what makes | |
| 33 | * this broader than the renderer. | |
| 34 | */ | |
| 35 | const NOT_CLIENT = /\bserver\b|\bsrc\s*=|\btype\s*=\s*["'](?!(?:text\/javascript|application\/javascript|module)["'])[^"']*["']/ | |
| 36 | const SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script>/g | |
| 37 | ||
| 38 | function stxFiles(dir: string): string[] { | |
| 39 | let found: string[] = [] | |
| 40 | for (const name of readdirSync(dir)) { | |
| 41 | if (SKIP.has(name) || name.startsWith('.') && name !== '.stx') continue | |
| 42 | const path = join(dir, name) | |
| 43 | let entry | |
| 44 | try { entry = statSync(path) } | |
| 45 | catch { continue } | |
| 46 | if (entry.isDirectory()) found = found.concat(stxFiles(path)) | |
| 47 | else if (path.endsWith('.stx')) found.push(path) | |
| 48 | } | |
| 49 | return found | |
| 50 | } | |
| 51 | ||
| 52 | function scan(): { blocks: number, files: number, violations: string[] } { | |
| 53 | const violations: string[] = [] | |
| 54 | const files = stxFiles(root) | |
| 55 | let blocks = 0 | |
| 56 | for (const file of files) { | |
| 57 | const name = relative(root, file) | |
| 58 | for (const [, attrs, body] of readFileSync(file, 'utf8').matchAll(SCRIPT)) { | |
| 59 | if (NOT_CLIENT.test(attrs ?? '')) continue | |
| 60 | blocks++ | |
| 61 | // failOnViolation rather than reading console.warn: the validator | |
| 62 | // memoises per (content, file, config) and a remembered verdict | |
| 63 | // re-throws but does not warn again, so two identical blocks would | |
| 64 | // otherwise report once. | |
| 65 | try { validateClientScript(body ?? '', name, { enabled: true, failOnViolation: true, allowPatterns: [] }, name) } | |
| 66 | catch (error) { violations.push(error instanceof Error ? error.message : String(error)) } | |
| 67 | } | |
| 68 | } | |
| 69 | return { blocks, files: files.length, violations } | |
| 70 | } | |
| 71 | ||
| 72 | const result = scan() | |
| 73 | ||
| 74 | describe('rule 8: no vanilla JS in client scripts', () => { | |
| 75 | it('reaches the client scripts it is meant to check', () => { | |
| 76 | // Without this the suite would pass just as happily on a scan that | |
| 77 | // matched nothing at all, which is the one failure mode a green test | |
| 78 | // cannot tell you about. | |
| 79 | expect(result.files).toBeGreaterThan(0) | |
| 80 | expect(result.blocks).toBeGreaterThan(0) | |
| 81 | }) | |
| 82 | ||
| 83 | it('finds no prohibited DOM calls', () => { | |
| 84 | // Asserting on the joined report rather than a count: the diff on failure | |
| 85 | // is the list of offending files, lines and suggested composables. | |
| 86 | expect(result.violations.join('\n\n')).toBe('') | |
| 87 | }) | |
| 88 | }) | |