Files
accounted/extensions/general/skatteverket/__tests__/api-client.test.ts
T
Mattsson 75043632de Fix/compliance and invoices (#433)
* feat(invoices): implement inline membership checks for invoice-number RPCs and enhance error handling

* fix(invoices): enhance unpaid amount calculation to support currency-specific rounding

* fix(api-client): handle response.text() error for 403 status in skvRequest
2026-05-10 19:09:57 +02:00

159 lines
5.8 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
// Mock the token-store to bypass DB and supply a fresh access token.
const deleteTokensMock = vi.fn()
vi.mock('../lib/token-store', () => ({
getTokens: vi.fn(async () => ({
access_token: 'test-access',
refresh_token: 'test-refresh',
expires_at: Date.now() + 60 * 60_000,
refresh_count: 0,
scope: 'momsdeklaration',
})),
storeTokens: vi.fn(),
deleteTokens: (...args: unknown[]) => deleteTokensMock(...args),
}))
// Mock oauth so a refresh attempt (shouldn't fire) is harmless.
vi.mock('../lib/oauth', () => ({
refreshAccessToken: vi.fn(async () => ({
access_token: 'refreshed',
refresh_token: 'refreshed-r',
expires_at: Date.now() + 60 * 60_000,
refresh_count: 1,
})),
exchangeCodeForTokens: vi.fn(),
}))
import { skvRequest, SkatteverketAuthError } from '../lib/api-client'
const fakeSupabase = {} as unknown as Parameters<typeof skvRequest>[0]
beforeEach(() => {
process.env.SKATTEVERKET_APIGW_CLIENT_ID = 'gw-id'
process.env.SKATTEVERKET_APIGW_CLIENT_SECRET = 'gw-secret'
process.env.SKATTEVERKET_API_BASE_URL = 'https://api.test.example/x'
vi.restoreAllMocks()
})
function mockFetchStatus(status: number, body = '', headers?: HeadersInit) {
global.fetch = vi.fn(async () =>
new Response(body, { status, statusText: String(status), headers })
) as unknown as typeof fetch
}
describe('skvRequest — error mapping', () => {
it('maps empty 401 → ACCESS_DENIED (likely missing APIGW subscription)', async () => {
mockFetchStatus(401)
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
expect((e as SkatteverketAuthError).message).toMatch(/Utvecklarportalen|prenumeration/i)
}
})
it('maps 401 with body text → SESSION_EXPIRED with a clean Swedish message (no body leak)', async () => {
mockFetchStatus(401, 'token expired')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
expect((e as SkatteverketAuthError).message).toMatch(/Sessionen har gått ut/)
// Audit V16.1: the raw response body must NOT be concatenated into the
// user-facing message — that information stays in server-side logs.
expect((e as SkatteverketAuthError).message).not.toContain('token expired')
}
})
it('maps 401 with "Token has been revoked." body → TOKEN_REVOKED and clears local row', async () => {
deleteTokensMock.mockClear()
mockFetchStatus(401, '{"error":"Token has been revoked."}')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('TOKEN_REVOKED')
expect((e as SkatteverketAuthError).message).toMatch(/återkallat/i)
expect(deleteTokensMock).toHaveBeenCalledWith(fakeSupabase, 'user-1')
}
})
it('maps 401 with WWW-Authenticate insufficient_scope → MISSING_SCOPE', async () => {
mockFetchStatus(401, '', {
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
})
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
}
})
it('maps 403 with Behörighet body → BEHORIGHET_SAKNAS', async () => {
mockFetchStatus(403, 'Behörighet saknas för aktören')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('BEHORIGHET_SAKNAS')
}
})
it('maps generic 403 → ACCESS_DENIED', async () => {
mockFetchStatus(403, 'Forbidden')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
}
})
it('maps 429 → RATE_LIMITED (new behavior)', async () => {
mockFetchStatus(429)
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('RATE_LIMITED')
// Swedish message — UI surfaces it directly.
expect((e as SkatteverketAuthError).message).toMatch(/Skatteverket/)
expect((e as SkatteverketAuthError).message).toMatch(/igen/i)
}
})
it('returns the response for 5xx (caller decides retry)', async () => {
mockFetchStatus(503, 'Service Unavailable')
const res = await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect(res.status).toBe(503)
})
it('returns the response for success', async () => {
mockFetchStatus(200, '{"ok":true}')
const res = await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ ok: true })
})
})
describe('SkatteverketAuthError', () => {
it('exposes the new TOKEN_CORRUPTED and RATE_LIMITED codes', () => {
const a = new SkatteverketAuthError('msg', 'TOKEN_CORRUPTED')
const b = new SkatteverketAuthError('msg', 'RATE_LIMITED')
expect(a.code).toBe('TOKEN_CORRUPTED')
expect(b.code).toBe('RATE_LIMITED')
})
})