@@ -114,6 +114,26 @@ async function createVersion(params: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForAdvisoryLock(
|
||||||
|
pid: number,
|
||||||
|
maxAttempts = 150,
|
||||||
|
intervalMs = 20,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
|
const result = await getPool().query<{ wait_event: string | null }>(
|
||||||
|
`SELECT wait_event
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE pid = $1`,
|
||||||
|
[pid],
|
||||||
|
)
|
||||||
|
if (result.rows[0]?.wait_event === 'advisory') return
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Backend ${pid} did not wait for the annual-report advisory lock after ${maxAttempts * intervalMs}ms`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
describe('annual report profile and version enforcement', () => {
|
describe('annual report profile and version enforcement', () => {
|
||||||
it('isolates profiles by membership and rejects cross-company period links', async () => {
|
it('isolates profiles by membership and rejects cross-company period links', async () => {
|
||||||
const owner = await seedCompany()
|
const owner = await seedCompany()
|
||||||
@@ -307,6 +327,54 @@ describe('annual report profile and version enforcement', () => {
|
|||||||
).rejects.toThrow(/row-level security policy/i)
|
).rejects.toThrow(/row-level security policy/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('limits authenticated roster edits to blank pending unbound inserts and deletes', async () => {
|
||||||
|
const owner = await seedCompany()
|
||||||
|
const signatureId = randomUUID()
|
||||||
|
|
||||||
|
const inserted = await withCommittedRoleContext(owner.userId, 'authenticated', (client) =>
|
||||||
|
client.query(
|
||||||
|
`INSERT INTO public.arsredovisning_signature_requests
|
||||||
|
(id, user_id, company_id, fiscal_period_id, role, signer_name)
|
||||||
|
VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')
|
||||||
|
RETURNING id`,
|
||||||
|
[signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(inserted.rowCount).toBe(1)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withUserContext(owner.userId, (client) =>
|
||||||
|
client.query(
|
||||||
|
`INSERT INTO public.arsredovisning_signature_requests
|
||||||
|
(user_id, company_id, fiscal_period_id, role, signer_name, evidence_reference)
|
||||||
|
VALUES ($1, $2, $3, 'VD', 'Erik Eriksson', 'archive:A-2')`,
|
||||||
|
[owner.userId, owner.companyId, owner.fiscalPeriodId],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/row-level security policy/i)
|
||||||
|
|
||||||
|
const updated = await withUserContext(owner.userId, (client) =>
|
||||||
|
client.query(
|
||||||
|
`UPDATE public.arsredovisning_signature_requests
|
||||||
|
SET signer_name = 'Changed signer'
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[signatureId],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(updated.rowCount).toBe(0)
|
||||||
|
|
||||||
|
const deleted = await withCommittedRoleContext(owner.userId, 'authenticated', (client) =>
|
||||||
|
client.query(
|
||||||
|
`DELETE FROM public.arsredovisning_signature_requests
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[signatureId],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(deleted.rowCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('invalidates representative confirmation when the draft signer roster changes', async () => {
|
it('invalidates representative confirmation when the draft signer roster changes', async () => {
|
||||||
const owner = await seedCompany()
|
const owner = await seedCompany()
|
||||||
await getPool().query(
|
await getPool().query(
|
||||||
@@ -353,6 +421,30 @@ describe('annual report profile and version enforcement', () => {
|
|||||||
).rejects.toThrow(/only permits draft status/i)
|
).rejects.toThrow(/only permits draft status/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requires ready_for_signature status at the trusted finalization boundary', async () => {
|
||||||
|
const owner = await seedCompany()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withCommittedRoleContext(owner.userId, 'service_role', (client) =>
|
||||||
|
client.query(
|
||||||
|
`SELECT * FROM public.create_annual_report_version_with_signatures(
|
||||||
|
$1, $2, '1.0', 'k2', 'draft', $3::jsonb, $4::jsonb,
|
||||||
|
$5, '2024-09-12', 'k2-ab-risbs-2024-09-12', $6::jsonb, $7
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
owner.companyId,
|
||||||
|
owner.fiscalPeriodId,
|
||||||
|
JSON.stringify(reportData(owner.fiscalPeriodId)),
|
||||||
|
JSON.stringify({ entryPointId: 'k2-ab-risbs-2024-09-12' }),
|
||||||
|
'f'.repeat(64),
|
||||||
|
JSON.stringify({}),
|
||||||
|
owner.userId,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/requires ready_for_signature status/i)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps version content immutable and blocks member deletion', async () => {
|
it('keeps version content immutable and blocks member deletion', async () => {
|
||||||
const owner = await seedCompany()
|
const owner = await seedCompany()
|
||||||
const version = await createVersion(owner)
|
const version = await createVersion(owner)
|
||||||
@@ -534,4 +626,114 @@ describe('annual report profile and version enforcement', () => {
|
|||||||
),
|
),
|
||||||
).rejects.toThrow(/belongs to another company or period/i)
|
).rejects.toThrow(/belongs to another company or period/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps validation history insert-only for authenticated callers', async () => {
|
||||||
|
const owner = await seedCompany()
|
||||||
|
const version = await createVersion(owner)
|
||||||
|
const validationId = randomUUID()
|
||||||
|
|
||||||
|
const inserted = await withCommittedRoleContext(owner.userId, 'authenticated', (client) =>
|
||||||
|
client.query(
|
||||||
|
`INSERT INTO public.annual_report_validation_runs
|
||||||
|
(id, company_id, fiscal_period_id, version_id, user_id, validation_layer, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'local', 'passed')
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
validationId,
|
||||||
|
owner.companyId,
|
||||||
|
owner.fiscalPeriodId,
|
||||||
|
version.rows[0].id,
|
||||||
|
owner.userId,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(inserted.rowCount).toBe(1)
|
||||||
|
|
||||||
|
const updated = await withUserContext(owner.userId, (client) =>
|
||||||
|
client.query(
|
||||||
|
`UPDATE public.annual_report_validation_runs
|
||||||
|
SET status = 'failed'
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[validationId],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(updated.rowCount).toBe(0)
|
||||||
|
|
||||||
|
const deleted = await withUserContext(owner.userId, (client) =>
|
||||||
|
client.query(
|
||||||
|
`DELETE FROM public.annual_report_validation_runs
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[validationId],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(deleted.rowCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes concurrent final signatures and completes the version exactly once', async () => {
|
||||||
|
const owner = await seedCompany()
|
||||||
|
const firstSignatureId = randomUUID()
|
||||||
|
const secondSignatureId = randomUUID()
|
||||||
|
await getPool().query(
|
||||||
|
`INSERT INTO public.arsredovisning_signature_requests
|
||||||
|
(id, user_id, company_id, fiscal_period_id, role, signer_name)
|
||||||
|
VALUES
|
||||||
|
($1, $3, $4, $5, 'Styrelseledamot', 'Anna Andersson'),
|
||||||
|
($2, $3, $4, $5, 'VD', 'Erik Eriksson')`,
|
||||||
|
[
|
||||||
|
firstSignatureId,
|
||||||
|
secondSignatureId,
|
||||||
|
owner.userId,
|
||||||
|
owner.companyId,
|
||||||
|
owner.fiscalPeriodId,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const version = await createVersion({ ...owner, status: 'ready_for_signature' })
|
||||||
|
|
||||||
|
const firstClient = await getClient()
|
||||||
|
const secondClient = await getClient()
|
||||||
|
try {
|
||||||
|
await firstClient.query('BEGIN')
|
||||||
|
await secondClient.query('BEGIN')
|
||||||
|
const secondPid = await secondClient.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')
|
||||||
|
|
||||||
|
await firstClient.query(
|
||||||
|
`UPDATE public.arsredovisning_signature_requests
|
||||||
|
SET status = 'signed', signed_at = now(),
|
||||||
|
signing_method = 'paper_original', evidence_reference = 'archive:A-1',
|
||||||
|
evidence_recorded_by = $2, evidence_recorded_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[firstSignatureId, owner.userId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const secondUpdate = secondClient.query(
|
||||||
|
`UPDATE public.arsredovisning_signature_requests
|
||||||
|
SET status = 'signed', signed_at = now(),
|
||||||
|
signing_method = 'paper_original', evidence_reference = 'archive:A-2',
|
||||||
|
evidence_recorded_by = $2, evidence_recorded_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[secondSignatureId, owner.userId],
|
||||||
|
)
|
||||||
|
await waitForAdvisoryLock(secondPid.rows[0].pid)
|
||||||
|
await firstClient.query('COMMIT')
|
||||||
|
await secondUpdate
|
||||||
|
await secondClient.query('COMMIT')
|
||||||
|
|
||||||
|
const signedVersion = await getPool().query<{ status: string }>(
|
||||||
|
`SELECT status
|
||||||
|
FROM public.annual_report_versions
|
||||||
|
WHERE id = $1`,
|
||||||
|
[version.rows[0].id],
|
||||||
|
)
|
||||||
|
expect(signedVersion.rows[0].status).toBe('signed')
|
||||||
|
} catch (error) {
|
||||||
|
await firstClient.query('ROLLBACK').catch(() => {})
|
||||||
|
await secondClient.query('ROLLBACK').catch(() => {})
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
firstClient.release()
|
||||||
|
secondClient.release()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,47 @@ import { getPool } from './setup'
|
|||||||
import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures'
|
import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures'
|
||||||
|
|
||||||
describe('BFL retention expiry', () => {
|
describe('BFL retention expiry', () => {
|
||||||
|
it('runs the corrected retention trigger after the original migration 017 trigger', async () => {
|
||||||
|
const result = await getPool().query<{
|
||||||
|
trigger_name: string
|
||||||
|
trigger_definition: string
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
trigger.tgname AS trigger_name,
|
||||||
|
pg_get_triggerdef(trigger.oid) AS trigger_definition
|
||||||
|
FROM pg_trigger trigger
|
||||||
|
JOIN pg_class target ON target.oid = trigger.tgrelid
|
||||||
|
JOIN pg_namespace schema ON schema.oid = target.relnamespace
|
||||||
|
WHERE schema.nspname = 'public'
|
||||||
|
AND target.relname = 'fiscal_periods'
|
||||||
|
AND NOT trigger.tgisinternal
|
||||||
|
AND trigger.tgname IN (
|
||||||
|
'calculate_retention_expiry',
|
||||||
|
'zz_set_bfl_retention_expiry',
|
||||||
|
'enforce_period_start_day'
|
||||||
|
)
|
||||||
|
ORDER BY trigger.tgname`,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.rows).toEqual([
|
||||||
|
{
|
||||||
|
trigger_name: 'calculate_retention_expiry',
|
||||||
|
trigger_definition:
|
||||||
|
'CREATE TRIGGER calculate_retention_expiry BEFORE INSERT OR UPDATE ON public.fiscal_periods FOR EACH ROW EXECUTE FUNCTION calculate_retention_expiry()',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger_name: 'enforce_period_start_day',
|
||||||
|
trigger_definition:
|
||||||
|
'CREATE TRIGGER enforce_period_start_day BEFORE INSERT OR UPDATE OF company_id, period_start ON public.fiscal_periods FOR EACH ROW EXECUTE FUNCTION enforce_first_of_month_for_subsequent_periods()',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger_name: 'zz_set_bfl_retention_expiry',
|
||||||
|
trigger_definition:
|
||||||
|
'CREATE TRIGGER zz_set_bfl_retention_expiry BEFORE INSERT OR UPDATE ON public.fiscal_periods FOR EACH ROW EXECUTE FUNCTION set_bfl_retention_expiry()',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('retains a fiscal year through the end of the seventh following calendar year', async () => {
|
it('retains a fiscal year through the end of the seventh following calendar year', async () => {
|
||||||
const userId = await insertAuthUser()
|
const userId = await insertAuthUser()
|
||||||
const companyId = await insertCompany({ createdBy: userId })
|
const companyId = await insertCompany({ createdBy: userId })
|
||||||
|
|||||||
Reference in New Issue
Block a user