Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/dev/manifest-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,14 @@ The e2e suite connects with `dropSchema: true` (helpers and the migration specs)

To use a Postgres you manage yourself, point it at a database dedicated to tests: `DATABASE_URL=postgresql://user:pass@host:port/thinker_e2e_test THINKER_E2E_ALLOW_DROP=1 npm run test:e2e --workspace=packages/backend`. Never point it at a development database or at a port owned by another project.

New specs can't skip the guard either (issue #108). `test/e2e-db-guard-scan.spec.ts` runs in the unit suite (`npm test`). It parses every `.ts` file under `packages/backend/test` with the TypeScript AST, so comments don't count, and fails on any of these:

- `unguarded-drop`: a file wipes the database without calling `resolveE2eDatabaseUrl()` or `createTestApp()`. Wiping means `dropSchema` set to anything but a literal `false`, `DROP SCHEMA`/`DROP DATABASE` in a string, `.dropDatabase()` or `.synchronize(true)`.
- `database-url-fallback`: `process.env.DATABASE_URL ?? ...` or `|| ...`.
- `default-database`: a string naming `localhost:5432` or `mydatabase`.

`e2e-db-guard.spec.ts` may contain those strings, because it proves the guard refuses them. The scanner's own spec isn't scanned. Its negative cases live in `test/fixtures/e2e-db-guard/*.fixture`, whose extension keeps them out of the scan and out of Jest.

### Database Migrations

TypeORM migrations run automatically on app startup by default (gated by `RUN_MIGRATIONS_ON_BOOT`, default `true`). Schema sync (`synchronize`) is permanently disabled — all schema changes must go through migrations.
Expand Down
178 changes: 178 additions & 0 deletions packages/backend/test/e2e-db-guard-scan.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
DEFAULT_DATABASE_STRING_EXEMPT,
SCAN_EXEMPT,
GuardScanViolation,
formatViolations,
listTestSources,
scanSource,
scanTestTree,
} from './e2e-db-guard-scan';

const TEST_DIR = __dirname;
const FIXTURES = join(__dirname, 'fixtures', 'e2e-db-guard');

function fixture(name: string): string {
return readFileSync(join(FIXTURES, name), 'utf8');
}

function rules(violations: readonly GuardScanViolation[]): string[] {
return violations.map((v) => v.rule).sort();
}

describe('e2e database guard scan (issue #108)', () => {
describe('the backend test tree', () => {
it('has no spec that bypasses resolveE2eDatabaseUrl()/createTestApp()', () => {
const violations = scanTestTree(TEST_DIR);
// On failure the message lists file:line and the rule for each hit.
expect(formatViolations(violations)).toBe('');
});

it('actually sees the specs that drop the schema', () => {
const files = listTestSources(TEST_DIR);
expect(files).toEqual(
expect.arrayContaining([
'helpers.ts',
'e2e-db-guard.spec.ts',
'request-backfill-fallbacks.e2e-spec.ts',
'drop-saas-feature-schema-migration.e2e-spec.ts',
]),
);
expect(files.length).toBeGreaterThan(50);
expect(files.some((f) => f.includes('fixtures/'))).toBe(false);
});

it('only exempts files that exist', () => {
const files = listTestSources(TEST_DIR);
for (const exempt of [...DEFAULT_DATABASE_STRING_EXEMPT, ...SCAN_EXEMPT]) {
expect(files).toContain(exempt);
}
});
});

describe('fixtures', () => {
it('catches the legacy migration-spec pattern', () => {
const violations = scanSource(
'legacy-migration.e2e-spec.ts',
fixture('legacy-migration.e2e-spec.ts.fixture'),
);
expect(rules(violations)).toEqual([
'database-url-fallback',
'default-database',
'unguarded-drop',
]);
expect(violations.find((v) => v.rule === 'unguarded-drop')).toMatchObject({
line: 15,
detail: expect.stringContaining('dropSchema: true'),
});
expect(violations.find((v) => v.rule === 'database-url-fallback')?.line).toBe(6);
});

it('catches raw DROP SCHEMA SQL and a || fallback', () => {
const violations = scanSource(
'raw-drop-schema.e2e-spec.ts',
fixture('raw-drop-schema.e2e-spec.ts.fixture'),
);
expect(rules(violations)).toEqual(['database-url-fallback', 'unguarded-drop']);
expect(violations.find((v) => v.rule === 'unguarded-drop')).toMatchObject({
line: 9,
detail: expect.stringContaining('"DROP SCHEMA" SQL'),
});
});

it('accepts the guarded pattern and ignores comments', () => {
expect(
scanSource(
'guarded-migration.e2e-spec.ts',
fixture('guarded-migration.e2e-spec.ts.fixture'),
),
).toEqual([]);
});

it('fails a tree scan when a legacy spec is added', () => {
const dir = mkdtempSync(join(tmpdir(), 'thinker-e2e-guard-scan-'));
try {
mkdirSync(join(dir, 'nested'));
copyFileSync(
join(FIXTURES, 'guarded-migration.e2e-spec.ts.fixture'),
join(dir, 'guarded-migration.e2e-spec.ts'),
);
expect(scanTestTree(dir)).toEqual([]);

copyFileSync(
join(FIXTURES, 'legacy-migration.e2e-spec.ts.fixture'),
join(dir, 'nested', 'legacy-migration.e2e-spec.ts'),
);
const violations = scanTestTree(dir);
expect(new Set(violations.map((v) => v.file))).toEqual(
new Set(['nested/legacy-migration.e2e-spec.ts']),
);
expect(formatViolations(violations)).toContain(
'test/nested/legacy-migration.e2e-spec.ts:15 [unguarded-drop]',
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

describe('scanSource rules', () => {
const scan = (source: string) => rules(scanSource('inline.ts', source));

it('treats dropSchema: false as safe and any other value as a drop', () => {
expect(scan(`new DataSource({ url, dropSchema: false });`)).toEqual([]);
expect(scan(`new DataSource({ url, dropSchema: reset });`)).toEqual(['unguarded-drop']);
expect(scan(`new DataSource({ url, 'dropSchema': true });`)).toEqual(['unguarded-drop']);
expect(scan(`const dropSchema = true; new DataSource({ url, dropSchema });`)).toEqual([
'unguarded-drop',
]);
});

it('flags other ways to wipe the database', () => {
expect(scan(`await ds.dropDatabase();`)).toEqual(['unguarded-drop']);
expect(scan(`await queryRunner.dropSchema('public', true, true);`)).toEqual([
'unguarded-drop',
]);
expect(scan(`await ds.synchronize(true);`)).toEqual(['unguarded-drop']);
expect(scan(`await ds.synchronize();`)).toEqual([]);
expect(scan('await ds.query(`drop schema public cascade`);')).toEqual(['unguarded-drop']);
expect(scan(`await ds.query('DROP DATABASE x');`)).toEqual(['unguarded-drop']);
});

it('needs a call to the guard, not just an import', () => {
const importOnly = `import { resolveE2eDatabaseUrl } from './e2e-db-guard';
new DataSource({ url: process.env['E2E_URL'], dropSchema: true });`;
expect(scan(importOnly)).toEqual(['unguarded-drop']);
expect(scan(`new DataSource({ url: resolveE2eDatabaseUrl(), dropSchema: true });`)).toEqual(
[],
);
expect(scan(`app = await createTestApp(); await ds.query('DROP SCHEMA x CASCADE');`)).toEqual(
[],
);
expect(scan(`app = await helpers.createTestApp({ dropSchema: true });`)).toEqual([]);
});

it('flags any DATABASE_URL fallback, guarded or not', () => {
expect(scan(`const u = process.env.DATABASE_URL ?? other;`)).toEqual([
'database-url-fallback',
]);
expect(scan(`const u = process.env['DATABASE_URL']! || other;`)).toEqual([
'database-url-fallback',
]);
expect(scan(`const u = process.env['OTHER_URL'] ?? other;`)).toEqual([]);
expect(scan(`const saved = process.env['DATABASE_URL'];`)).toEqual([]);
});

it('flags strings naming the development database unless exempt', () => {
const source = 'const u = `postgresql://u:p@localhost:5432/${name}`;';
expect(scan(source)).toEqual(['default-database']);
expect(scan(`const db = 'MyDatabase';`)).toEqual(['default-database']);
expect(
scanSource('e2e-db-guard.spec.ts', source, { allowDefaultDatabaseStrings: true }),
).toEqual([]);
expect(scan(`// localhost:5432/mydatabase in a comment\nconst port = 5432;`)).toEqual([]);
});
});
});
Loading
Loading