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:
@@ -29,7 +29,7 @@ function makeClient() {
|
||||
}
|
||||
}
|
||||
|
||||
import { lockPeriod, unlockPeriod, closePeriod, createNextPeriod } from '../period-service'
|
||||
import { lockPeriod, unlockPeriod, closePeriod, createNextPeriod, findNextPeriod } from '../period-service'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -230,3 +230,78 @@ describe('createNextPeriod', () => {
|
||||
expect(result.period_end).toBe('2025-06-30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findNextPeriod', () => {
|
||||
it('returns the period chained via previous_period_id', async () => {
|
||||
const current = makeFiscalPeriod({
|
||||
id: 'fp-2024',
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-12-31',
|
||||
})
|
||||
const next = makeFiscalPeriod({
|
||||
id: 'fp-2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
previous_period_id: 'fp-2024',
|
||||
})
|
||||
|
||||
results = [
|
||||
{ data: current, error: null }, // fetch current
|
||||
{ data: next, error: null }, // chained lookup (.maybeSingle)
|
||||
]
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024')
|
||||
expect(result?.id).toBe('fp-2025')
|
||||
})
|
||||
|
||||
it('falls back to period_start lookup when chain is missing', async () => {
|
||||
const current = makeFiscalPeriod({
|
||||
id: 'fp-2024',
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-12-31',
|
||||
})
|
||||
const next = makeFiscalPeriod({
|
||||
id: 'fp-2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
previous_period_id: null,
|
||||
})
|
||||
|
||||
results = [
|
||||
{ data: current, error: null }, // fetch current
|
||||
{ data: null, error: null }, // chained lookup misses
|
||||
{ data: next, error: null }, // date lookup hits
|
||||
]
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024')
|
||||
expect(result?.id).toBe('fp-2025')
|
||||
})
|
||||
|
||||
it('returns null when no next period exists', async () => {
|
||||
const current = makeFiscalPeriod({
|
||||
id: 'fp-2024',
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-12-31',
|
||||
})
|
||||
|
||||
results = [
|
||||
{ data: current, error: null },
|
||||
{ data: null, error: null },
|
||||
{ data: null, error: null },
|
||||
]
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when current period not found', async () => {
|
||||
results = [{ data: null, error: { message: 'not found' } }]
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await findNextPeriod(supabase as never, 'company-1', 'missing')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,11 +56,13 @@ vi.mock('../period-service', () => ({
|
||||
lockPeriod: vi.fn(),
|
||||
closePeriod: vi.fn(),
|
||||
createNextPeriod: vi.fn(),
|
||||
findNextPeriod: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
import { validateYearEndReadiness, previewYearEndClosing } from '../year-end-service'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { findNextPeriod } from '../period-service'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -345,6 +347,55 @@ describe('validateYearEndReadiness', () => {
|
||||
expect(result.warnings.some((w: string) => w.includes('Sequence counter ahead'))).toBe(true)
|
||||
expect(result.sequenceMismatches).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('warns (not errors) when next period already exists without IB', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
results = noGapResults(period)
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 10000,
|
||||
totalCredit: 10000,
|
||||
} as never)
|
||||
|
||||
vi.mocked(findNextPeriod).mockResolvedValueOnce({
|
||||
id: 'fp-2',
|
||||
name: 'FY 2025',
|
||||
opening_balance_entry_id: null,
|
||||
} as never)
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(true)
|
||||
// Period name intentionally not interpolated into the warning — see
|
||||
// year-end-service for rationale. We assert on the stable English
|
||||
// substring instead.
|
||||
expect(result.warnings.some((w: string) => w.includes('Next fiscal period already exists'))).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks when next period already has opening balances posted', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
results = noGapResults(period)
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 10000,
|
||||
totalCredit: 10000,
|
||||
} as never)
|
||||
|
||||
vi.mocked(findNextPeriod).mockResolvedValueOnce({
|
||||
id: 'fp-2',
|
||||
name: 'FY 2025',
|
||||
opening_balance_entry_id: 'ib-1',
|
||||
} as never)
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.errors.some((e: string) => e.includes('already has opening balances'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewYearEndClosing', () => {
|
||||
|
||||
@@ -215,20 +215,22 @@ export async function createNextPeriod(
|
||||
throw new Error('Current fiscal period not found')
|
||||
}
|
||||
|
||||
// Compute next period start (day after current end)
|
||||
const nextStart = new Date(current.period_end)
|
||||
nextStart.setDate(nextStart.getDate() + 1)
|
||||
// Compute next period start (day after current end) in pure UTC — see
|
||||
// findNextPeriod for the DST off-by-one rationale.
|
||||
const nextStart = new Date(current.period_end + 'T00:00:00Z')
|
||||
nextStart.setUTCDate(nextStart.getUTCDate() + 1)
|
||||
|
||||
// After a broken first fiscal year, subsequent years should always be
|
||||
// 12 months (standard fiscal year). The first year is the only one that
|
||||
// can be longer/shorter than 12 months per BFL 3 kap.
|
||||
const nextEnd = new Date(nextStart)
|
||||
nextEnd.setMonth(nextEnd.getMonth() + 12)
|
||||
// Go to last day of that month
|
||||
nextEnd.setDate(0)
|
||||
nextEnd.setUTCMonth(nextEnd.getUTCMonth() + 12)
|
||||
// Go to last day of the previous month — setUTCDate(0) rolls back into
|
||||
// the prior month's last day.
|
||||
nextEnd.setUTCDate(0)
|
||||
|
||||
const nextStartStr = nextStart.toISOString().split('T')[0]
|
||||
const nextEndStr = nextEnd.toISOString().split('T')[0]
|
||||
const nextStartStr = nextStart.toISOString().slice(0, 10)
|
||||
const nextEndStr = nextEnd.toISOString().slice(0, 10)
|
||||
|
||||
// Validate period duration — subsequent periods always start on 1st of month
|
||||
const durationError = validatePeriodDuration(nextStartStr, nextEndStr, { isFirstPeriod: false })
|
||||
@@ -250,8 +252,8 @@ export async function createNextPeriod(
|
||||
}
|
||||
|
||||
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
|
||||
const startYear = nextStart.getFullYear()
|
||||
const endYear = nextEnd.getFullYear()
|
||||
const startYear = nextStart.getUTCFullYear()
|
||||
const endYear = nextEnd.getUTCFullYear()
|
||||
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
|
||||
|
||||
const { data: newPeriod, error: insertError } = await supabase
|
||||
@@ -274,6 +276,68 @@ export async function createNextPeriod(
|
||||
return newPeriod as FiscalPeriod
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the next fiscal period after the given one without creating it.
|
||||
*
|
||||
* Used by year-end closing to handle the common case where the next period
|
||||
* was already created (e.g. by SIE import, manual creation, or a previous
|
||||
* partial year-end run). Returns null when no such period exists.
|
||||
*
|
||||
* Matches first on previous_period_id chain, then falls back to a
|
||||
* period_start = (current.period_end + 1 day) lookup so periods created
|
||||
* before the chain was wired up are still recognised.
|
||||
*/
|
||||
export async function findNextPeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
currentPeriodId: string
|
||||
): Promise<FiscalPeriod | null> {
|
||||
const { data: current, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', currentPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { data: chained } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('previous_period_id', currentPeriodId)
|
||||
.maybeSingle()
|
||||
|
||||
if (chained) {
|
||||
return chained as FiscalPeriod
|
||||
}
|
||||
|
||||
// UTC-only arithmetic: anchor the date string at UTC midnight, then
|
||||
// advance via setUTCDate. Using Date(string) + setDate/getDate causes an
|
||||
// off-by-one on servers in TZ+ when the day after period_end crosses a
|
||||
// DST spring-forward, because setDate(local) writes local-time fields
|
||||
// and toISOString() converts back through the shifted offset.
|
||||
const expectedStartStr = addDaysUTC(current.period_end, 1)
|
||||
|
||||
const { data: byDate } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('period_start', expectedStartStr)
|
||||
.maybeSingle()
|
||||
|
||||
return (byDate as FiscalPeriod | null) ?? null
|
||||
}
|
||||
|
||||
/** Add `days` to a YYYY-MM-DD string in pure UTC and return YYYY-MM-DD. */
|
||||
function addDaysUTC(isoDate: string, days: number): string {
|
||||
const d = new Date(isoDate + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a previous fiscal period before the given one.
|
||||
* Computes a 12-month period ending the day before the given period starts.
|
||||
@@ -466,10 +530,7 @@ export async function getPeriodStatus(
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('status', 'draft')
|
||||
|
||||
// Check if next period exists
|
||||
const nextStart = new Date(period.period_end)
|
||||
nextStart.setDate(nextStart.getDate() + 1)
|
||||
|
||||
// Check if next period exists via the chain pointer
|
||||
const { data: nextPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createLogger } from '@/lib/logger'
|
||||
const log = createLogger('year-end-service')
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { lockPeriod, closePeriod, createNextPeriod } from './period-service'
|
||||
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
|
||||
import {
|
||||
previewCurrencyRevaluation,
|
||||
executeCurrencyRevaluation,
|
||||
@@ -253,6 +253,25 @@ export async function validateYearEndReadiness(
|
||||
errors.push('Opening balance continuity check failed for this period — resolve discrepancies before closing')
|
||||
}
|
||||
|
||||
// Check: next period state. A pre-existing next period (from SIE import,
|
||||
// manual creation, or a prior partial run) is fine — we'll reuse it — but
|
||||
// one with opening balances already booked blocks closing because we
|
||||
// can't post a second IB on top.
|
||||
//
|
||||
// The period name is not interpolated into the message — although the
|
||||
// name is user-supplied at create time and confined to the company,
|
||||
// surfacing DB-sourced strings through error paths is the kind of
|
||||
// injection footgun we'd rather close at the source than rely on the UI
|
||||
// to escape (text rendering and aria-label propagation differ).
|
||||
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
|
||||
if (nextPeriod) {
|
||||
if (nextPeriod.opening_balance_entry_id) {
|
||||
errors.push('Next fiscal period already has opening balances posted')
|
||||
} else {
|
||||
warnings.push('Next fiscal period already exists — opening balances will be booked into it')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: errors.length === 0,
|
||||
errors,
|
||||
@@ -397,12 +416,15 @@ export async function previewYearEndClosing(
|
||||
* Execute year-end closing for a fiscal period.
|
||||
*
|
||||
* 1. Validate readiness
|
||||
* 2. Create closing entry (zeros class 3-8 accounts)
|
||||
* 3. Set closing_entry_id on the period
|
||||
* 4. Lock the period
|
||||
* 5. Close the period
|
||||
* 6. Create next fiscal period
|
||||
* 7. Generate opening balances in next period
|
||||
* 2. Run currency revaluation (FX gains/losses to 3960/7960)
|
||||
* 3. Generate closing preview and check öre balance
|
||||
* 4. Create closing entry (zeros class 3-8 accounts)
|
||||
* 5. Set closing_entry_id on the period
|
||||
* 6. Resolve next fiscal period (reuse existing or create new)
|
||||
* 7. Lock the period
|
||||
* 8. Close the period (irreversible — every guard must run before this)
|
||||
* 9. Generate opening balances in next period
|
||||
* 10. Validate IB/UB continuity
|
||||
*/
|
||||
export async function executeYearEndClosing(
|
||||
supabase: SupabaseClient,
|
||||
@@ -509,15 +531,37 @@ export async function executeYearEndClosing(
|
||||
throw new Error(`Failed to set closing_entry_id: ${updateError.message}`)
|
||||
}
|
||||
|
||||
// 6. Lock the period
|
||||
// 6. Resolve the next period BEFORE locking/closing this one. A pre-existing
|
||||
// next period is common (SIE import, manual creation, prior partial
|
||||
// year-end run); reusing it is fine as long as no IB has been booked
|
||||
// into it. Doing this check after closePeriod would leave the books in
|
||||
// a half-closed state if a concurrent process posted IB into the next
|
||||
// period between validateYearEndReadiness and step 8 (TOCTOU race).
|
||||
//
|
||||
// The thrown error is intentionally a stable English string with no
|
||||
// DB-sourced data interpolated — the route layer maps it to a
|
||||
// structured error code, and the next period name (if any) is surfaced
|
||||
// only through the structured details payload after explicit checks.
|
||||
const existingNextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
|
||||
let nextPeriod
|
||||
if (existingNextPeriod) {
|
||||
if (existingNextPeriod.opening_balance_entry_id) {
|
||||
throw new Error(
|
||||
'Next fiscal period already has opening balance entry posted; reverse it before re-running year-end'
|
||||
)
|
||||
}
|
||||
nextPeriod = existingNextPeriod
|
||||
} else {
|
||||
nextPeriod = await createNextPeriod(supabase, companyId, userId, fiscalPeriodId)
|
||||
}
|
||||
|
||||
// 7. Lock the period
|
||||
await lockPeriod(supabase, companyId, userId, fiscalPeriodId)
|
||||
|
||||
// 7. Close the period
|
||||
// 8. Close the period — irreversible per BFL. Every guard that can fail
|
||||
// on prior state must run before this point.
|
||||
await closePeriod(supabase, companyId, userId, fiscalPeriodId)
|
||||
|
||||
// 8. Create next period
|
||||
const nextPeriod = await createNextPeriod(supabase, companyId, userId, fiscalPeriodId)
|
||||
|
||||
// 9. Generate opening balances in next period
|
||||
const openingBalanceEntry = await generateOpeningBalances(
|
||||
supabase,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -770,6 +770,11 @@ const YEAR_END: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Resultaträkningens debet och kredit balanserar inte. Granska verifikationerna innan bokslut.',
|
||||
message_en: 'Trial balance does not balance.',
|
||||
},
|
||||
YEAR_END_NEXT_PERIOD_HAS_IB: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Nästa räkenskapsperiod har redan ingående balanser bokförda. Storno dem innan du kör om bokslutet.',
|
||||
message_en: 'Next fiscal period already has opening balances posted; reverse them before re-running year-end.',
|
||||
},
|
||||
}
|
||||
|
||||
const OPENING_BAL: Record<string, StructuredErrorEntry> = {
|
||||
|
||||
Reference in New Issue
Block a user