diff --git a/tests/env-example.test.mjs b/tests/env-example.test.mjs new file mode 100644 index 0000000..c058cb1 --- /dev/null +++ b/tests/env-example.test.mjs @@ -0,0 +1,235 @@ +/** + * .env.example test — locks in the [E00-S04-T05] guarantee that the committed + * `.env.example` template at the repo root contains placeholders only and no + * real secret values. + * + * Acceptance criteria covered (each test fails without the committed state): + * - ".env.example contains placeholders only" → the file exists at the repo + * root and every assignment value is a placeholder (an explicit + * `change-me`/`<…>`-style marker) or a benign non-secret default (bind + * address, port, local database/user name); no value is a long + * random-looking token without a placeholder marker, no value embeds a + * credential URI, every line is a comment, a blank line, or a well-formed + * `KEY=value` assignment, no variable is repeated, and the file documents + * every configuration environment source (`HOST`/`PORT`/`DATABASE_URL`/ + * `EPPP_SESSION_SECRET`). + * - "no real secret values appear in the example file" → the same value + * predicate rejects secret-shaped values, the compose dev-default + * credential value appears nowhere in the file (values or comments), and + * `.gitignore` keeps real `.env` files ignored while un-ignoring the + * committed `.env.example`. The mutation probes below prove the + * assertions are non-vacuous (a secret-looking value, a credential URI, a + * compose default credential, a missing required variable, a dropped + * `!.env.example` negation, or a malformed line all break the criterion). + * + * Run: `node --test tests/env-example.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); +const exists = (relPath) => existsSync(path.join(REPO_ROOT, relPath)); + +const ENV_EXAMPLE_PATH = '.env.example'; + +/** The config schema's environment sources (E00-S04-T01/T04) the template must document. */ +const REQUIRED_VARS = ['HOST', 'PORT', 'DATABASE_URL', 'EPPP_SESSION_SECRET']; + +/** Compose dev-default credential values that must never appear in the template. */ +const FORBIDDEN_VALUES = ['postgres://eppp:eppp@db:5432/eppp']; + +/** A long random-looking value (JWT/API-key/token-shaped literal). */ +const LONG_SECRET_RE = /^[A-Za-z0-9+/=_-]{32,}$/; + +/** A credential URI (`scheme://user:pass@host`) embedded as a literal value. */ +const CREDENTIAL_URI_RE = /:\/\/[^/\s]+:[^@\s]+@/; + +/** Explicit placeholder markers a value may carry. */ +const PLACEHOLDER_MARKERS = [ + 'change-me', + 'changeme', + 'change_me', + 'your-', + 'replace', + 'example', + 'xxx', + '<', + '>', +]; + +/** Benign non-secret defaults the template may show as values. */ +const BENIGN_VALUES = new Set([ + '0.0.0.0', // HOST container default + '3000', // PORT / APP_PORT default + '5432', // POSTGRES_PORT default + 'localhost', // local bind/db host + 'eppp', // local database/user name (non-secret) + 'postgres://localhost:5432/eppp', // DATABASE_URL shape without embedded credentials +]); + +/** + * True when an assignment value is a placeholder, never a real secret. + * + * A value is a placeholder when it is a benign non-secret default or carries + * an explicit placeholder marker; a marked value is still rejected when it + * embeds a credential URI or reproduces a compose default credential. Any + * other value — including a long random-looking token — is not a placeholder. + */ +function isPlaceholderValue(value) { + if (value === '') return false; + if (BENIGN_VALUES.has(value)) return true; + if (PLACEHOLDER_MARKERS.some((marker) => value.includes(marker))) { + return !CREDENTIAL_URI_RE.test(value) && !FORBIDDEN_VALUES.includes(value); + } + return false; +} + +/** + * Parses the template into `{ key, value }` assignments, ignoring blank lines + * and `#` comment lines. Throws a descriptive Error on a malformed line so a + * stray non-assignment line cannot silently pass. + */ +function parseAssignments(content) { + const assignments = []; + content.split(/\r?\n/).forEach((raw, index) => { + const line = raw.trim(); + if (line === '' || line.startsWith('#')) return; + const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line); + if (!match) { + throw new Error( + `line ${index + 1} is not a comment, blank line, or KEY=value assignment: "${raw}"`, + ); + } + assignments.push({ key: match[1], value: match[2] }); + }); + return assignments; +} + +/** + * Asserts the acceptance criteria for the given template content — used by + * the committed-state test and by the mutation probes (which must make it + * throw). + */ +function assertTemplateHasPlaceholdersOnly(content) { + for (const forbidden of FORBIDDEN_VALUES) { + assert.ok( + !content.includes(forbidden), + `the template must not contain the compose default credential value "${forbidden}"`, + ); + } + + const assignments = parseAssignments(content); + assert.ok(assignments.length > 0, 'the template must contain at least one assignment'); + + const keys = assignments.map(({ key }) => key); + assert.equal(new Set(keys).size, keys.length, 'the template must not repeat a variable'); + + for (const required of REQUIRED_VARS) { + assert.ok( + keys.includes(required), + `the template must document the required variable "${required}"`, + ); + } + + for (const { key, value } of assignments) { + assert.ok( + isPlaceholderValue(value), + `"${key}" must be a placeholder value, got: "${value}"`, + ); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('a committed .env.example exists at the repo root', () => { + assert.ok(exists(ENV_EXAMPLE_PATH), `"${ENV_EXAMPLE_PATH}" must exist at the repo root`); +}); + +test('.gitignore keeps real .env files ignored while un-ignoring the committed example', () => { + const gitignore = read('.gitignore'); + assert.ok(gitignore.includes('.env'), 'real .env files must stay git-ignored'); + assert.ok(gitignore.includes('.env.*'), 'real .env.* files must stay git-ignored'); + assert.ok( + gitignore.includes('!.env.example'), + 'the committed .env.example must be un-ignored (negation pattern)', + ); +}); + +test('.env.example contains placeholders only and no real secret values', () => { + assertTemplateHasPlaceholdersOnly(read(ENV_EXAMPLE_PATH)); +}); + +// --------------------------------------------------------------------------- +// Non-vacuous probes — the assertions above really do fail on violations +// --------------------------------------------------------------------------- + +test('a compose default credential value in the template fails the criterion (mutation probe)', () => { + const content = read(ENV_EXAMPLE_PATH); + const mutated = content.replace( + 'DATABASE_URL=postgres://localhost:5432/eppp', + 'DATABASE_URL=postgres://eppp:eppp@db:5432/eppp', + ); + assert.notEqual(mutated, content, 'the mutation must actually replace the DATABASE_URL value'); + assert.throws( + () => assertTemplateHasPlaceholdersOnly(mutated), + /compose default credential|placeholder/, + ); +}); + +test('a long secret-looking value fails the criterion (mutation probe)', () => { + const content = read(ENV_EXAMPLE_PATH); + const mutated = content.replace( + 'EPPP_SESSION_SECRET=change-me-to-a-random-32-character-secret', + 'EPPP_SESSION_SECRET=aB3dE9fG0hI1jK2lM3nO4pQ5rS6tU7vW8xY9zA0', + ); + assert.notEqual(mutated, content, 'the mutation must actually replace the session secret'); + assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/); +}); + +test('a credential URI value fails the criterion (mutation probe)', () => { + const content = read(ENV_EXAMPLE_PATH); + const mutated = content.replace( + 'DATABASE_URL=postgres://localhost:5432/eppp', + 'DATABASE_URL=postgres://alice:supersecret@db.example.com:5432/eppp', + ); + assert.notEqual(mutated, content, 'the mutation must actually replace the DATABASE_URL value'); + assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /placeholder/); +}); + +test('a missing required variable fails the criterion (mutation probe)', () => { + const content = read(ENV_EXAMPLE_PATH); + const mutated = content.replace(/^EPPP_SESSION_SECRET=.*$/m, ''); + assert.notEqual( + mutated, + content, + 'the mutation must actually remove the EPPP_SESSION_SECRET assignment', + ); + assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /EPPP_SESSION_SECRET/); +}); + +test('dropping the !.env.example negation fails the criterion (mutation probe)', () => { + const gitignore = read('.gitignore'); + const mutated = gitignore.replace('!.env.example', ''); + assert.notEqual(mutated, gitignore, 'the mutation must actually drop the negation pattern'); + assert.throws( + () => { + assert.ok(mutated.includes('!.env.example'), 'the committed .env.example must be un-ignored'); + }, + /un-ignored/, + ); +}); + +test('a malformed non-assignment line fails the criterion (mutation probe)', () => { + const content = read(ENV_EXAMPLE_PATH); + const mutated = `${content}\nTHIS IS NOT A VALID LINE\n`; + assert.throws(() => assertTemplateHasPlaceholdersOnly(mutated), /not a comment/); +});