* Implement company and account deletion features - Add event types for company and account deletion to CoreEvent. - Enhance Supabase middleware to handle company context resolution and cookie management for archived companies. - Create API routes for deleting accounts and companies, including necessary validations and event emissions. - Implement tests for account and company deletion endpoints to ensure proper functionality and error handling. - Add retention notice component to inform users about bookkeeping data retention during destructive actions. - Create database migrations to support soft deletion of companies and anonymization of user accounts, ensuring compliance with retention laws. * feat: enhance account deletion process and update user notifications * Add service client for onboarding completion check and update escape hatch visibility * Enhance invite flow and email handling for company members * Refactor company context and RLS policies for active company isolation - Update `switchCompany` to remove unnecessary revalidation as client handles navigation. - Revise `getActiveCompanyId` to prioritize `user_preferences` and validate against non-archived memberships. - Modify `setActiveCompany` to ensure `user_preferences` is the authoritative source while maintaining cookie compatibility. - Enhance middleware to resolve active company using `user_preferences` and fallback to first non-archived membership. - Introduce new API route `/api/company/current` to fetch the active company ID for cross-tab synchronization. - Implement `CompanyTabSync` component for real-time active company enforcement across tabs. - Create migration for RLS policies to enforce single-active-company isolation using `current_active_company_id()`. * feat: implement viewer role enforcement for write permissions - Added `useCanWrite` hook to determine if the current user has write permissions based on their role in the active company. - Updated various components (JournalEntryForm, CustomerForm, DeadlineForm, etc.) to disable write actions and show a lock icon with a tooltip for users without write permissions. - Introduced `requireWritePermission` function to enforce write permissions at the API level, returning a 403 response for viewers. - Created tests to verify the behavior of the viewer role and write permissions. - Added database migration to enforce read-only access for viewers at the database level.
247 lines
8.0 KiB
TypeScript
247 lines
8.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// Mock supabase
|
|
const mockFrom = vi.fn()
|
|
const mockRpc = vi.fn()
|
|
const mockAuth = vi.fn()
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn().mockResolvedValue({
|
|
from: (...args: unknown[]) => mockFrom(...args),
|
|
rpc: (...args: unknown[]) => mockRpc(...args),
|
|
auth: { getUser: () => mockAuth() },
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
|
}))
|
|
|
|
import { GET, POST } from '../route'
|
|
|
|
function mockChain(result: { data?: unknown; error?: unknown; count?: number | null }) {
|
|
const chain: Record<string, unknown> = {}
|
|
for (const m of ['select', 'eq', 'neq', 'in', 'insert', 'update', 'upsert', 'order', 'limit', 'single', 'maybeSingle']) {
|
|
chain[m] = vi.fn().mockReturnValue(chain)
|
|
}
|
|
chain.single = vi.fn().mockResolvedValue(result)
|
|
chain.maybeSingle = vi.fn().mockResolvedValue(result)
|
|
// Make the chain thenable so await resolves to result
|
|
chain.then = (resolve: (v: unknown) => void) => resolve(result)
|
|
return chain
|
|
}
|
|
|
|
function makeRequest(url: string, options?: RequestInit) {
|
|
return new Request(url, options)
|
|
}
|
|
|
|
async function parseJson(response: Response) {
|
|
return response.json()
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('GET /api/bookkeeping/voucher-gaps', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: null } })
|
|
|
|
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=fp-1')
|
|
const response = await GET(request)
|
|
const body = await parseJson(response)
|
|
|
|
expect(response.status).toBe(401)
|
|
expect(body.error).toBe('Unauthorized')
|
|
})
|
|
|
|
it('returns 400 when fiscal_period_id is missing', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps')
|
|
const response = await GET(request)
|
|
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('returns empty result when no voucher sequences exist', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
mockFrom.mockReturnValue(mockChain({ data: [], error: null }))
|
|
|
|
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
|
|
const response = await GET(request)
|
|
const body = await parseJson(response)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data.gaps).toEqual([])
|
|
expect(body.data.totalGaps).toBe(0)
|
|
expect(body.data.unexplainedGaps).toBe(0)
|
|
})
|
|
|
|
it('returns gaps with explanations', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
let fromCallCount = 0
|
|
mockFrom.mockImplementation(() => {
|
|
fromCallCount++
|
|
if (fromCallCount === 1) {
|
|
// voucher_sequences query
|
|
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
|
|
}
|
|
if (fromCallCount === 2) {
|
|
// gap_explanations query
|
|
return mockChain({
|
|
data: [{ id: 'exp-1', voucher_series: 'A', gap_start: 3, gap_end: 3, explanation: 'Transient error', user_id: 'user-1', created_at: '2026-01-01' }],
|
|
error: null,
|
|
})
|
|
}
|
|
return mockChain({ data: null, error: null })
|
|
})
|
|
|
|
mockRpc.mockResolvedValue({
|
|
data: [{ gap_start: 3, gap_end: 3 }],
|
|
error: null,
|
|
})
|
|
|
|
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
|
|
const response = await GET(request)
|
|
const body = await parseJson(response)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data.totalGaps).toBe(1)
|
|
expect(body.data.unexplainedGaps).toBe(0)
|
|
expect(body.data.gaps[0].explanation).toBeTruthy()
|
|
expect(body.data.gaps[0].explanation.explanation).toBe('Transient error')
|
|
})
|
|
|
|
it('returns unexplained gaps', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
let fromCallCount = 0
|
|
mockFrom.mockImplementation(() => {
|
|
fromCallCount++
|
|
if (fromCallCount === 1) {
|
|
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
|
|
}
|
|
if (fromCallCount === 2) {
|
|
return mockChain({ data: [], error: null })
|
|
}
|
|
return mockChain({ data: null, error: null })
|
|
})
|
|
|
|
mockRpc.mockResolvedValue({
|
|
data: [{ gap_start: 5, gap_end: 7 }],
|
|
error: null,
|
|
})
|
|
|
|
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
|
|
const response = await GET(request)
|
|
const body = await parseJson(response)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data.totalGaps).toBe(1)
|
|
expect(body.data.unexplainedGaps).toBe(1)
|
|
expect(body.data.gaps[0].explanation).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('POST /api/bookkeeping/voucher-gaps', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: null } })
|
|
|
|
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
gap_start: 5,
|
|
gap_end: 7,
|
|
explanation: 'Transient DB error during commit',
|
|
}),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
const response = await POST(request)
|
|
|
|
expect(response.status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 when explanation is empty', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
gap_start: 5,
|
|
gap_end: 7,
|
|
explanation: '',
|
|
}),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
const response = await POST(request)
|
|
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('saves gap explanation successfully', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
const savedRow = {
|
|
id: 'exp-1',
|
|
company_id: 'company-1',
|
|
user_id: 'user-1',
|
|
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
voucher_series: 'A',
|
|
gap_start: 5,
|
|
gap_end: 7,
|
|
explanation: 'Failed commit during bank sync',
|
|
created_at: '2026-04-01',
|
|
updated_at: '2026-04-01',
|
|
}
|
|
|
|
mockFrom.mockReturnValue(mockChain({ data: savedRow, error: null }))
|
|
|
|
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
gap_start: 5,
|
|
gap_end: 7,
|
|
explanation: 'Failed commit during bank sync',
|
|
}),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
const response = await POST(request)
|
|
const body = await parseJson(response)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data.explanation).toBe('Failed commit during bank sync')
|
|
})
|
|
|
|
it('returns 403 when user lacks permission', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
mockFrom.mockReturnValue(mockChain({ data: null, error: { code: '42501', message: 'permission denied' } }))
|
|
|
|
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
gap_start: 5,
|
|
gap_end: 7,
|
|
explanation: 'Test explanation',
|
|
}),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
const response = await POST(request)
|
|
const body = await parseJson(response)
|
|
|
|
expect(response.status).toBe(403)
|
|
expect(body.error).toContain('owners and admins')
|
|
})
|
|
})
|