Files
accounted/app/api/account/delete/__tests__/route.test.ts
T
Mattsson a1a816b4a5 Delete features (#218)
* 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.
2026-04-11 17:06:32 +02:00

155 lines
4.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { POST } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockCreateServiceClient = vi.mocked(createServiceClient)
function mockAuth(
user: { id: string; email: string | null } | null,
rpcResult: { data?: unknown; error?: unknown } = { data: null, error: null }
) {
const signOut = vi.fn().mockResolvedValue({ error: null })
const rpc = vi.fn().mockResolvedValue(rpcResult)
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user } }),
signOut,
},
rpc,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
return { rpc, signOut }
}
function mockServiceClient(blockers: { id: string; name: string }[] = []) {
const updateUserById = vi.fn().mockResolvedValue({ data: {}, error: null })
const adminSignOut = vi.fn().mockResolvedValue({ data: null, error: null })
const chain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
is: vi.fn().mockResolvedValue({
data: blockers.map((b) => ({ companies: { id: b.id, name: b.name } })),
error: null,
}),
}
mockCreateServiceClient.mockReturnValue({
from: vi.fn().mockReturnValue(chain),
auth: {
admin: {
updateUserById,
signOut: adminSignOut,
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
return { updateUserById, adminSignOut }
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('POST /api/account/delete', () => {
it('returns 401 when unauthenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/account/delete', {
method: 'POST',
body: { confirm_email: 'u@example.com' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(401)
})
it('returns 400 when confirm_email does not match', async () => {
mockAuth({ id: 'user-1', email: 'right@example.com' })
const req = createMockRequest('/api/account/delete', {
method: 'POST',
body: { confirm_email: 'wrong@example.com' },
})
const { status, body } = await parseJsonResponse(await POST(req))
expect(status).toBe(400)
expect(body).toHaveProperty('error')
})
it('returns 409 with blockers when RPC raises P0001', async () => {
mockAuth(
{ id: 'user-1', email: 'u@example.com' },
{ data: null, error: { code: 'P0001', message: 'blocked' } }
)
mockServiceClient([{ id: 'c1', name: 'Acme AB' }])
const req = createMockRequest('/api/account/delete', {
method: 'POST',
body: { confirm_email: 'u@example.com' },
})
const { status, body } = await parseJsonResponse<{
error: string
blockers: { id: string; name: string }[]
}>(await POST(req))
expect(status).toBe(409)
expect(body.blockers).toEqual([{ id: 'c1', name: 'Acme AB' }])
})
it('anonymizes, bans, signs out, and emits event on happy path', async () => {
const { rpc } = mockAuth({ id: 'user-1', email: 'u@example.com' })
const { updateUserById, adminSignOut } = mockServiceClient()
const emitted: unknown[] = []
eventBus.on('account.deleted', (payload) => {
emitted.push(payload)
})
const req = createMockRequest('/api/account/delete', {
method: 'POST',
body: { confirm_email: 'u@example.com' },
})
const { status, body } = await parseJsonResponse<{ success: boolean }>(
await POST(req)
)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(rpc).toHaveBeenCalledWith('anonymize_user_account', {
target_user_id: 'user-1',
})
expect(updateUserById).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({
user_metadata: {},
app_metadata: {},
ban_duration: expect.any(String),
})
)
// Email must NOT be scrubbed — retaining it is what blocks re-signup
// with the same address. Recovery goes through support instead.
const updatePayload = updateUserById.mock.calls[0][1]
expect(updatePayload).not.toHaveProperty('email')
expect(adminSignOut).toHaveBeenCalledWith('user-1', 'global')
expect(emitted).toHaveLength(1)
expect(emitted[0]).toMatchObject({ userId: 'user-1' })
})
})