Testing Migrations
This commit is contained in:
1 parent
d9abeb7fb1
commit
9804771a5b
8 files changed
+1109
No files matched your search
@@ -0,0 +1,243 @@
|
||||
require('module-alias/register')
|
||||
|
||||
const Schema = require('@helpers/Schema')
|
||||
const db = require('@config/database')
|
||||
|
||||
afterAll(() => {
|
||||
db.end()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Schema.__reset()
|
||||
})
|
||||
|
||||
describe('verifyScrapeSchema against the real dev schema', () => {
|
||||
|
||||
test('reports ok once the migration has been applied', async () => {
|
||||
const result = await Schema.verifyScrapeSchema()
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`dev scrape schema not applied - run ./migrateDatabase.sh. ` +
|
||||
`missing objects: ${result.missingObjects.join(', ') || 'none'}; ` +
|
||||
`missing privileges: ${result.missingPrivileges.join(', ') || 'none'}`
|
||||
)
|
||||
}
|
||||
|
||||
expect(result.missingObjects).toEqual([])
|
||||
expect(result.missingPrivileges).toEqual([])
|
||||
expect(result.remedy).toBe('')
|
||||
})
|
||||
|
||||
test('isScrapeSchemaReady reflects the last verification', async () => {
|
||||
expect(Schema.isScrapeSchemaReady()).toBe(false)
|
||||
|
||||
await Schema.verifyScrapeSchema()
|
||||
expect(Schema.isScrapeSchemaReady()).toBe(true)
|
||||
|
||||
Schema.__reset()
|
||||
expect(Schema.isScrapeSchemaReady()).toBe(false)
|
||||
})
|
||||
|
||||
test('__setReady is honoured, so request paths can be driven in tests', () => {
|
||||
Schema.__setReady(true)
|
||||
expect(Schema.isScrapeSchemaReady()).toBe(true)
|
||||
Schema.__setReady(false)
|
||||
expect(Schema.isScrapeSchemaReady()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
//
|
||||
// The FULLTEXT index is the one object whose loss is silent: MATCH..AGAINST
|
||||
// against a BTREE index returns nothing without erroring. Guard it by type.
|
||||
//
|
||||
describe('FULLTEXT index guard', () => {
|
||||
|
||||
test('attachment_text_index exists and is actually FULLTEXT', async () => {
|
||||
await expect(Schema.__fulltextIndexExists('attachment', 'attachment_text_index'))
|
||||
.resolves.toBe(true)
|
||||
})
|
||||
|
||||
test('a BTREE index of the same shape would NOT satisfy the check', async () => {
|
||||
// attachment_scrape_state is a real BTREE index, so this proves the
|
||||
// check discriminates on type rather than just on name existing.
|
||||
await expect(Schema.__indexExists('attachment', 'attachment_scrape_state'))
|
||||
.resolves.toBe(true)
|
||||
await expect(Schema.__fulltextIndexExists('attachment', 'attachment_scrape_state'))
|
||||
.resolves.toBe(false)
|
||||
})
|
||||
|
||||
test('verifyScrapeSchema includes it among the verified objects', async () => {
|
||||
const result = await Schema.verifyScrapeSchema()
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.missingObjects).toEqual([])
|
||||
})
|
||||
|
||||
test('MATCH..AGAINST actually works, not just that an index exists', async () => {
|
||||
// The functional end of the guard. An index of the right type that
|
||||
// somehow could not be searched would still be a broken search.
|
||||
const [rows] = await db.promise().query(
|
||||
`SELECT COUNT(*) AS n FROM attachment
|
||||
WHERE MATCH(text) AGAINST(? IN NATURAL LANGUAGE MODE)`,
|
||||
['the']
|
||||
)
|
||||
// Any result (including 0 matches) proves the query planner accepted
|
||||
// MATCH..AGAINST against this column, which a BTREE index would reject.
|
||||
expect(typeof rows[0].n).toBe('number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('object existence checks', () => {
|
||||
|
||||
test('finds the real table, columns and index', async () => {
|
||||
await expect(Schema.__tableExists('scrape_cache')).resolves.toBe(true)
|
||||
await expect(Schema.__columnExists('attachment', 'scrape_state')).resolves.toBe(true)
|
||||
await expect(Schema.__columnExists('attachment', 'scrape_attempts')).resolves.toBe(true)
|
||||
await expect(Schema.__columnExists('attachment', 'enqueued_at')).resolves.toBe(true)
|
||||
await expect(Schema.__indexExists('attachment', 'attachment_scrape_state')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
test('reports absence rather than throwing for things that do not exist', async () => {
|
||||
await expect(Schema.__tableExists('no_such_table_xyz')).resolves.toBe(false)
|
||||
await expect(Schema.__columnExists('attachment', 'no_such_column_xyz')).resolves.toBe(false)
|
||||
await expect(Schema.__indexExists('attachment', 'no_such_index_xyz')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
//
|
||||
// The privilege check is what turns a future narrow-grants problem into one
|
||||
// clear boot message instead of a swallowed runtime failure.
|
||||
//
|
||||
describe('parseGrants', () => {
|
||||
|
||||
const DB = 'application'
|
||||
const TABLES = ['attachment', 'scrape_cache']
|
||||
const parse = (lines) => Schema.parseGrants(lines, DB, TABLES)
|
||||
|
||||
test('ALL PRIVILEGES ON *.* grants everything', () => {
|
||||
const granted = parse(['GRANT ALL PRIVILEGES ON *.* TO `root`@`localhost` WITH GRANT OPTION'])
|
||||
|
||||
expect(granted.has('SELECT')).toBe(true)
|
||||
expect(granted.has('INSERT')).toBe(true)
|
||||
expect(granted.has('UPDATE')).toBe(true)
|
||||
expect(granted.has('DELETE')).toBe(true)
|
||||
})
|
||||
|
||||
test('ALL PRIVILEGES scoped to the database grants everything', () => {
|
||||
const granted = parse(['GRANT ALL PRIVILEGES ON `application`.* TO `app`@`localhost`'])
|
||||
expect(granted.has('DELETE')).toBe(true)
|
||||
})
|
||||
|
||||
test('reads an explicit privilege list', () => {
|
||||
const granted = parse([
|
||||
'GRANT USAGE ON *.* TO `app`@`localhost`',
|
||||
'GRANT SELECT, INSERT, UPDATE, DELETE ON `application`.* TO `app`@`localhost`',
|
||||
])
|
||||
|
||||
expect([...granted].sort()).toEqual(['DELETE', 'INSERT', 'SELECT', 'UPDATE'])
|
||||
})
|
||||
|
||||
// The case that caused this whole change class.
|
||||
test('spots a missing DELETE', () => {
|
||||
const granted = parse(['GRANT SELECT, INSERT, UPDATE ON `application`.* TO `app`@`localhost`'])
|
||||
|
||||
expect(granted.has('UPDATE')).toBe(true)
|
||||
expect(granted.has('DELETE')).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts table-scoped grants for our tables', () => {
|
||||
const granted = parse([
|
||||
'GRANT SELECT, DELETE ON `application`.`attachment` TO `app`@`localhost`',
|
||||
'GRANT INSERT, UPDATE ON `application`.`scrape_cache` TO `app`@`localhost`',
|
||||
])
|
||||
|
||||
expect([...granted].sort()).toEqual(['DELETE', 'INSERT', 'SELECT', 'UPDATE'])
|
||||
})
|
||||
|
||||
test('ignores grants on an unrelated database', () => {
|
||||
const granted = parse(['GRANT ALL PRIVILEGES ON `somethingelse`.* TO `app`@`localhost`'])
|
||||
expect(granted.size).toBe(0)
|
||||
})
|
||||
|
||||
test('ignores USAGE, which conveys no privileges', () => {
|
||||
const granted = parse(['GRANT USAGE ON *.* TO `app`@`localhost`'])
|
||||
expect(granted.size).toBe(0)
|
||||
})
|
||||
|
||||
test('handles grants written without backticks', () => {
|
||||
const granted = parse(['GRANT SELECT, DELETE ON application.* TO app@localhost'])
|
||||
expect(granted.has('DELETE')).toBe(true)
|
||||
})
|
||||
|
||||
test('strips column-level qualifiers', () => {
|
||||
const granted = parse(['GRANT SELECT (id, url), UPDATE (text) ON `application`.`attachment` TO `app`@`localhost`'])
|
||||
|
||||
expect(granted.has('SELECT')).toBe(true)
|
||||
expect(granted.has('UPDATE')).toBe(true)
|
||||
})
|
||||
|
||||
test('ignores lines that are not grant statements', () => {
|
||||
expect(parse(['', 'not a grant line', null]).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
//
|
||||
// The regression that matters most.
|
||||
//
|
||||
// Schema.js used to issue CREATE TABLE / ALTER TABLE through the application's
|
||||
// own connection pool, which failed on production's least-privilege user with
|
||||
// "CREATE command denied to user 'app'@'localhost'". Nothing in this module may
|
||||
// ever issue DDL again.
|
||||
//
|
||||
describe('issues no DDL', () => {
|
||||
|
||||
test('verifyScrapeSchema runs only read-only statements', async () => {
|
||||
const statements = []
|
||||
|
||||
// db.promise() hands back a NEW wrapper on every call, so spying on one
|
||||
// instance would miss the one Schema.js obtains internally. Intercept
|
||||
// the factory and instrument each wrapper it returns.
|
||||
const realPromise = db.promise.bind(db)
|
||||
const spy = jest.spyOn(db, 'promise').mockImplementation(() => {
|
||||
const wrapper = realPromise()
|
||||
const originalQuery = wrapper.query.bind(wrapper)
|
||||
|
||||
wrapper.query = (sql, params) => {
|
||||
statements.push(String(sql))
|
||||
return originalQuery(sql, params)
|
||||
}
|
||||
|
||||
return wrapper
|
||||
})
|
||||
|
||||
await Schema.verifyScrapeSchema()
|
||||
|
||||
spy.mockRestore()
|
||||
|
||||
expect(statements.length).toBeGreaterThan(0)
|
||||
|
||||
const forbidden = /\b(CREATE|ALTER|DROP|TRUNCATE|RENAME)\b/i
|
||||
const offenders = statements.filter(statement => forbidden.test(statement))
|
||||
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
test('the module source contains no DDL outside comments', () => {
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'helpers', 'Schema.js'), 'utf8'
|
||||
)
|
||||
|
||||
const code = source
|
||||
.split('\n')
|
||||
.filter(line => !line.trim().startsWith('//'))
|
||||
.join('\n')
|
||||
|
||||
expect(code).not.toMatch(/\bCREATE\s+TABLE\b/i)
|
||||
expect(code).not.toMatch(/\bALTER\s+TABLE\b/i)
|
||||
expect(code).not.toMatch(/\bCREATE\s+INDEX\b/i)
|
||||
expect(code).not.toMatch(/\bDROP\b/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
require('module-alias/register')
|
||||
|
||||
const { execFileSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const REPO = path.join(__dirname, '..', '..', '..')
|
||||
|
||||
//
|
||||
// These shell out to the Prisma CLI, which only exists inside nix-shell. Skip
|
||||
// rather than fail when it is absent, so `npx jest` still works outside the
|
||||
// shell -- but do NOT skip silently in a way that hides real drift when it IS
|
||||
// available.
|
||||
//
|
||||
const hasPrisma = (() => {
|
||||
try {
|
||||
execFileSync('prisma', ['--version'], { stdio: 'ignore' })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
const describeWithPrisma = hasPrisma ? describe : describe.skip
|
||||
|
||||
if (!hasPrisma) {
|
||||
console.log('[schema tests] prisma not on PATH - skipping. Run inside nix-shell to exercise these.')
|
||||
}
|
||||
|
||||
const prisma = (args) => execFileSync('prisma', args, {
|
||||
cwd: REPO,
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
})
|
||||
|
||||
describe('committed migration artifacts', () => {
|
||||
|
||||
test('the baseline migration and lock file are present', () => {
|
||||
expect(fs.existsSync(path.join(REPO, 'prisma/schema.prisma'))).toBe(true)
|
||||
expect(fs.existsSync(path.join(REPO, 'prisma/migrations/0_init/migration.sql'))).toBe(true)
|
||||
expect(fs.existsSync(path.join(REPO, 'prisma/migrations/migration_lock.toml'))).toBe(true)
|
||||
})
|
||||
|
||||
//
|
||||
// Prisma emits COLLATE utf8mb4_unicode_ci for MySQL, but every table in this
|
||||
// database is utf8mb4_general_ci, and `prisma migrate diff` does NOT report
|
||||
// the difference. So a regenerated migration would silently build a
|
||||
// differently-collated database. This asserts the hand-correction is intact.
|
||||
//
|
||||
test('no migration declares a collation other than utf8mb4_general_ci', () => {
|
||||
const dir = path.join(REPO, 'prisma/migrations')
|
||||
const files = fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => path.join(dir, entry.name, 'migration.sql'))
|
||||
.filter(file => fs.existsSync(file))
|
||||
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
|
||||
files.forEach(file => {
|
||||
const ddl = fs.readFileSync(file, 'utf8')
|
||||
.split('\n')
|
||||
.filter(line => !line.trim().startsWith('--'))
|
||||
.join('\n')
|
||||
|
||||
const collations = [...ddl.matchAll(/COLLATE\s+([A-Za-z0-9_]+)/gi)].map(match => match[1])
|
||||
collations.forEach(collation => {
|
||||
expect(collation).toBe('utf8mb4_general_ci')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('the baseline creates the FULLTEXT index, not a plain one', () => {
|
||||
const sql = fs.readFileSync(path.join(REPO, 'prisma/migrations/0_init/migration.sql'), 'utf8')
|
||||
|
||||
expect(sql).toMatch(/FULLTEXT INDEX `attachment_text_index`/)
|
||||
// A plain INDEX of that name would be the silent-failure mode.
|
||||
expect(sql).not.toMatch(/^\s*INDEX `attachment_text_index`/m)
|
||||
})
|
||||
|
||||
//
|
||||
// @@fulltext is GA in Prisma 7 -- the `fullTextIndex` preview flag no longer
|
||||
// exists in the CLI at all (verified: zero occurrences in the 7.8.0 bundle).
|
||||
// It was silently ignored rather than rejected, which makes it exactly the
|
||||
// kind of dead config that misleads the next reader.
|
||||
//
|
||||
test('the schema declares @@fulltext and no longer carries the dead preview flag', () => {
|
||||
const schema = fs.readFileSync(path.join(REPO, 'prisma/schema.prisma'), 'utf8')
|
||||
|
||||
expect(schema).toMatch(/@@fulltext\(\[text\]/)
|
||||
expect(schema).not.toMatch(/fullTextIndex/)
|
||||
})
|
||||
|
||||
//
|
||||
// No generator block means `prisma generate` cannot produce a client into
|
||||
// this repo. That is the structural guarantee behind "migrations only" --
|
||||
// stronger than a convention, because it cannot be done by accident.
|
||||
//
|
||||
test('the schema declares no generator, so no client can be generated', () => {
|
||||
const schema = fs.readFileSync(path.join(REPO, 'prisma/schema.prisma'), 'utf8')
|
||||
|
||||
expect(schema).not.toMatch(/generator\s+\w+\s*\{/)
|
||||
expect(schema).toMatch(/datasource\s+db\s*\{/)
|
||||
})
|
||||
|
||||
//
|
||||
// The running server must never depend on Prisma.
|
||||
//
|
||||
test('no application code imports prisma', () => {
|
||||
const { execFileSync } = require('child_process')
|
||||
let hits = ''
|
||||
try {
|
||||
hits = execFileSync('grep', [
|
||||
'-rn', '--include=*.js',
|
||||
'-E', "require\\(['\"]@?prisma", path.join(REPO, 'server'),
|
||||
], { encoding: 'utf8' })
|
||||
} catch (error) {
|
||||
hits = '' // grep exits 1 when nothing matches
|
||||
}
|
||||
expect(hits.trim()).toBe('')
|
||||
})
|
||||
|
||||
test('no @relation is introduced - this database has no foreign keys', () => {
|
||||
// Adding relations would mean adding FK constraints to a live database,
|
||||
// which is a schema change well outside adopting a migration tool.
|
||||
const schema = fs.readFileSync(path.join(REPO, 'prisma/schema.prisma'), 'utf8')
|
||||
expect(schema).not.toMatch(/@relation/)
|
||||
})
|
||||
})
|
||||
|
||||
describeWithPrisma('schema drift', () => {
|
||||
|
||||
//
|
||||
// The check that keeps schema.prisma honest: if the live database and the
|
||||
// committed schema ever diverge, this fails.
|
||||
//
|
||||
test('schema.prisma matches the live database exactly', () => {
|
||||
const output = prisma([
|
||||
'migrate', 'diff',
|
||||
'--from-config-datasource',
|
||||
'--to-schema', 'prisma/schema.prisma',
|
||||
'--script',
|
||||
])
|
||||
|
||||
expect(output).toMatch(/This is an empty migration/)
|
||||
})
|
||||
|
||||
//
|
||||
// The load-bearing guarantee: the committed migrations, applied to an empty
|
||||
// database, reproduce the live schema exactly.
|
||||
//
|
||||
// Done by actually rebuilding into a scratch database and comparing
|
||||
// information_schema, rather than via `migrate diff --from-migrations`
|
||||
// (which requires a shadow database) -- and this is the stronger check
|
||||
// anyway, because it also catches collation drift, which Prisma's own diff
|
||||
// does NOT report.
|
||||
//
|
||||
test('the committed migrations rebuild the live schema exactly', () => {
|
||||
const db = require('@config/database')
|
||||
const SCRATCH = 'prisma_drift_check'
|
||||
|
||||
const mysqlBin = (() => {
|
||||
try { execFileSync('mariadb', ['--version'], { stdio: 'ignore' }); return 'mariadb' }
|
||||
catch (error) { return 'mysql' }
|
||||
})()
|
||||
|
||||
const adminArgs = ['-u', 'root']
|
||||
const socket = path.join(REPO, '.mysql', 'mysql.sock')
|
||||
if (fs.existsSync(socket)) { adminArgs.push(`--socket=${socket}`) }
|
||||
|
||||
const admin = (sql, database) => execFileSync(
|
||||
mysqlBin,
|
||||
[...adminArgs, '-N', '-B', ...(database ? [database] : []), '-e', sql],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
|
||||
try {
|
||||
admin(`DROP DATABASE IF EXISTS \`${SCRATCH}\`; CREATE DATABASE \`${SCRATCH}\`;`)
|
||||
|
||||
execFileSync('prisma', ['migrate', 'deploy'], {
|
||||
cwd: REPO,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DATABASE_URL: `mysql://[email protected]:3306/${SCRATCH}` },
|
||||
})
|
||||
|
||||
const snapshot = (schema) => ({
|
||||
columns: admin(
|
||||
`SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLLATION_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='${schema}' AND TABLE_NAME <> '_prisma_migrations'
|
||||
ORDER BY TABLE_NAME, COLUMN_NAME`
|
||||
),
|
||||
indexes: admin(
|
||||
`SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE, INDEX_TYPE
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA='${schema}' AND TABLE_NAME <> '_prisma_migrations'
|
||||
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`
|
||||
),
|
||||
collations: admin(
|
||||
`SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA='${schema}' AND TABLE_NAME <> '_prisma_migrations'
|
||||
ORDER BY TABLE_NAME`
|
||||
),
|
||||
})
|
||||
|
||||
const live = snapshot('application')
|
||||
const rebuilt = snapshot(SCRATCH)
|
||||
|
||||
expect(rebuilt.columns).toBe(live.columns)
|
||||
expect(rebuilt.indexes).toBe(live.indexes)
|
||||
expect(rebuilt.collations).toBe(live.collations)
|
||||
} finally {
|
||||
try { admin(`DROP DATABASE IF EXISTS \`${SCRATCH}\`;`) } catch (error) { /* best effort */ }
|
||||
}
|
||||
})
|
||||
|
||||
test('migrate status reports no pending migrations', () => {
|
||||
const output = prisma(['migrate', 'status'])
|
||||
expect(output).toMatch(/Database schema is up to date/)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user