Files
accounted/extensions/general/bolagsverket/__tests__/index.test.ts
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

245 lines
9.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
vi.mock('../lib/submission-service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/submission-service')>()
return {
...actual,
submitArsredovisning: vi.fn(),
}
})
import { bolagsverketExtension } from '../index'
import { BolagsverketSubmissionError, submitArsredovisning } from '../lib/submission-service'
function route(method: string, path: string) {
const found = bolagsverketExtension.apiRoutes?.find(
(r) => r.method === method && r.path === path,
)
if (!found) throw new Error(`route ${method} ${path} not found`)
return found
}
function makeCtx(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'bolagsverket',
requestId: 'req-1',
supabase,
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn(), clear: vi.fn() },
storage: {},
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
services: {},
} as unknown as ExtensionContext
}
const validBody = {
fiscal_period_id: '123e4567-e89b-12d3-a456-426614174000',
annual_report_version_id: '123e4567-e89b-12d3-a456-426614174001',
avsandare_pnr: '198001019879',
undertecknare: {
pnr: '198001019879',
fornamn: 'Anna',
efternamn: 'Svensson',
roll: 'VD',
epost: 'anna@example.com',
},
}
function makePost(path: string, body: unknown = validBody): Request {
return new Request(`http://localhost/api/extensions/ext/bolagsverket${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
interface ErrorEnvelope {
error: { code: string; message: string; message_en?: string }
}
const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL
const ORIGINAL_BV_ENV = process.env.BOLAGSVERKET_ENV
const ORIGINAL_FILING_ENABLED = process.env.BOLAGSVERKET_FILING_ENABLED
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
process.env.BOLAGSVERKET_FILING_ENABLED = 'true'
delete process.env.BOLAGSVERKET_ENV
})
afterEach(() => {
if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
if (ORIGINAL_BV_ENV === undefined) delete process.env.BOLAGSVERKET_ENV
else process.env.BOLAGSVERKET_ENV = ORIGINAL_BV_ENV
if (ORIGINAL_FILING_ENABLED === undefined) delete process.env.BOLAGSVERKET_FILING_ENABLED
else process.env.BOLAGSVERKET_FILING_ENABLED = ORIGINAL_FILING_ENABLED
})
describe('POST /submissions: write-role enforcement', () => {
it('rejects viewer members with 403 BOLAGSVERKET_FORBIDDEN', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'viewer' }, error: null }) // company_members
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
})
it('rejects non-members (no role row) with 403', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // no membership
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
})
it('rejects viewer members on POST /poll-events too', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'viewer' }, error: null })
const res = await route('POST', '/poll-events').handler(makePost('/poll-events', {}), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
})
})
describe('POST /submissions: environment validation + ceiling', () => {
it('rejects an invalid environment setting with 400 BOLAGSVERKET_INVALID_ENVIRONMENT', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null }) // company_members
enqueue({ data: { value: { environment: 'banana' } }, error: null }) // settings blob
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(400)
expect(body.error.code).toBe('BOLAGSVERKET_INVALID_ENVIRONMENT')
})
it('rejects an environment above the BOLAGSVERKET_ENV ceiling (unset → test)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
enqueue({ data: { value: { environment: 'prod' } }, error: null })
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_ENV_NOT_ALLOWED')
})
it('allows an environment at or below the ceiling', async () => {
process.env.BOLAGSVERKET_ENV = 'accept'
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
enqueue({ data: { value: { environment: 'test' } }, error: null })
vi.mocked(submitArsredovisning).mockResolvedValue({
outcome: 'uploaded',
submissionId: 'sub-1',
idnummer: '49679',
sha256: 'sha',
url: 'https://ext.bolagsverket.se/eu/49679',
utfall: [],
})
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<{
data: { outcome: string; idnummer?: string }
}>(res as Response)
expect(status).toBe(200)
expect(body.data.outcome).toBe('uploaded')
expect(body.data.idnummer).toBeUndefined()
// The service got a client pinned to the validated environment.
const deps = vi.mocked(submitArsredovisning).mock.calls[0][0]
expect(deps.client.environment).toBe('test')
expect(deps.appUrl).toBe('http://localhost:3000')
})
})
describe('POST /submissions: config + error mapping', () => {
it('fails fast with 503 BOLAGSVERKET_CONFIG_MISSING when NEXT_PUBLIC_APP_URL is unset', async () => {
delete process.env.NEXT_PUBLIC_APP_URL
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'owner' }, error: null })
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(503)
expect(body.error.code).toBe('BOLAGSVERKET_CONFIG_MISSING')
})
it('maps BolagsverketSubmissionError to its structured code (409 double submission)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
enqueue({ data: { value: { environment: 'test' } }, error: null })
vi.mocked(submitArsredovisning).mockRejectedValue(
new BolagsverketSubmissionError('BOLAGSVERKET_SUBMISSION_EXISTS', 'already active', {
submission_id: 'sub-0',
}),
)
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(409)
expect(body.error.code).toBe('BOLAGSVERKET_SUBMISSION_EXISTS')
})
it('returns 400 VALIDATION_ERROR for a malformed body', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
const res = await route('POST', '/submissions').handler(
makePost('/submissions', { fiscal_period_id: 'not-a-uuid' }),
makeCtx(supabase),
)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects an invalid personnummer before calling the filing service', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
const res = await route('POST', '/submissions').handler(
makePost('/submissions', {
...validBody,
avsandare_pnr: '198002309879',
}),
makeCtx(supabase),
)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(submitArsredovisning).not.toHaveBeenCalled()
})
})
describe('GET /status', () => {
it('reports resolved environment, ceiling, and env-derived certificate presence', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { value: { environment: 'test' } }, error: null })
const res = await route('GET', '/status').handler(
new Request('http://localhost/api/extensions/ext/bolagsverket/status'),
makeCtx(supabase),
)
const { status, body } = await parseJsonResponse<{
data: { environment: string; environment_ceiling: string; has_certificate: boolean }
}>(res as Response)
expect(status).toBe(200)
expect(body.data.environment).toBe('test')
expect(body.data.environment_ceiling).toBe('test')
// No BOLAGSVERKET_CLIENT_CERT/_KEY in the test env.
expect(body.data.has_certificate).toBe(false)
})
})