Bug/year end failure (#575)

* feat: implement findNextPeriod function and integrate into year-end closing logic

* feat: add integrity check for PDF documents and enhance user feedback for corrupt files

* Refactor year-end service and period creation logic for improved UTC handling and error messaging

- Update `validateYearEndReadiness` to assert on stable warning messages without interpolating period names.
- Modify `createNextPeriod` to ensure date calculations are performed in UTC, preventing DST-related issues.
- Enhance error handling in `validateYearEndReadiness` and `executeYearEndClosing` to avoid exposing database details.
- Introduce structured error messages for year-end processes in `structured-errors.ts`.
- Add tests for document integrity checks, ensuring proper authentication and error handling.
- Implement GUC checks in document versioning to prevent unauthorized modifications and ensure company membership.
- Update migration scripts to reflect changes in document immutability enforcement.

* fix: add comment to clarify GUC behavior in document supersession logic
This commit is contained in:
Mattsson
2026-05-27 14:19:30 +02:00
committed by GitHub
parent a2a556d837
commit 8979f6eda3
14 changed files with 1214 additions and 70 deletions
@@ -1,8 +1,11 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { getPool, withUserContext } from '@/tests/pg/setup'
import {
insertAuthUser,
insertBalancedLines,
insertCompany,
insertCompanyMember,
insertDraftJournalEntry,
seedCompany,
} from '@/tests/pg/fixtures'
@@ -231,3 +234,266 @@ describe('document-immutability.pg — BFL retention bypass guards', () => {
}
})
})
// The supersession flow needs to flip the OLD row from is_current_version=true
// to false. The metadata-immutability trigger blocks that change for docs
// linked to posted entries unless the gnubok.allow_supersede GUC is set —
// which create_document_version sets before its UPDATE. Without this, users
// have no way to replace a corrupt underlag (e.g. a bad PDF uploaded via the
// MCP server before magic-byte validation).
describe('document-immutability.pg — version supersession on posted entries', () => {
it('allows create_document_version on a doc linked to a posted entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
// create_document_version is SECURITY DEFINER but enforces auth.uid()
// matches p_user_id and that the caller is a company member, so the
// call must run under withUserContext(userId).
const newId = await withUserContext(userId, async (client) => {
const result = await client.query<{ new_id: string }>(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
) AS new_id`,
[
userId,
docId,
`documents/${userId}/replacement.pdf`,
'replacement.pdf',
2048,
'application/pdf',
'b'.repeat(64),
],
)
const id = result.rows[0]!.new_id
// The transaction is rolled back by withUserContext, so we have to
// assert state from within the same client/transaction.
const oldRow = await client.query<{
is_current_version: boolean
superseded_by_id: string | null
journal_entry_id: string | null
}>(
`SELECT is_current_version, superseded_by_id, journal_entry_id
FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(oldRow.rows[0]!.is_current_version).toBe(false)
expect(oldRow.rows[0]!.superseded_by_id).toBe(id)
expect(oldRow.rows[0]!.journal_entry_id).toBe(entryId)
const newRow = await client.query<{
is_current_version: boolean
version: number
journal_entry_id: string | null
prev_version_hash: string | null
}>(
`SELECT is_current_version, version, journal_entry_id, prev_version_hash
FROM public.document_attachments WHERE id = $1`,
[id],
)
expect(newRow.rows[0]!.is_current_version).toBe(true)
expect(newRow.rows[0]!.version).toBe(2)
expect(newRow.rows[0]!.journal_entry_id).toBe(entryId)
expect(newRow.rows[0]!.prev_version_hash).toBe('a'.repeat(64))
return id
})
expect(newId).toBeDefined()
})
it('allows create_document_version on a doc linked to a reversed entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1, status: 'reversed',
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
await withUserContext(userId, async (client) => {
const result = await client.query(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
)`,
[
userId, docId,
`documents/${userId}/replacement.pdf`,
'replacement.pdf', 2048, 'application/pdf', 'c'.repeat(64),
],
)
expect(result.rows).toHaveLength(1)
})
})
// Cross-tenant attack: a member of company A calls create_document_version
// with a document id that belongs to company B. Before the auth+membership
// check landed, the SECURITY DEFINER function would happily mutate the
// foreign company's row because the GUC bypass disarmed the immutability
// trigger. The membership guard inside the function is the only line of
// defence — PostgREST exposes the RPC to all authenticated users.
it('rejects create_document_version when caller is not a member of the document company', async () => {
const { userId: aliceId, companyId: aliceCompanyId, fiscalPeriodId: aliceFp } =
await seedCompany()
const entryId = await insertEntryAtStatus({
userId: aliceId,
companyId: aliceCompanyId,
fiscalPeriodId: aliceFp,
voucherNumber: 1,
})
const docId = await insertDocument({
userId: aliceId,
companyId: aliceCompanyId,
journalEntryId: entryId,
})
// Bob has his own company; he is NOT a member of Alice's company.
const bobId = await insertAuthUser()
const bobCompany = await insertCompany({ createdBy: bobId })
await insertCompanyMember({ companyId: bobCompany, userId: bobId, role: 'owner' })
await withUserContext(bobId, async (client) => {
await expect(
client.query(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
)`,
[
bobId, docId,
`documents/${bobId}/exfil.pdf`,
'exfil.pdf', 2048, 'application/pdf', 'd'.repeat(64),
],
),
).rejects.toThrow(/not a member/i)
})
})
// Identity-spoofing attack: caller passes p_user_id ≠ auth.uid(). Allowing
// this would let any authenticated user manufacture supersession events
// attributed to a different user — useful for audit-log forgery even when
// the supersession itself is legitimate.
it('rejects create_document_version when p_user_id does not match auth.uid()', async () => {
const { userId: aliceId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId: aliceId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({
userId: aliceId, companyId, journalEntryId: entryId,
})
// Mallory is also a member of the company — so the membership check
// alone would not catch a spoofed p_user_id.
const malloryId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: malloryId, role: 'member' })
await withUserContext(malloryId, async (client) => {
await expect(
client.query(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
)`,
[
// Spoofing aliceId as the actor while auth.uid() is malloryId.
aliceId, docId,
`documents/${aliceId}/spoofed.pdf`,
'spoofed.pdf', 2048, 'application/pdf', 'e'.repeat(64),
],
),
).rejects.toThrow(/does not match authenticated user/i)
})
})
// The narrowed bypass: even with allow_supersede set, a direct UPDATE that
// tries to change sha256_hash, journal_entry_id, or any other audit field
// must still be blocked. Before the narrowing the GUC was a blanket
// bypass that disarmed the entire trigger.
it('keeps blocking sha256_hash mutation even when allow_supersede is set', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_supersede', 'true', true)`)
await expect(
client.query(
`UPDATE public.document_attachments SET sha256_hash = $2 WHERE id = $1`,
[docId, 'f'.repeat(64)],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('keeps blocking journal_entry_id mutation even when allow_supersede is set', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryA = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const entryB = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 2,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryA })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_supersede', 'true', true)`)
await expect(
client.query(
`UPDATE public.document_attachments SET journal_entry_id = $1 WHERE id = $2`,
[entryB, docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('rejects direct UPDATE flipping is_current_version without the GUC', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
await expect(
getPool().query(
`UPDATE public.document_attachments SET is_current_version = false WHERE id = $1`,
[docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
})
it('respects gnubok.allow_supersede bypass on direct UPDATE', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_supersede', 'true', true)`)
await client.query(
`UPDATE public.document_attachments SET is_current_version = false WHERE id = $1`,
[docId],
)
const after = await client.query<{ is_current_version: boolean }>(
`SELECT is_current_version FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(after.rows[0]!.is_current_version).toBe(false)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
})