Files
accounted/tests/pg/full-archive-coverage.pg.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

204 lines
7.5 KiB
TypeScript

import { describe, it, expect, beforeAll } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import {
MASTER_DATA_DUMP_TABLES,
ARCHIVE_COVERED_ELSEWHERE_TABLES,
ARCHIVE_EXCLUDED_TABLES,
} from '@/lib/reports/full-archive-export'
/**
* Anti-rot guard for the full-archive backup (BFL 7-year retention).
*
* The säkerhetsbackup dumps company data table-by-table from an explicit list.
* That list rotted once already: salary, assets, dimensions, articles and
* rot/rut shipped without ever joining the dump, and three child tables were
* queried by a company_id column they do not have (silent error stubs in every
* backup). This test makes the schema and the dump contract converge:
*
* every public base table with a company_id column must be classified as
* DUMPED, COVERED ELSEWHERE in the archive, or EXCLUDED with a reason.
*
* A migration adding a company-scoped table fails here until the author makes
* an explicit call about its place in the backup.
*/
interface ColumnRow {
table_name: string
column_name: string
}
let columnsByTable: Map<string, Set<string>>
beforeAll(async () => {
const { rows } = await getPool().query<ColumnRow>(`
SELECT c.table_name, c.column_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
WHERE c.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
`)
columnsByTable = new Map()
for (const row of rows) {
let set = columnsByTable.get(row.table_name)
if (!set) {
set = new Set()
columnsByTable.set(row.table_name, set)
}
set.add(row.column_name)
}
})
function companyScopedTables(): string[] {
return [...columnsByTable.entries()]
.filter(([, cols]) => cols.has('company_id'))
.map(([name]) => name)
.sort()
}
describe('full-archive coverage contract', () => {
it('classifies every company-scoped table (new tables must be triaged)', () => {
const dumped = new Set(MASTER_DATA_DUMP_TABLES.map((t) => t.name))
const covered = new Set(Object.keys(ARCHIVE_COVERED_ELSEWHERE_TABLES))
const excluded = new Set(Object.keys(ARCHIVE_EXCLUDED_TABLES))
const unclassified = companyScopedTables().filter(
(t) => !dumped.has(t) && !covered.has(t) && !excluded.has(t)
)
expect(
unclassified,
`Company-scoped tables missing from the backup contract: ${unclassified.join(', ')}. ` +
'Add each to MASTER_DATA_DUMP_TABLES, ARCHIVE_COVERED_ELSEWHERE_TABLES or ' +
'ARCHIVE_EXCLUDED_TABLES in lib/reports/full-archive-export.ts (with a reason).'
).toEqual([])
})
it('classifies only tables that actually exist (catches renames and drops)', () => {
const allClassified = [
...MASTER_DATA_DUMP_TABLES.map((t) => t.name),
...Object.keys(ARCHIVE_COVERED_ELSEWHERE_TABLES),
...Object.keys(ARCHIVE_EXCLUDED_TABLES),
]
const missing = allClassified.filter((t) => !columnsByTable.has(t))
expect(
missing,
`Classified tables that no longer exist in the schema: ${missing.join(', ')}`
).toEqual([])
})
it('keeps the three classification buckets disjoint', () => {
const dumped = MASTER_DATA_DUMP_TABLES.map((t) => t.name)
const covered = Object.keys(ARCHIVE_COVERED_ELSEWHERE_TABLES)
const excluded = Object.keys(ARCHIVE_EXCLUDED_TABLES)
const seen = new Set<string>()
const duplicates: string[] = []
for (const name of [...dumped, ...covered, ...excluded]) {
if (seen.has(name)) duplicates.push(name)
seen.add(name)
}
expect(duplicates).toEqual([])
})
it('dumps direct tables by an existing company_id column', () => {
const broken = MASTER_DATA_DUMP_TABLES.filter(
(t) => !t.via && !columnsByTable.get(t.name)?.has('company_id')
).map((t) => t.name)
expect(
broken,
`Direct dump tables without a company_id column (the query would error and ` +
`the backup would contain an error stub): ${broken.join(', ')}`
).toEqual([])
})
it('pages every dump table by an existing unique key', () => {
const broken = MASTER_DATA_DUMP_TABLES.filter(
(t) => !columnsByTable.get(t.name)?.has(t.pageKey ?? 'id')
).map((t) => `${t.name}.${t.pageKey ?? 'id'}`)
expect(broken, `page keys missing from schema: ${broken.join(', ')}`).toEqual([])
})
it('fetches via-tables through a real parent/fk relationship', () => {
for (const t of MASTER_DATA_DUMP_TABLES) {
if (!t.via) continue
const childCols = columnsByTable.get(t.name)
expect(childCols, `via-table ${t.name} does not exist`).toBeDefined()
expect(
childCols!.has(t.via.fk),
`via-table ${t.name} has no column ${t.via.fk}`
).toBe(true)
expect(
childCols!.has('company_id'),
`via-table ${t.name} HAS a company_id column: dump it directly instead`
).toBe(false)
expect(
columnsByTable.get(t.via.parent)?.has('company_id'),
`via-parent ${t.via.parent} of ${t.name} has no company_id column`
).toBe(true)
}
})
it('denormalizes only parent columns that exist', () => {
const broken: string[] = []
for (const t of MASTER_DATA_DUMP_TABLES) {
if (!t.denormalize || !t.via) continue
const parentCols = columnsByTable.get(t.via.parent)
for (const column of t.denormalize.columns) {
if (!parentCols?.has(column)) broken.push(`${t.via.parent}.${column}`)
}
}
expect(
broken,
`denormalize columns missing from the parent schema: ${broken.join(', ')}`
).toEqual([])
})
it('never denormalizes over a real child column', () => {
const collisions: string[] = []
for (const t of MASTER_DATA_DUMP_TABLES) {
if (!t.denormalize) continue
const childCols = columnsByTable.get(t.name)
for (const column of t.denormalize.columns) {
const key = `${t.denormalize.prefix}${column}`
if (childCols?.has(key)) collisions.push(`${t.name}.${key}`)
}
}
expect(
collisions,
`denormalized keys collide with real child columns (the export keeps the ` +
`DB column and silently drops the parent value): ${collisions.join(', ')}`
).toEqual([])
})
it('gives every child of a multi-currency parent its own unit', () => {
// A child line row carries money but no currency of its own, so the unit
// is unrecoverable from its JSON file alone. Whenever the parent has a
// currency column, the child dump must copy it down.
const missing: string[] = []
for (const t of MASTER_DATA_DUMP_TABLES) {
if (!t.via) continue
if (!columnsByTable.get(t.via.parent)?.has('currency')) continue
if (!t.denormalize?.columns.includes('currency')) missing.push(t.name)
}
expect(
missing,
`child tables whose parent has a currency column but which do not ` +
`denormalize it: ${missing.join(', ')}. Add ` +
`denormalize: { prefix, columns: ['currency', ...] } in ` +
`lib/reports/full-archive-export.ts so the archived line states its unit.`
).toEqual([])
})
it('orders every dump query by columns that exist', () => {
const broken = MASTER_DATA_DUMP_TABLES.filter(
(t) => t.orderBy && !columnsByTable.get(t.name)?.has(t.orderBy)
).map((t) => `${t.name}.${t.orderBy}`)
expect(broken, `orderBy columns missing from schema: ${broken.join(', ')}`).toEqual([])
})
it('writes each table to a unique file', () => {
const files = MASTER_DATA_DUMP_TABLES.map((t) => t.file)
expect(new Set(files).size).toBe(files.length)
})
})