d708a85d4c
* feat: cloud backup to Google Drive + full-archive all-scope Adds a cloud-backup extension that uploads a full-company backup ZIP to the user's own Google Drive via OAuth (drive.file scope only). Refresh tokens are AES-256-GCM encrypted before being stored in extension_data. The full-archive export gains a scope=all mode for whole-company backups (per-period SIE under sie/, per-period rapporter/ subfolders, flat dokument/ manifest tagged with fiscal_period_id). An 80 MB size guard short-circuits generation before the platform response limit. Also fixes a latent bug in lib/core/audit/audit-service.ts where the parameter was named userId while the query filtered by company_id; the audit-trail API route was passing user.id so audit queries returned empty unless user and company shared a UUID. Drive-by: scope the dashboard "fresh start" localStorage key per companyId so dismissing the setup checklist in one company no longer carries over to others. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address review comments on cloud backup + archive export - Extend audit trail to_date to end-of-day so last-day entries aren't silently excluded from period-scoped archives. - Apply 413 size-limit guard regardless of include_documents, using the overhead-only figure when documents are excluded. - Use crypto.randomUUID() for Drive multipart boundary to eliminate any collision risk with ZIP payload bytes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: migrate legacy setup-gate localStorage keys on dashboard Users who previously dismissed the setup checklist via the old global erp_setup_fresh_start or erp_checklist_dismissed keys were re-gated after the switch to a company-scoped key. Fall back to the legacy keys on read and migrate them to the scoped key on first hit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: update customer email handling and anonymization rules in supportmail-to-ticket skill * test: update audit trail to_date expectation for end-of-day timestamp Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
3.3 KiB
TypeScript
115 lines
3.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/core/audit/audit-service', () => ({
|
|
getAuditLog: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
|
import { GET } from '../route'
|
|
|
|
const mockCreateClient = vi.mocked(createClient)
|
|
const mockGetAuditLog = vi.mocked(getAuditLog)
|
|
|
|
function mockAuth(userId: string | null) {
|
|
mockCreateClient.mockResolvedValue({
|
|
auth: {
|
|
getUser: vi.fn().mockResolvedValue({
|
|
data: { user: userId ? { id: userId } : null },
|
|
}),
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('GET /api/audit-trail', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
mockAuth(null)
|
|
const req = createMockRequest('/api/audit-trail')
|
|
const { status, body } = await parseJsonResponse(await GET(req))
|
|
expect(status).toBe(401)
|
|
expect(body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('returns audit log with data and count', async () => {
|
|
mockAuth('user-1')
|
|
const entries = [
|
|
{ id: '1', action: 'INSERT', table_name: 'journal_entries', created_at: '2024-01-01T00:00:00Z' },
|
|
{ id: '2', action: 'COMMIT', table_name: 'journal_entries', created_at: '2024-01-02T00:00:00Z' },
|
|
]
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockGetAuditLog.mockResolvedValue({ data: entries as any, count: 2 })
|
|
|
|
const req = createMockRequest('/api/audit-trail')
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(await GET(req))
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data).toHaveLength(2)
|
|
expect(body.count).toBe(2)
|
|
expect(mockGetAuditLog).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
expect.objectContaining({})
|
|
)
|
|
})
|
|
|
|
it('passes query param filters to getAuditLog', async () => {
|
|
mockAuth('user-1')
|
|
mockGetAuditLog.mockResolvedValue({ data: [], count: 0 })
|
|
|
|
const req = createMockRequest('/api/audit-trail', {
|
|
searchParams: {
|
|
action: 'INSERT',
|
|
table_name: 'journal_entries',
|
|
record_id: 'rec-1',
|
|
from_date: '2024-01-01',
|
|
to_date: '2024-12-31',
|
|
page: '2',
|
|
page_size: '25',
|
|
},
|
|
})
|
|
|
|
await GET(req)
|
|
|
|
expect(mockGetAuditLog).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'company-1',
|
|
{
|
|
action: 'INSERT',
|
|
table_name: 'journal_entries',
|
|
record_id: 'rec-1',
|
|
from_date: '2024-01-01',
|
|
to_date: '2024-12-31',
|
|
page: 2,
|
|
pageSize: 25,
|
|
}
|
|
)
|
|
})
|
|
|
|
it('returns 500 on service error', async () => {
|
|
mockAuth('user-1')
|
|
mockGetAuditLog.mockRejectedValue(new Error('DB error'))
|
|
|
|
const req = createMockRequest('/api/audit-trail')
|
|
const { status, body } = await parseJsonResponse(await GET(req))
|
|
|
|
expect(status).toBe(500)
|
|
expect(body).toEqual({ error: 'DB error' })
|
|
})
|
|
})
|