* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2) A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an account was reconciled against to (account_key, through_date), before or after the sign-off, from every account body on the Avstämning page. Rows live in account_reconciliation_attachments (append-only, removal stamp by trigger, RLS like account_reconciliations), bytes in the documents bucket under the company prefix so its RLS applies unchanged, and the full archive copies them into bilagor/ with a hash manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867) * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) The bokslut checklist is a catalogue in code with one state row per period (bokslut_checklist_items): the steps the system can judge (sign-offs through balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are computed live and a stored row only overrides them; the manual steps are the konsult's ticks, with who and when. It sits on the wizard's Kontroll step and is dumped into the full archive. A hole between fiscal years (one-file SIE migrations) is now named on the bokslut readiness screen and on the import result screen, where the next file is one click away. Non-adjacent period links are #1849's fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
189 lines
8.4 KiB
TypeScript
189 lines
8.4 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { describe, it, expect } from 'vitest'
|
|
import { getPool, withUserContext } from './setup'
|
|
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
|
|
|
|
// pg-real coverage for 20260824200000_account_reconciliation_attachments:
|
|
// RLS (members read, owner/admin/member attach as themselves, viewers
|
|
// read-only, no DELETE policy), the append-only freeze trigger (only the
|
|
// removal stamp may change, and only once), the no-delete trigger, and the
|
|
// account_key / sha256 CHECKs.
|
|
|
|
const SHA = 'ab'.repeat(32)
|
|
|
|
async function insertAttachment(
|
|
companyId: string,
|
|
uploadedBy: string,
|
|
overrides: { accountKey?: string; throughDate?: string; storagePath?: string } = {},
|
|
): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.account_reconciliation_attachments
|
|
(id, company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
|
VALUES ($1, $2, $3, $4, 'kontoutdrag.pdf', 'application/pdf', 1234, 'documents', $5, $6, $7)`,
|
|
[
|
|
id,
|
|
companyId,
|
|
overrides.accountKey ?? 'manual:2350',
|
|
overrides.throughDate ?? '2026-12-31',
|
|
overrides.storagePath ?? `documents/${companyId}/reconciliation/manual_2350/2026-12-31/${id}_kontoutdrag.pdf`,
|
|
SHA,
|
|
uploadedBy,
|
|
],
|
|
)
|
|
return id
|
|
}
|
|
|
|
describe('account_reconciliation_attachments RLS', () => {
|
|
it('lets company members read, strangers see nothing', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const rowId = await insertAttachment(companyId, userId)
|
|
const stranger = await insertAuthUser()
|
|
|
|
const ownerView = await withUserContext(userId, (client) =>
|
|
client.query<{ id: string }>(`SELECT id FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
|
)
|
|
expect(ownerView.rows).toHaveLength(1)
|
|
|
|
const strangerView = await withUserContext(stranger, (client) =>
|
|
client.query<{ id: string }>(`SELECT id FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
|
)
|
|
expect(strangerView.rows).toHaveLength(0)
|
|
})
|
|
|
|
it('lets viewers read but not attach', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const rowId = await insertAttachment(companyId, userId)
|
|
const viewer = await insertAuthUser()
|
|
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
|
|
|
|
const viewerRead = await withUserContext(viewer, (client) =>
|
|
client.query<{ id: string }>(`SELECT id FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
|
)
|
|
expect(viewerRead.rows).toHaveLength(1)
|
|
|
|
await expect(
|
|
withUserContext(viewer, (client) =>
|
|
client.query(
|
|
`INSERT INTO public.account_reconciliation_attachments
|
|
(company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
|
VALUES ($1, 'manual:2350', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, $3, $4)`,
|
|
[companyId, `documents/${companyId}/reconciliation/manual_2350/2026-12-31/${randomUUID()}_x.pdf`, SHA, viewer],
|
|
),
|
|
),
|
|
).rejects.toThrow(/row-level security/i)
|
|
})
|
|
|
|
it('lets members attach as themselves but not as someone else', async () => {
|
|
const { userId: owner, companyId } = await seedCompany()
|
|
const member = await insertAuthUser()
|
|
await insertCompanyMember({ companyId, userId: member, role: 'member' })
|
|
|
|
const inserted = await withUserContext(member, (client) =>
|
|
client.query<{ id: string }>(
|
|
`INSERT INTO public.account_reconciliation_attachments
|
|
(company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
|
VALUES ($1, 'skattekonto', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, $3, $4) RETURNING id`,
|
|
[companyId, `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_x.pdf`, SHA, member],
|
|
),
|
|
)
|
|
expect(inserted.rows).toHaveLength(1)
|
|
|
|
await expect(
|
|
withUserContext(member, (client) =>
|
|
client.query(
|
|
`INSERT INTO public.account_reconciliation_attachments
|
|
(company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
|
VALUES ($1, 'skattekonto', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, $3, $4)`,
|
|
[companyId, `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_y.pdf`, SHA, owner],
|
|
),
|
|
),
|
|
).rejects.toThrow(/row-level security/i)
|
|
})
|
|
|
|
it('has no DELETE policy and a no-delete trigger', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const rowId = await insertAttachment(companyId, userId)
|
|
|
|
// RLS: the statement runs but touches nothing.
|
|
const asMember = await withUserContext(userId, (client) =>
|
|
client.query(`DELETE FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
|
)
|
|
expect(asMember.rowCount).toBe(0)
|
|
|
|
// Even the superuser cannot: BFL 7 kap. retention is enforced by trigger.
|
|
await expect(
|
|
getPool().query(`DELETE FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
|
|
).rejects.toThrow(/never deleted/i)
|
|
})
|
|
})
|
|
|
|
describe('account_reconciliation_attachments append-only', () => {
|
|
it('lets a member stamp removal once, and freezes everything else', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const rowId = await insertAttachment(companyId, userId)
|
|
|
|
await expect(
|
|
withUserContext(userId, (client) =>
|
|
client.query(`UPDATE public.account_reconciliation_attachments SET note = 'ändrad' WHERE id = $1`, [rowId]),
|
|
),
|
|
).rejects.toThrow(/append-only/i)
|
|
|
|
await expect(
|
|
withUserContext(userId, (client) =>
|
|
client.query(`UPDATE public.account_reconciliation_attachments SET storage_path = 'documents/x' WHERE id = $1`, [rowId]),
|
|
),
|
|
).rejects.toThrow(/append-only/i)
|
|
|
|
const stamped = await withUserContext(userId, (client) =>
|
|
client.query<{ removed_at: string }>(
|
|
`UPDATE public.account_reconciliation_attachments
|
|
SET removed_at = NOW(), removed_by = $2, removed_reason = 'fel fil'
|
|
WHERE id = $1 RETURNING removed_at`,
|
|
[rowId, userId],
|
|
),
|
|
)
|
|
expect(stamped.rows).toHaveLength(1)
|
|
|
|
// withUserContext rolls back; stamp for real (superuser) to test finality.
|
|
await getPool().query(
|
|
`UPDATE public.account_reconciliation_attachments
|
|
SET removed_at = NOW(), removed_by = $2, removed_reason = 'fel fil'
|
|
WHERE id = $1`,
|
|
[rowId, userId],
|
|
)
|
|
|
|
// The stamp itself is final: no restore, no re-stamp.
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.account_reconciliation_attachments SET removed_at = NULL, removed_by = NULL, removed_reason = NULL WHERE id = $1`,
|
|
[rowId],
|
|
),
|
|
).rejects.toThrow(/cannot be restored/i)
|
|
})
|
|
|
|
it('rejects a malformed account_key, a bad hash, and a half removal stamp', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await expect(insertAttachment(companyId, userId, { accountKey: '1930' })).rejects.toThrow(/account_key/i)
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.account_reconciliation_attachments
|
|
(company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
|
|
VALUES ($1, 'skattekonto', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, 'nothex', $3)`,
|
|
[companyId, `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_x.pdf`, userId],
|
|
),
|
|
).rejects.toThrow(/sha256/i)
|
|
const rowId = await insertAttachment(companyId, userId)
|
|
await expect(
|
|
getPool().query(`UPDATE public.account_reconciliation_attachments SET removed_at = NOW() WHERE id = $1`, [rowId]),
|
|
).rejects.toThrow(/removal_pair/i)
|
|
})
|
|
|
|
it('refuses the same storage object twice', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const path = `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_same.pdf`
|
|
await insertAttachment(companyId, userId, { accountKey: 'skattekonto', storagePath: path })
|
|
await expect(insertAttachment(companyId, userId, { accountKey: 'skattekonto', storagePath: path })).rejects.toThrow(/storage_path_unique|duplicate key/i)
|
|
})
|
|
})
|