The AGI kvittenser cron error-logged 'Skatteverket token refresh failed (400): access_denied / Refresh Token status is expired' every 15 minutes (19+ times on 2026-07-24 alone) and kept re-attempting the dead token against SKV forever. Only the 404 id_not_found dialect of a dead refresh session was classified as SESSION_EXPIRED; the 400 variants escaped as raw Errors, so the cron's needs_reconsent flagging never ran. Extend the classifier in refreshTokenForUser to also match the 400 'Refresh Token status is expired' body and OAuth2's standard 400 invalid_grant. With a typed SESSION_EXPIRED, the cron marks the token row needs_reconsent on the first failure and resolveReadAuth skips the connection on every later run until the user reconnects via BankID. Config-shaped 400s (invalid_client, invalid_scope) deliberately stay raw errors: a reconnect cannot fix those, and mislabeling them would re-create the self-perpetuating reconnect banner from the 2026-07 MISSING_SCOPE incident. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
357 lines
14 KiB
TypeScript
357 lines
14 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', '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')
|
|
})
|
|
})
|
|
|
|
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')
|
|
}
|
|
})
|
|
|
|
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/)
|
|
}
|
|
})
|
|
})
|