Files
accounted/extensions/general/skatteverket/__tests__/api-client.test.ts
T
5984652e47 fix(skatteverket): request the AGI kvittens scope and stop misdiagnosing the 403 (#1596)
* fix(skatteverket): request the AGI kvittens scope, not just the inlamning one

AGI is backed by two SKV APIs and each needs its own scope, but DEFAULT_SCOPES
only carried `agd`. That covers arbetsgivardeklaration/inlamning (POST underlag,
kontrollresultat, spara, skapaGranskningsunderlag), so a filing submits and
signs perfectly. The kvittens read lives on hanteraredovisningsperiod, which
requires `agdredovisningperiod`, so the flow died on its very last step with
403 {"error": "The required scopes are not authorized"}.

Confirmed against production: the same APIGW client id and secret reach the
bearer check on both APIs (401 "Token has been revoked" from each with a bogus
token), proving both subscriptions exist and the gateway contract was never the
problem, and SKV's application registration lists `agd`, `agdredovisningperiod`
and `momsdeklaration` as three separate scopes.

The body is identical to the APIGW subscription gap of #973, which is why
api-client's classification cannot distinguish the two from the string alone;
that split still needs the gateway-side check, so it is left as is.

Note the spelling `agdredovisningperiod`: no genitive s, exactly as SKV
registers it. Pinned with a scope-set regression test, since this is the third
time a scope has gone missing (#431 removed `ska` the same way) and the damage
is always invisible until a real filing fails.

The AGIPanel missing-scope banner now checks both scopes. It only looked for
`agd`, so the token shape that actually hurts, one that gets all the way
through signing before failing, produced no warning at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skatteverket): stop blaming the APIGW subscription for a missing token scope

`{"error": "The required scopes are not authorized"}` has two causes and the
gateway never says which: our APIGW client has no subscription for the API
(#973), or the token lacks the scope that API requires. The message named only
the first, so a real production filing spent its debugging time in
Utvecklarportalen while the actual fix was a scope the code never requested.

The message now names both knobs, and says WHICH service refused. That last
part is what was really missing: the sibling inlamning API kept working
throughout, so "Skatteverket denied the call" pointed at nothing. Ruling the
two apart still needs the out-of-band test (call the API with a deliberately
invalid bearer and the same Client_Id: a subscription gap fails at the gateway
with this body, a scope gap reaches the bearer check and answers 401), which is
now documented at the predicate instead of living in someone's memory.

Classification is deliberately unchanged. ACCESS_DENIED stays out of
RECONSENT_ERROR_CODES: the scope case does need a reconnect, but only after the
scope exists, so an automatic reconsent would rebuild the self-perpetuating
banner of #1155.

This reverses one specific decision from #1250, which removed the
SKATTEVERKET_SYSTEM_SCOPES mention on the reasoning that the gateway rather
than the scope list had refused. That reasoning assumed the body could tell
them apart. It cannot. The test asserting the omission is inverted, with the
reason recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(salary): make the AGI kvittens-scope notice dismissible

The two AGI scopes get different treatments in AGIPanel: a token
without agd keeps the hard reconnect nudge, but a token missing only
agdredovisningperiod gets its own softer, dismissible notice. Every
existing hosted token lacks the new scope, and until Skatteverket's
application registration carries it a reconnect mints the same grant
again (SKV silently drops unregistered scope names), so a hard
reconnect demand would be the #1010 un-clearable banner, with each
futile attempt costing a BankID round-trip. Dismissal persists per
granted scope string: a futile reconnect stays quiet, a widened grant
re-evaluates from scratch.

Also asserts the shared prenumeration/scope message and the refused
API path on the 401 contract test, matching the 403 test (review nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(salary): point the kvittens-scope notice at Skatteverket's own e-service

The compliance review flagged that dismissing the notice could leave a
filer without a software-side path to the receipt. Retrieval never
depended on the notice (the kvittens cron retries server-side and the
period's status row stays unconfirmed until a kvittens lands), but the
copy now also names the fallback that always works: verifying the
receipt in Skatteverket's Arbetsgivardeklaration e-service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(salary): use the ladder radius on the kvittens notice

The radius ladder guard landed on main after this notice was written;
rounded-md is dead vocabulary, bordered boxes are rounded-lg.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): scope the kvittens-notice dismissal to the employer

The localStorage key carried only the granted scope string, so on a
shared browser one company's dismissal hid the notice for every other
company with an identical grant. Skatteverket tokens are per company,
so the key now includes arbetsgivare alongside the scope string; the
same-grant reconnect behavior per company is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-08-14 12:01:02 +02:00

465 lines
19 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, 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, skvRequestWithAuth, SkatteverketAuthError } from '../lib/api-client'
import { __resetSystemTokenCacheForTests } from '../lib/system-auth/token-provider'
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', 'comp-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', 'comp-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', 'comp-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', 'comp-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', 'comp-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', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('BEHORIGHET_SAKNAS')
}
})
// #1155: the MuleSoft APIGW contract error wears scope wording but is our
// subscription gap (#973), not the user's token. It used to match the
// `required scope` substring test and surface as MISSING_SCOPE, which is in
// RECONSENT_ERROR_CODES: every reconnect ran runPostConnectRefresh ->
// syncSkattekonto -> 403 and instantly re-flagged the row, so the reconnect
// banner could never be cleared.
it('maps the APIGW "required scopes are not authorized" 403 → ACCESS_DENIED, not MISSING_SCOPE', async () => {
mockFetchStatus(403, '{"error": "The required scopes are not authorized"}')
try {
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
expect((e as SkatteverketAuthError).message).toMatch(/APIGW|Utvecklarportalen/)
}
})
// The same body has two causes (subscription gap #973, missing token scope
// like the AGI kvittens one), and the gateway never says which. A message
// naming only the subscription sent a real prod filing down a dead end: the
// fix was a scope, so every minute spent in Utvecklarportalen was wasted.
it('names BOTH causes and the refused service on the gateway 403', async () => {
mockFetchStatus(403, '{"error": "The required scopes are not authorized"}')
try {
// The real shape of the incident: the kvittens read on the hantera API.
await skvRequest(
fakeSupabase, 'user-1', 'comp-1', 'GET',
'/arbetsgivare/165560000000/redovisningsperioder/202608/kvittenser',
undefined,
{ baseUrl: 'https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1' },
)
expect.fail('expected throw')
} catch (e) {
const { message } = e as SkatteverketAuthError
expect(message).toMatch(/prenumeration/)
expect(message).toMatch(/scope/)
// Which service refused. Naming it is the whole point: the sibling
// inlamning API kept working, so "Skatteverket said no" is not a clue.
expect(message).toContain('arbetsgivardeklaration/hanteraredovisningsperiod/v1')
}
})
it('still maps a real token-scope rejection → MISSING_SCOPE', async () => {
// Body shape from SKV's AGI Tjänstebeskrivning v1.7 §4.1.2.2.
mockFetchStatus(
403,
'{"error":"invalid_scope","description":"The required scope agd has been requested for that access token."}',
)
try {
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
}
})
it('maps the SKV scope sentence alone (no invalid_scope code) → MISSING_SCOPE', async () => {
mockFetchStatus(403, 'The required scope agd has been requested for that access token.')
try {
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
}
})
it('treats the APIGW contract wording on a 401 as a gateway issue, even with an OAuth challenge header', async () => {
// SESSION_EXPIRED and MISSING_SCOPE are both reconsent codes, so either
// verdict would re-arm the banner. The gateway signature wins over the
// WWW-Authenticate scope marker when both are present.
mockFetchStatus(401, '{"error": "The required scopes are not authorized"}', {
'WWW-Authenticate': 'Bearer error="invalid_scope"',
})
try {
await skvRequest(
fakeSupabase, 'user-1', 'comp-1', 'GET', '/x', undefined,
{ baseUrl: 'https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1' },
)
expect.fail('expected throw')
} catch (e) {
const { code, message } = e as SkatteverketAuthError
expect(code).toBe('ACCESS_DENIED')
// Same shared message as the 403 contract path: both causes named plus
// the refused service, so the 401 shape cannot drift into vaguer text.
expect(message).toMatch(/prenumeration/)
expect(message).toMatch(/scope/)
expect(message).toContain('arbetsgivardeklaration/inlamning/v1')
}
})
it('maps generic 403 → ACCESS_DENIED', async () => {
mockFetchStatus(403, 'Forbidden')
try {
await skvRequest(fakeSupabase, 'user-1', 'comp-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', 'comp-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', 'comp-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', 'comp-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')
})
})
describe('skvRequestWithAuth: system mode', () => {
beforeEach(() => {
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'on'
process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM = 'stub'
__resetSystemTokenCacheForTests()
deleteTokensMock.mockClear()
})
afterEach(() => {
delete process.env.SKATTEVERKET_SYSTEM_AUTH_MODE
delete process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM
})
it('sends the system token and returns success responses', async () => {
mockFetchStatus(200, '{"ok":true}')
const res = await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect(res.status).toBe(200)
const call = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]
expect((call[1] as RequestInit).headers).toMatchObject({
Authorization: 'Bearer stub-system-token',
})
})
it('401 in system mode -> SYSTEM_AUTH_FAILED and NEVER touches the user token table', async () => {
mockFetchStatus(401, '{"error":"Token has been revoked."}')
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
}
// The revoked-body branch deletes the user row in user mode; system
// mode must never reach it.
expect(deleteTokensMock).not.toHaveBeenCalled()
})
it('403 in system mode -> OMBUD_GRANT_MISSING (company-level)', async () => {
mockFetchStatus(403, 'Forbidden')
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('OMBUD_GRANT_MISSING')
}
expect(deleteTokensMock).not.toHaveBeenCalled()
})
it('403 invalid_scope in system mode -> SYSTEM_AUTH_FAILED (config, not grant)', async () => {
mockFetchStatus(403, '{"error":"invalid_scope"}')
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
expect((e as SkatteverketAuthError).message).toMatch(/SKATTEVERKET_SYSTEM_SCOPES/)
}
})
it('403 APIGW contract error in system mode names both knobs', async () => {
// Still SYSTEM_AUTH_FAILED (run-level config either way). This assertion
// is the inverse of what it was: #1250 removed the SKATTEVERKET_SYSTEM_SCOPES
// mention on the reasoning that the gateway, not the scope list, had
// refused. Production later proved the body cannot distinguish the two, so
// naming one and hiding the other is a coin flip presented as a diagnosis.
mockFetchStatus(403, '{"error": "The required scopes are not authorized"}')
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
expect((e as SkatteverketAuthError).message).toMatch(/prenumeration/)
expect((e as SkatteverketAuthError).message).toMatch(/SKATTEVERKET_SYSTEM_SCOPES/)
}
})
it('unconfigured system auth -> SYSTEM_AUTH_FAILED before any fetch', async () => {
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'off'
global.fetch = vi.fn() as unknown as typeof fetch
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
}
expect(global.fetch).not.toHaveBeenCalled()
})
})
describe('refresh-token dead-session classification', () => {
// SKV's per-flow refresh tokens live 65 minutes, so crons always find a
// dead token. SKV reports that in several dialects (404 id_not_found,
// 400 "Refresh Token status is expired", 400 invalid_grant); all must
// surface as the SESSION_EXPIRED SkatteverketAuthError (which cron
// quiet-buckets and the UI reconnect flow understand), not as a raw
// Error that error-logs every run. Unique userIds per test: the
// module-level refresh coalescing map is keyed by userId.
const expiredTokens = {
access_token: 'stale',
refresh_token: 'dead-refresh',
expires_at: Date.now() - 60_000,
refresh_count: 1,
scope: 'momsdeklaration',
}
it('classifies 404 id_not_found as SESSION_EXPIRED', async () => {
const { getTokens } = await import('../lib/token-store')
const { refreshAccessToken } = await import('../lib/oauth')
// getValidToken reads once, refreshTokenForUser re-reads — queue both.
vi.mocked(getTokens)
.mockResolvedValueOnce(expiredTokens)
.mockResolvedValueOnce(expiredTokens)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
new Error(
'Skatteverket token refresh failed (404): {\n "error":"id_not_found",\n "error_description":"The refresh token is not found"\n}\n',
),
)
try {
await skvRequest(fakeSupabase, 'user-404', '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/)
}
})
it('classifies 400 "Refresh Token status is expired" as SESSION_EXPIRED', async () => {
// Exact prod payload observed 2026-07-24: the AGI kvittenser cron hit
// this every 15 minutes and error-logged it because only the 404
// dialect was classified.
const { getTokens } = await import('../lib/token-store')
const { refreshAccessToken } = await import('../lib/oauth')
vi.mocked(getTokens)
.mockResolvedValueOnce(expiredTokens)
.mockResolvedValueOnce(expiredTokens)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
new Error(
'Skatteverket token refresh failed (400): {\n "error":"access_denied",\n "error_description":"Refresh Token status is expired"\n}\n',
),
)
try {
await skvRequest(fakeSupabase, 'user-400-expired', '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/)
}
})
it('classifies 400 invalid_grant as SESSION_EXPIRED', async () => {
const { getTokens } = await import('../lib/token-store')
const { refreshAccessToken } = await import('../lib/oauth')
vi.mocked(getTokens)
.mockResolvedValueOnce(expiredTokens)
.mockResolvedValueOnce(expiredTokens)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
new Error('Skatteverket token refresh failed (400): {"error": "invalid_grant"}'),
)
try {
await skvRequest(fakeSupabase, 'user-400-grant', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
}
})
it('leaves config-shaped 400s (invalid_client) as raw errors', async () => {
// invalid_client means OUR client credentials are broken; telling the
// user to reconnect cannot fix it and would re-create the
// self-perpetuating reconnect banner (2026-07 MISSING_SCOPE incident).
const { getTokens } = await import('../lib/token-store')
const { refreshAccessToken } = await import('../lib/oauth')
vi.mocked(getTokens)
.mockResolvedValueOnce(expiredTokens)
.mockResolvedValueOnce(expiredTokens)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
new Error('Skatteverket token refresh failed (400): {"error":"invalid_client"}'),
)
try {
await skvRequest(fakeSupabase, 'user-400-client', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).not.toBeInstanceOf(SkatteverketAuthError)
expect((e as Error).message).toMatch(/invalid_client/)
}
})
it('re-throws other refresh failures untouched', async () => {
const { getTokens } = await import('../lib/token-store')
const { refreshAccessToken } = await import('../lib/oauth')
vi.mocked(getTokens)
.mockResolvedValueOnce(expiredTokens)
.mockResolvedValueOnce(expiredTokens)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
new Error('Skatteverket token refresh failed (500): upstream unavailable'),
)
try {
await skvRequest(fakeSupabase, 'user-500', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).not.toBeInstanceOf(SkatteverketAuthError)
expect((e as Error).message).toMatch(/500/)
}
})
})