diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index 5a70dd7f..681d5fbd 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -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 } diff --git a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts index 69fb3c61..92a2975d 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts @@ -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 } diff --git a/app/api/documents/[id]/integrity/__tests__/route.test.ts b/app/api/documents/[id]/integrity/__tests__/route.test.ts new file mode 100644 index 00000000..a4de8ca5 --- /dev/null +++ b/app/api/documents/[id]/integrity/__tests__/route.test.ts @@ -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 }>(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\)/) + }) +}) diff --git a/app/api/documents/[id]/integrity/route.ts b/app/api/documents/[id]/integrity/route.ts new file mode 100644 index 00000000..6c8ce44c --- /dev/null +++ b/app/api/documents/[id]/integrity/route.ts @@ -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 } }) +} diff --git a/components/bookkeeping/AttachmentPreviewSheet.tsx b/components/bookkeeping/AttachmentPreviewSheet.tsx index 7b472c63..521f15cb 100644 --- a/components/bookkeeping/AttachmentPreviewSheet.tsx +++ b/components/bookkeeping/AttachmentPreviewSheet.tsx @@ -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() + 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([]) const [loading, setLoading] = useState(false) + const [integrity, setIntegrity] = useState>({}) const [blockedDoc, setBlockedDoc] = useState(null) const [replacingDocId, setReplacingDocId] = useState(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 = {} + 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 = { ...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({ - {isPdfType(doc.mime_type) && ( + {isPdfType(doc.mime_type) && integrity[doc.id] === 'invalid' && ( +
+
+ +
+
+

{t('corrupt_title')}

+

{t('corrupt_body')}

+
+ +
+ )} + + {isPdfType(doc.mime_type) && integrity[doc.id] !== 'invalid' && ( // + type="application/pdf" invokes Chrome's PDF // plugin directly.