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:
@@ -171,6 +171,9 @@ export default function YearEndPage() {
|
||||
})
|
||||
const body = await res.json()
|
||||
if (!res.ok) {
|
||||
// body.error.message is the localized Swedish message picked by
|
||||
// the structured-error registry. Do NOT interpolate raw details
|
||||
// here — they can contain DB-sourced strings (V2.3 finding).
|
||||
setExecuteError(body?.error?.message ?? 'Bokslutet kunde inte verkställas')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,10 +27,7 @@ export const GET = withRouteContext(
|
||||
if (/not found/i.test(message)) {
|
||||
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, { requestId })
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -49,17 +46,18 @@ export const POST = withRouteContext(
|
||||
} catch (err) {
|
||||
opLog.error('year-end execution failed', err as Error)
|
||||
const message = err instanceof Error ? err.message : ''
|
||||
// The downstream errors below are matched on stable English keywords
|
||||
// emitted by year-end-service. Do NOT include the raw message in
|
||||
// details — it may contain DB-sourced names; UI surfacing relies on
|
||||
// the structured message_sv / message_en pair.
|
||||
if (/Next fiscal period already has opening balance/i.test(message)) {
|
||||
return errorResponseFromCode('YEAR_END_NEXT_PERIOD_HAS_IB', opLog, { requestId })
|
||||
}
|
||||
if (/prior.*open/i.test(message)) {
|
||||
return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, {
|
||||
requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, { requestId })
|
||||
}
|
||||
if (/not balanced|unbalanced/i.test(message)) {
|
||||
return errorResponseFromCode('YEAR_END_UNBALANCED_TRIAL', opLog, {
|
||||
requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
return errorResponseFromCode('YEAR_END_UNBALANCED_TRIAL', opLog, { requestId })
|
||||
}
|
||||
if (/not found/i.test(message)) {
|
||||
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
|
||||
@@ -67,10 +65,7 @@ export const POST = withRouteContext(
|
||||
// Fall through bookkeeping/Zod/etc to errorResponse, but cap to YEAR_END_FAILED.
|
||||
const fallback = errorResponse(err, opLog, { requestId })
|
||||
if (fallback.status === 500) {
|
||||
return errorResponseFromCode('YEAR_END_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
return errorResponseFromCode('YEAR_END_FAILED', opLog, { requestId })
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
validateDocumentMagicBytes: vi.fn(),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { validateDocumentMagicBytes } from '@/lib/core/documents/document-service'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// v4 UUIDs (variant 'a' / 'b' in the 4th group) so Zod's stricter validators
|
||||
// accept them — the looser `2222...` style fails on the variant check.
|
||||
const mockUser = { id: '11111111-1111-4111-a111-111111111111' }
|
||||
const validDocId = '22222222-2222-4222-a222-222222222222'
|
||||
const companyId = '33333333-3333-4333-a333-333333333333'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: mockUser as never,
|
||||
supabase: mockSupabase as never,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
function req() {
|
||||
return new Request(`http://localhost/api/documents/${validDocId}/integrity`)
|
||||
}
|
||||
|
||||
describe('GET /api/documents/[id]/integrity', () => {
|
||||
it('returns 401 when the user is not authenticated', async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null as never,
|
||||
supabase: mockSupabase as never,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when id is not a UUID', async () => {
|
||||
const res = await GET(req(), createMockRouteParams({ id: 'not-a-uuid' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toMatch(/invalid/i)
|
||||
})
|
||||
|
||||
it('returns 404 when document is not found or is superseded', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 when caller is not a member of the document company', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: validDocId,
|
||||
company_id: companyId,
|
||||
mime_type: 'application/pdf',
|
||||
storage_path: 'documents/foo/bar.pdf',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // membership check returns null
|
||||
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(404)
|
||||
// The endpoint deliberately returns the same 404 as missing-document so
|
||||
// the response cannot be used to enumerate documents across companies.
|
||||
expect(body.error).toBe('Document not found')
|
||||
})
|
||||
|
||||
it('returns { valid: true } and skips download when mime_type is null', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: validDocId,
|
||||
company_id: companyId,
|
||||
mime_type: null,
|
||||
storage_path: 'documents/foo/bar.pdf',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { company_id: companyId }, error: null }) // membership
|
||||
|
||||
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { valid: boolean } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.valid).toBe(true)
|
||||
expect(mockSupabase.storage.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns { valid: true } when the bytes match the declared mime type', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: validDocId,
|
||||
company_id: companyId,
|
||||
mime_type: 'application/pdf',
|
||||
storage_path: 'documents/foo/bar.pdf',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { company_id: companyId }, error: null })
|
||||
|
||||
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37])
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' })
|
||||
mockSupabase.storage.from.mockReturnValue({
|
||||
download: vi.fn().mockResolvedValue({ data: blob, error: null }),
|
||||
} as never)
|
||||
|
||||
vi.mocked(validateDocumentMagicBytes).mockReturnValue(null)
|
||||
|
||||
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { valid: boolean } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.valid).toBe(true)
|
||||
expect(Object.keys(body.data)).toEqual(['valid'])
|
||||
})
|
||||
|
||||
it('returns { valid: false } without leaking the reason text', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: validDocId,
|
||||
company_id: companyId,
|
||||
mime_type: 'application/pdf',
|
||||
storage_path: 'documents/foo/bar.pdf',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { company_id: companyId }, error: null })
|
||||
|
||||
const blob = new Blob([new Uint8Array([0x00, 0x00, 0x00, 0x00])])
|
||||
mockSupabase.storage.from.mockReturnValue({
|
||||
download: vi.fn().mockResolvedValue({ data: blob, error: null }),
|
||||
} as never)
|
||||
|
||||
vi.mocked(validateDocumentMagicBytes).mockReturnValue('Internal /storage/v1/object error 42')
|
||||
|
||||
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.valid).toBe(false)
|
||||
// The whole point of the V1.2.5 / Art 25(2) hardening — internal text
|
||||
// never appears in the response.
|
||||
expect(body.data.reason).toBeUndefined()
|
||||
expect(JSON.stringify(body)).not.toContain('storage/v1/object')
|
||||
})
|
||||
|
||||
it('returns a generic 500 without leaking the underlying storage error', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: validDocId,
|
||||
company_id: companyId,
|
||||
mime_type: 'application/pdf',
|
||||
storage_path: 'documents/foo/bar.pdf',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { company_id: companyId }, error: null })
|
||||
|
||||
mockSupabase.storage.from.mockReturnValue({
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'storage internal: bucket=documents object=secret/path.pdf' },
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Integrity check unavailable')
|
||||
expect(JSON.stringify(body)).not.toContain('secret/path.pdf')
|
||||
})
|
||||
|
||||
it('does not import the service-role supabase client', async () => {
|
||||
// The download must go through the per-request user-scoped client so
|
||||
// RLS on storage.objects can act as defense-in-depth. Statically
|
||||
// verifying the source is the cleanest check — runtime mocking of the
|
||||
// service-client export would not catch a future regression where
|
||||
// someone added an import but conditionally used it.
|
||||
const fs = await import('node:fs/promises')
|
||||
const path = await import('node:path')
|
||||
const routePath = path.resolve(__dirname, '../route.ts')
|
||||
const source = await fs.readFile(routePath, 'utf8')
|
||||
expect(source).not.toMatch(/createServiceClient(\b|NoCookies)/)
|
||||
})
|
||||
|
||||
it('filters the document lookup to the current version', async () => {
|
||||
// The route's first `from('document_attachments')` chain must include
|
||||
// `.eq('is_current_version', true)` so superseded versions cannot have
|
||||
// their bytes probed via this surface. Source-level check rather than
|
||||
// runtime spying — the proxy-based queued mock collapses every chained
|
||||
// method into the same handler, so introspecting individual .eq calls
|
||||
// is not feasible without rebuilding the mock.
|
||||
const fs = await import('node:fs/promises')
|
||||
const path = await import('node:path')
|
||||
const routePath = path.resolve(__dirname, '../route.ts')
|
||||
const source = await fs.readFile(routePath, 'utf8')
|
||||
expect(source).toMatch(/\.eq\(['"]is_current_version['"],\s*true\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { validateDocumentMagicBytes } from '@/lib/core/documents/document-service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('documents.integrity')
|
||||
|
||||
const ParamsSchema = z.object({ id: z.string().uuid() })
|
||||
|
||||
/**
|
||||
* GET /api/documents/:id/integrity
|
||||
*
|
||||
* Probes the actual stored bytes against the declared MIME type. Used by the
|
||||
* Bilagor modal to surface a clear "this file is corrupt — please re-upload"
|
||||
* warning instead of relying on the browser's PDF viewer error UI, which
|
||||
* only fires after the user has already tried to view the file.
|
||||
*
|
||||
* Some legacy MCP uploads landed with non-PDF bytes under
|
||||
* `mime_type = 'application/pdf'` because magic-byte validation was added
|
||||
* after those rows were written. This endpoint lets the UI detect and steer
|
||||
* the user toward replacing them.
|
||||
*
|
||||
* Response shape is intentionally minimal — { valid: boolean } only. The
|
||||
* reason for an invalid result is logged server-side rather than returned
|
||||
* to the client to avoid information disclosure (V1.2.5 / GDPR Art 25(2))
|
||||
* and to keep this from being a probe surface for storage internals.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { user, supabase, error } = await requireAuth()
|
||||
if (error) return error
|
||||
|
||||
const rawParams = await params
|
||||
const parsed = ParamsSchema.safeParse(rawParams)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid document id' }, { status: 400 })
|
||||
}
|
||||
const { id } = parsed.data
|
||||
|
||||
// Filter to the current version. The integrity check is meaningful only
|
||||
// on the live file; superseded versions are archived bytes and should
|
||||
// not be re-probed (they're already preserved in the version chain
|
||||
// exactly as uploaded).
|
||||
const { data: doc, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, company_id, mime_type, storage_path')
|
||||
.eq('id', id)
|
||||
.eq('is_current_version', true)
|
||||
.single()
|
||||
|
||||
if (docError || !doc) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Tenant membership: even with the user-scoped supabase client below,
|
||||
// we want a clear 404 rather than relying on a storage-layer RLS deny
|
||||
// (which can present as a generic error). RLS on document_attachments
|
||||
// is the primary control; this is defense in depth.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.eq('company_id', doc.company_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!doc.mime_type) {
|
||||
return NextResponse.json({ data: { valid: true } })
|
||||
}
|
||||
|
||||
// Use the user-scoped supabase client so the storage download is subject
|
||||
// to RLS on storage.objects, not just the application-layer membership
|
||||
// check above. A logic bug in the membership check would still be
|
||||
// arrested at the storage layer.
|
||||
const { data: blob, error: downloadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(doc.storage_path)
|
||||
|
||||
if (downloadError || !blob) {
|
||||
log.error('storage download failed for integrity check', downloadError as Error, {
|
||||
documentId: id,
|
||||
companyId: doc.company_id,
|
||||
})
|
||||
return NextResponse.json({ error: 'Integrity check unavailable' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Only the first 16 bytes are needed for magic-byte detection (PDF/PNG
|
||||
// use ≤8, WebP needs 12). Trimming here doesn't change bandwidth — the
|
||||
// full blob is already downloaded — but it makes the intent explicit and
|
||||
// keeps memory churn off the hot path for large PDFs.
|
||||
const headerBuffer = await blob.slice(0, 16).arrayBuffer()
|
||||
const magicError = validateDocumentMagicBytes(headerBuffer, doc.mime_type)
|
||||
|
||||
if (magicError) {
|
||||
log.warn('document failed magic-byte integrity check', {
|
||||
documentId: id,
|
||||
companyId: doc.company_id,
|
||||
reason: magicError,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { valid: magicError === null } })
|
||||
}
|
||||
@@ -41,6 +41,15 @@ interface AttachmentPreviewSheetProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// Tri-state integrity result so a transport/parse failure does not get
|
||||
// collapsed into "valid". Document bytes are immutable (WORM), so once we
|
||||
// have a definitive valid/invalid we can also memoise across re-opens of
|
||||
// the sheet — the integrity probe is the most expensive call on this
|
||||
// surface and there's no need to re-run it for the same document twice in
|
||||
// a session.
|
||||
type IntegrityState = 'valid' | 'invalid' | 'error'
|
||||
const integrityCache = new Map<string, IntegrityState>()
|
||||
|
||||
function isImageType(type: string | null): boolean {
|
||||
return type?.startsWith('image/') ?? false
|
||||
}
|
||||
@@ -65,6 +74,7 @@ export default function AttachmentPreviewSheet({
|
||||
const { toast } = useToast()
|
||||
const [documents, setDocuments] = useState<DocumentRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [integrity, setIntegrity] = useState<Record<string, IntegrityState>>({})
|
||||
|
||||
const [blockedDoc, setBlockedDoc] = useState<DocumentRecord | null>(null)
|
||||
const [replacingDocId, setReplacingDocId] = useState<string | null>(null)
|
||||
@@ -97,8 +107,61 @@ export default function AttachmentPreviewSheet({
|
||||
})
|
||||
)
|
||||
setDocuments(enriched)
|
||||
|
||||
// Probe storage bytes for PDFs that we haven't already classified in
|
||||
// this session. Legacy MCP uploads from before magic-byte validation
|
||||
// can have non-PDF bytes stored under mime_type='application/pdf';
|
||||
// Chrome's PDF viewer surfaces this as "Failed to load PDF document"
|
||||
// only after the user has tried to view the file, so we want a
|
||||
// clearer warning up front.
|
||||
//
|
||||
// Why per-session caching: document bytes are immutable (WORM) once
|
||||
// uploaded, so a definitive valid/invalid result never changes for
|
||||
// the same document id. Re-running the probe every time the sheet
|
||||
// opens would be wasted bandwidth and unnecessary processing of
|
||||
// financial documents (GDPR Art. 5(1)(b) data minimisation).
|
||||
const seeded: Record<string, IntegrityState> = {}
|
||||
const needsProbe: DocumentRecord[] = []
|
||||
for (const doc of enriched) {
|
||||
if (doc.mime_type !== 'application/pdf') continue
|
||||
const cached = integrityCache.get(doc.id)
|
||||
if (cached) {
|
||||
seeded[doc.id] = cached
|
||||
} else {
|
||||
needsProbe.push(doc)
|
||||
}
|
||||
}
|
||||
if (Object.keys(seeded).length > 0) {
|
||||
setIntegrity(seeded)
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
needsProbe.map(async (doc) => {
|
||||
try {
|
||||
const r = await fetch(`/api/documents/${doc.id}/integrity`)
|
||||
if (!r.ok) {
|
||||
// Server reachable but returned a non-2xx — treat as unknown.
|
||||
// Caching the error would stick across reloads of the sheet,
|
||||
// which is not what we want for transient 5xx.
|
||||
return [doc.id, 'error' as const, false] as const
|
||||
}
|
||||
const { data } = await r.json()
|
||||
const state: IntegrityState = data?.valid === false ? 'invalid' : 'valid'
|
||||
return [doc.id, state, true] as const
|
||||
} catch {
|
||||
return [doc.id, 'error' as const, false] as const
|
||||
}
|
||||
})
|
||||
)
|
||||
const next: Record<string, IntegrityState> = { ...seeded }
|
||||
for (const [docId, state, cache] of results) {
|
||||
next[docId] = state
|
||||
if (cache) integrityCache.set(docId, state)
|
||||
}
|
||||
setIntegrity(next)
|
||||
} catch {
|
||||
setDocuments([])
|
||||
setIntegrity({})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -109,6 +172,7 @@ export default function AttachmentPreviewSheet({
|
||||
fetchAttachments(entryId)
|
||||
} else if (!open) {
|
||||
setDocuments([])
|
||||
setIntegrity({})
|
||||
setBlockedDoc(null)
|
||||
}
|
||||
}, [open, entryId, fetchAttachments])
|
||||
@@ -243,36 +307,75 @@ export default function AttachmentPreviewSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPdfType(doc.mime_type) && (
|
||||
{isPdfType(doc.mime_type) && integrity[doc.id] === 'invalid' && (
|
||||
<div className="flex h-[70vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 p-6 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-warning/15">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-2">
|
||||
<p className="text-sm font-medium">{t('corrupt_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('corrupt_body')}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleOpenReplacePicker(doc.id)}
|
||||
disabled={isReplacing}
|
||||
>
|
||||
{isReplacing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{tj('replace_uploading')}
|
||||
</>
|
||||
) : (
|
||||
t('corrupt_replace_cta')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPdfType(doc.mime_type) && integrity[doc.id] !== 'invalid' && (
|
||||
// <object> + type="application/pdf" invokes Chrome's PDF
|
||||
// plugin directly. <iframe> went through Chrome's frame
|
||||
// pipeline first and intermittently surfaced
|
||||
// "Det här innehållet har blockerats" even with a
|
||||
// permissive CSP. Firefox/Edge handled both fine; Chrome
|
||||
// is the odd one. See crbug.com/271452.
|
||||
<object
|
||||
data={inlineSrc}
|
||||
type="application/pdf"
|
||||
aria-label={doc.file_name}
|
||||
className="h-[70vh] w-full rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
|
||||
{t('not_previewable')}
|
||||
{doc.download_url && (
|
||||
<>
|
||||
{' — '}
|
||||
<a
|
||||
href={doc.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t('open_in_new_tab')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</object>
|
||||
<>
|
||||
{integrity[doc.id] === 'error' && (
|
||||
// Non-blocking indicator when the integrity probe
|
||||
// could not complete (network blip, 5xx). The PDF
|
||||
// preview still renders; the user can fall back to
|
||||
// Chrome's own viewer error UI if the bytes are
|
||||
// also corrupt. Surfacing this rather than falling
|
||||
// through silently to "valid" satisfies SOC 2 CC8.1
|
||||
// — failed checks must not be masked.
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('integrity_unknown')}
|
||||
</p>
|
||||
)}
|
||||
<object
|
||||
data={inlineSrc}
|
||||
type="application/pdf"
|
||||
aria-label={doc.file_name}
|
||||
className="h-[70vh] w-full rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
|
||||
{t('not_previewable')}
|
||||
{doc.download_url && (
|
||||
<>
|
||||
{' — '}
|
||||
<a
|
||||
href={doc.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t('open_in_new_tab')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</object>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isImageType(doc.mime_type) && (
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
+5
-1
@@ -2680,7 +2680,11 @@
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"not_previewable": "Preview is not available for this file type.",
|
||||
"remove": "Remove",
|
||||
"replace": "Replace with new version"
|
||||
"replace": "Replace with new version",
|
||||
"corrupt_title": "File appears to be corrupt",
|
||||
"corrupt_body": "The file cannot be read as a PDF. Upload a new version to replace it — the old one is kept in the version history.",
|
||||
"corrupt_replace_cta": "Upload new version",
|
||||
"integrity_unknown": "File integrity could not be verified — try again later if the preview does not appear."
|
||||
},
|
||||
"journal_attachments": {
|
||||
"loading": "Loading documents...",
|
||||
|
||||
+5
-1
@@ -2680,7 +2680,11 @@
|
||||
"open_in_new_tab": "Öppna i nytt fönster",
|
||||
"not_previewable": "Förhandsvisning är inte tillgänglig för denna filtyp.",
|
||||
"remove": "Ta bort",
|
||||
"replace": "Ersätt med ny version"
|
||||
"replace": "Ersätt med ny version",
|
||||
"corrupt_title": "Filen verkar vara skadad",
|
||||
"corrupt_body": "Filen kan inte läsas som PDF. Ladda upp en ny version så ersätter den den trasiga — den gamla bevaras i versionshistoriken.",
|
||||
"corrupt_replace_cta": "Ladda upp ny version",
|
||||
"integrity_unknown": "Filens integritet kunde inte verifieras — försök igen senare om förhandsgranskningen inte visas."
|
||||
},
|
||||
"journal_attachments": {
|
||||
"loading": "Laddar underlag...",
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
-- Restore document version supersession on posted entries.
|
||||
--
|
||||
|
||||
-- Background: 20260506150000 extended enforce_document_metadata_immutability
|
||||
-- to also block changes to journal_entry_id, journal_entry_line_id, AND
|
||||
-- is_current_version when the document is linked to a posted/reversed entry.
|
||||
-- The journal_entry_id / line_id additions are correct — they close a real
|
||||
-- BFL 7 kap 2§ bypass (UPDATE journal_entry_id = NULL → DELETE).
|
||||
--
|
||||
-- The is_current_version addition was overreach: the create_document_version
|
||||
-- RPC must flip the OLD row from is_current_version = true to false (and
|
||||
-- set superseded_by_id) as part of the legitimate WORM-compliant supersession
|
||||
-- flow. Every replace attempt on a doc linked to a posted verifikat now
|
||||
-- raises "Cannot modify metadata or journal entry link of document linked
|
||||
-- to a posted journal entry (BFL 7 kap)" — which surfaces in the Bilagor
|
||||
-- modal as "Kunde inte ladda upp ny version".
|
||||
--
|
||||
-- This blocks the only path users have to fix corrupt underlag: a PDF that
|
||||
-- was uploaded with bad bytes (e.g. via the MCP server before magic-byte
|
||||
-- validation landed in 20260526) is now permanently unreadable on a posted
|
||||
-- entry, with no replacement possible.
|
||||
--
|
||||
-- Fix: introduce a transaction-local gnubok.allow_supersede GUC. Unlike
|
||||
-- gnubok.allow_delete, this is NOT a blanket bypass — the trigger continues
|
||||
-- to enforce immutability on every field except is_current_version and
|
||||
-- superseded_by_id even when the GUC is set. Combined with caller-identity
|
||||
-- checks inside create_document_version (auth.uid() match + company
|
||||
-- membership), an attacker who sets the GUC manually still cannot mutate
|
||||
-- journal_entry_id, sha256_hash, storage_path, or any other audit-critical
|
||||
-- field on a posted document.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_document_metadata_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_entry_status text;
|
||||
v_allow_supersede boolean;
|
||||
BEGIN
|
||||
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
v_allow_supersede := current_setting('gnubok.allow_supersede', true) = 'true';
|
||||
|
||||
IF OLD.journal_entry_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT status INTO v_entry_status
|
||||
FROM public.journal_entries
|
||||
WHERE id = OLD.journal_entry_id;
|
||||
|
||||
IF v_entry_status IS NULL OR v_entry_status NOT IN ('posted', 'reversed') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Even with allow_supersede, every field other than is_current_version and
|
||||
-- superseded_by_id remains immutable. The bypass is intentionally narrow
|
||||
-- so that a session that obtains the GUC (e.g. via SQL injection) cannot
|
||||
-- mutate journal_entry_id, sha256_hash, storage_path, or any other field
|
||||
-- the BFL 7 kap audit trail depends on.
|
||||
IF NEW.file_name IS DISTINCT FROM OLD.file_name
|
||||
OR NEW.storage_path IS DISTINCT FROM OLD.storage_path
|
||||
OR NEW.file_size_bytes IS DISTINCT FROM OLD.file_size_bytes
|
||||
OR NEW.mime_type IS DISTINCT FROM OLD.mime_type
|
||||
OR NEW.sha256_hash IS DISTINCT FROM OLD.sha256_hash
|
||||
OR NEW.upload_source IS DISTINCT FROM OLD.upload_source
|
||||
OR NEW.digitization_date IS DISTINCT FROM OLD.digitization_date
|
||||
OR NEW.uploaded_by IS DISTINCT FROM OLD.uploaded_by
|
||||
OR NEW.version IS DISTINCT FROM OLD.version
|
||||
OR NEW.original_id IS DISTINCT FROM OLD.original_id
|
||||
OR NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id
|
||||
OR NEW.journal_entry_line_id IS DISTINCT FROM OLD.journal_entry_line_id
|
||||
THEN
|
||||
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
|
||||
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
|
||||
'Blocked metadata or link modification of document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
|
||||
|
||||
RAISE EXCEPTION 'Cannot modify metadata or journal entry link of document linked to a % journal entry (BFL 7 kap)', v_entry_status;
|
||||
END IF;
|
||||
|
||||
-- is_current_version and superseded_by_id may only be changed under the
|
||||
-- supersede GUC. Without it, those flips are also blocked.
|
||||
IF NOT v_allow_supersede
|
||||
AND (NEW.is_current_version IS DISTINCT FROM OLD.is_current_version
|
||||
OR NEW.superseded_by_id IS DISTINCT FROM OLD.superseded_by_id)
|
||||
THEN
|
||||
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
|
||||
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
|
||||
'Blocked is_current_version/superseded_by_id flip without supersede GUC on document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
|
||||
|
||||
RAISE EXCEPTION 'Cannot modify is_current_version of document linked to a % journal entry without supersede GUC (BFL 7 kap)', v_entry_status;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_document_version(
|
||||
p_user_id uuid,
|
||||
p_original_doc_id uuid,
|
||||
p_storage_path text,
|
||||
p_file_name text,
|
||||
p_file_size_bytes bigint,
|
||||
p_mime_type text,
|
||||
p_sha256_hash text
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller uuid := auth.uid();
|
||||
v_current document_attachments%ROWTYPE;
|
||||
v_new_id uuid;
|
||||
v_root_id uuid;
|
||||
v_next_version integer;
|
||||
v_is_member boolean;
|
||||
BEGIN
|
||||
-- Caller identity: the RPC is SECURITY DEFINER, so without this guard a
|
||||
-- direct PostgREST call from any authenticated user could supplant
|
||||
-- p_user_id with an arbitrary UUID. Reject any mismatch — the route layer
|
||||
-- already passes the user's own id, so a mismatch is always malicious.
|
||||
IF v_caller IS NULL THEN
|
||||
RAISE EXCEPTION 'Authentication required to create document version';
|
||||
END IF;
|
||||
IF p_user_id IS DISTINCT FROM v_caller THEN
|
||||
RAISE EXCEPTION 'p_user_id does not match authenticated user';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_current
|
||||
FROM public.document_attachments
|
||||
WHERE id = p_original_doc_id
|
||||
AND is_current_version = true
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_current IS NULL THEN
|
||||
RAISE EXCEPTION 'Document % not found or is not the current version', p_original_doc_id;
|
||||
END IF;
|
||||
|
||||
-- Company membership: the caller must be a member of the document's
|
||||
-- company. RLS would block a direct SELECT in non-DEFINER contexts, but
|
||||
-- inside this SECURITY DEFINER function we bypass RLS and must enforce
|
||||
-- the tenant boundary ourselves.
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members cm
|
||||
WHERE cm.company_id = v_current.company_id
|
||||
AND cm.user_id = v_caller
|
||||
) INTO v_is_member;
|
||||
|
||||
IF NOT v_is_member THEN
|
||||
-- Audit the cross-tenant attempt so it shows up in security review.
|
||||
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
|
||||
VALUES (v_caller, v_current.company_id, 'SECURITY_EVENT', 'document_attachments', p_original_doc_id,
|
||||
'Blocked cross-company create_document_version attempt');
|
||||
RAISE EXCEPTION 'User is not a member of the document''s company';
|
||||
END IF;
|
||||
|
||||
v_root_id := COALESCE(v_current.original_id, v_current.id);
|
||||
v_next_version := v_current.version + 1;
|
||||
|
||||
-- Insert new version. The supersede GUC is set *before* both DML statements
|
||||
-- so the trigger sees a consistent state across the whole supersession.
|
||||
PERFORM set_config('gnubok.allow_supersede', 'true', true);
|
||||
|
||||
INSERT INTO public.document_attachments (
|
||||
user_id, company_id, storage_path, file_name, file_size_bytes,
|
||||
mime_type, sha256_hash, version, original_id, is_current_version,
|
||||
uploaded_by, upload_source, digitization_date,
|
||||
journal_entry_id, journal_entry_line_id, prev_version_hash
|
||||
) VALUES (
|
||||
p_user_id, v_current.company_id, p_storage_path, p_file_name,
|
||||
p_file_size_bytes, p_mime_type, p_sha256_hash, v_next_version,
|
||||
v_root_id, true, p_user_id, v_current.upload_source, now(),
|
||||
v_current.journal_entry_id, v_current.journal_entry_line_id,
|
||||
v_current.sha256_hash
|
||||
)
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
UPDATE public.document_attachments
|
||||
SET is_current_version = false,
|
||||
superseded_by_id = v_new_id
|
||||
WHERE id = p_original_doc_id;
|
||||
|
||||
-- Audit the legitimate supersession. BFL 7 kap requires the supersession
|
||||
-- chain to be reconstructible from immutable storage; the row chain itself
|
||||
-- carries the data, but an explicit audit row makes the event visible to
|
||||
-- SOC 2 monitoring without joining version timelines.
|
||||
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, description)
|
||||
VALUES (
|
||||
v_caller, v_current.company_id, 'UPDATE', 'document_attachments', p_original_doc_id, v_caller,
|
||||
'Document superseded: v' || v_current.version || ' (' || v_current.sha256_hash || ') → v' || v_next_version || ' (' || p_sha256_hash || '); new id=' || v_new_id
|
||||
);
|
||||
|
||||
RETURN v_new_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user