chore(connect): provider-host ratchet in check:guards, drop the dead Arcim gateway client (#2178)
* chore(connect): provider-host ratchet in check:guards, drop the dead Arcim gateway client Two boundary chores from the Connect plan. (1) A per-file ratchet in scripts/checks/no-new-antipatterns.mjs over files under lib/, app/ and extensions/ that name a provider API host (Enable Banking, Skatteverket, Qvalia, Fortnox, Visma, Briox, Bjorn Lunden, Bokio, Bolagsverket, TIC, Meta, Gmail). The 22 files that do so today are grandfathered in the baseline; a new one fails the guard with the connector routing as the remedy, and the set may only shrink as upstreams move behind the connector. (2) The client for the retired Arcim Sync gateway (extensions/general/arcim-migration/lib/ arcim-client.ts) is deleted with its test: provider-client.ts replaced it and nothing else imported it. The --update rewrite also locks in the lower naive-ore-round count (620 to 617) that main already reached. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * chore(guards): provider-host ratchet is case-insensitive and skips colocated .test.tsx; document the own-credentials exception Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --------- Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
c0ecf2fa3b
commit
8c8996773f
@@ -1,357 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.stubEnv('ARCIM_SYNC_GATEWAY_URL', 'https://arcim.test.com')
|
||||
vi.stubEnv('ARCIM_SYNC_API_KEY', 'test-api-key')
|
||||
|
||||
import { getConsent, createConsent, fetchCompanyInfo, fetchCustomers } from '../arcim-client'
|
||||
|
||||
describe('arcim-client', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auth & headers
|
||||
// -------------------------------------------------------------------------
|
||||
describe('request headers', () => {
|
||||
it('sends Authorization and Content-Type headers', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: 'c1', status: 'active' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
await getConsent('c1')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
const [, opts] = fetchSpy.mock.calls[0]
|
||||
expect(opts?.headers).toMatchObject({
|
||||
Authorization: 'Bearer test-api-key',
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Retry on retryable HTTP status
|
||||
// -------------------------------------------------------------------------
|
||||
describe('retry', () => {
|
||||
it('retries on 503 and succeeds', async () => {
|
||||
const fail = new Response('Service Unavailable', { status: 503 })
|
||||
const success = new Response(
|
||||
JSON.stringify({ id: 'c1', status: 'active' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(fail)
|
||||
.mockResolvedValueOnce(success)
|
||||
|
||||
const result = await getConsent('c1')
|
||||
expect(result).toEqual({ id: 'c1', status: 'active' })
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('returned 503, retrying')
|
||||
)
|
||||
})
|
||||
|
||||
it('retries on 429 (rate limit) and succeeds', async () => {
|
||||
const rateLimit = new Response('Too Many Requests', { status: 429 })
|
||||
const success = new Response(
|
||||
JSON.stringify({ id: 'c1', status: 'active' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(rateLimit)
|
||||
.mockResolvedValueOnce(success)
|
||||
|
||||
const result = await getConsent('c1')
|
||||
expect(result).toEqual({ id: 'c1', status: 'active' })
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries on 502 and 504 as well', async () => {
|
||||
const bad502 = new Response('Bad Gateway', { status: 502 })
|
||||
const bad504 = new Response('Gateway Timeout', { status: 504 })
|
||||
const success = new Response(
|
||||
JSON.stringify({ id: 'c1', status: 'active' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(bad502)
|
||||
.mockResolvedValueOnce(bad504)
|
||||
.mockResolvedValueOnce(success)
|
||||
|
||||
const result = await getConsent('c1')
|
||||
expect(result).toEqual({ id: 'c1', status: 'active' })
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('throws after exhausting all retries on retryable status', async () => {
|
||||
const fail = new Response('Service Unavailable', { status: 503 })
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(fail.clone())
|
||||
.mockResolvedValueOnce(fail.clone())
|
||||
.mockResolvedValueOnce(fail.clone())
|
||||
|
||||
await expect(getConsent('c1')).rejects.toThrow('Arcim API 503')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3) // 1 original + 2 retries
|
||||
})
|
||||
|
||||
it('does not retry on 400 errors', async () => {
|
||||
const badRequest = new Response('Bad Request', { status: 400 })
|
||||
fetchSpy.mockResolvedValueOnce(badRequest)
|
||||
|
||||
await expect(getConsent('c1')).rejects.toThrow('Arcim API 400')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not retry on 404 errors', async () => {
|
||||
const notFound = new Response('Not Found', { status: 404 })
|
||||
fetchSpy.mockResolvedValueOnce(notFound)
|
||||
|
||||
await expect(getConsent('c1')).rejects.toThrow('Arcim API 404')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Retry on timeout (AbortError)
|
||||
// -------------------------------------------------------------------------
|
||||
describe('timeout retry', () => {
|
||||
it('retries on AbortError and succeeds', async () => {
|
||||
const abortError = new DOMException('Aborted', 'AbortError')
|
||||
const success = new Response(
|
||||
JSON.stringify({ id: 'c1', status: 'active' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
fetchSpy
|
||||
.mockRejectedValueOnce(abortError)
|
||||
.mockResolvedValueOnce(success)
|
||||
|
||||
const result = await getConsent('c1')
|
||||
expect(result).toEqual({ id: 'c1', status: 'active' })
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('timed out, retrying')
|
||||
)
|
||||
})
|
||||
|
||||
it('throws timeout error after exhausting retries on AbortError', async () => {
|
||||
const abortError = new DOMException('Aborted', 'AbortError')
|
||||
|
||||
fetchSpy
|
||||
.mockRejectedValueOnce(abortError)
|
||||
.mockRejectedValueOnce(abortError)
|
||||
.mockRejectedValueOnce(abortError)
|
||||
|
||||
await expect(getConsent('c1')).rejects.toThrow('Arcim API timeout')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not retry on non-abort network errors', async () => {
|
||||
const networkError = new TypeError('fetch failed')
|
||||
fetchSpy.mockRejectedValueOnce(networkError)
|
||||
|
||||
await expect(getConsent('c1')).rejects.toThrow('fetch failed')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Exponential backoff
|
||||
// -------------------------------------------------------------------------
|
||||
describe('backoff', () => {
|
||||
it('increases delay on successive retries', async () => {
|
||||
const delays: number[] = []
|
||||
const realSetTimeout = globalThis.setTimeout
|
||||
// Only intercept retry delays (1000-10000ms range), pass abort timers through
|
||||
vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn, ms) => {
|
||||
if (ms && ms >= 1000 && ms < 120_000) {
|
||||
delays.push(ms as number)
|
||||
// Execute retry delay callback immediately
|
||||
if (typeof fn === 'function') fn()
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>
|
||||
}
|
||||
// Let abort controller timers run through real setTimeout
|
||||
return realSetTimeout(fn, ms)
|
||||
})
|
||||
|
||||
const fail = new Response('Service Unavailable', { status: 503 })
|
||||
const success = new Response(
|
||||
JSON.stringify({ id: 'c1', status: 'active' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(fail.clone())
|
||||
.mockResolvedValueOnce(fail.clone())
|
||||
.mockResolvedValueOnce(success)
|
||||
|
||||
await getConsent('c1')
|
||||
|
||||
// attempt 0 → delay = 1000 * (0+1) = 1000
|
||||
// attempt 1 → delay = 1000 * (1+1) = 2000
|
||||
expect(delays).toEqual([1000, 2000])
|
||||
|
||||
vi.restoreAllMocks()
|
||||
// Re-set our spies since restoreAllMocks clears them
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST body
|
||||
// -------------------------------------------------------------------------
|
||||
describe('request body', () => {
|
||||
it('sends JSON body for POST requests', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ id: 'c1', status: 'pending', provider: 'fortnox' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
|
||||
await createConsent('fortnox', 'Test', '5591234567', 'Test AB')
|
||||
|
||||
const [url, opts] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('https://arcim.test.com/api/v1/consents')
|
||||
expect(opts?.method).toBe('POST')
|
||||
expect(JSON.parse(opts?.body as string)).toEqual({
|
||||
name: 'Test',
|
||||
provider: 'fortnox',
|
||||
orgNumber: '5591234567',
|
||||
companyName: 'Test AB',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pagination (fetchAllPages)
|
||||
// -------------------------------------------------------------------------
|
||||
describe('pagination', () => {
|
||||
it('fetches all pages until hasMore is false', async () => {
|
||||
const page1 = { data: [{ id: 'c1' }, { id: 'c2' }], hasMore: true }
|
||||
const page2 = { data: [{ id: 'c3' }], hasMore: false }
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(page1), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(page2), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchCustomers('consent-1')
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((c) => c.id)).toEqual(['c1', 'c2', 'c3'])
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops when page returns empty data', async () => {
|
||||
const page1 = { data: [{ id: 'c1' }], hasMore: true }
|
||||
const page2 = { data: [], hasMore: true }
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(page1), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(page2), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchCustomers('consent-1')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Singleton resource (fetchCompanyInfo)
|
||||
// -------------------------------------------------------------------------
|
||||
describe('singleton resource', () => {
|
||||
it('unwraps { data } envelope for company info', async () => {
|
||||
const company = { orgNumber: '5591234567', name: 'Test AB' }
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: company }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchCompanyInfo('consent-1')
|
||||
expect(result).toEqual(company)
|
||||
})
|
||||
|
||||
it('returns null when data is undefined', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchCompanyInfo('consent-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Missing env vars
|
||||
// -------------------------------------------------------------------------
|
||||
describe('environment validation', () => {
|
||||
it('throws when ARCIM_SYNC_GATEWAY_URL is missing', async () => {
|
||||
const orig = process.env.ARCIM_SYNC_GATEWAY_URL
|
||||
delete process.env.ARCIM_SYNC_GATEWAY_URL
|
||||
|
||||
try {
|
||||
await expect(getConsent('c1')).rejects.toThrow(
|
||||
'ARCIM_SYNC_GATEWAY_URL is not configured'
|
||||
)
|
||||
} finally {
|
||||
process.env.ARCIM_SYNC_GATEWAY_URL = orig
|
||||
}
|
||||
})
|
||||
|
||||
it('throws when ARCIM_SYNC_API_KEY is missing', async () => {
|
||||
const orig = process.env.ARCIM_SYNC_API_KEY
|
||||
delete process.env.ARCIM_SYNC_API_KEY
|
||||
|
||||
try {
|
||||
await expect(getConsent('c1')).rejects.toThrow(
|
||||
'ARCIM_SYNC_API_KEY is not configured'
|
||||
)
|
||||
} finally {
|
||||
process.env.ARCIM_SYNC_API_KEY = orig
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* HTTP client for the Arcim Sync gateway API.
|
||||
*
|
||||
* Targets the consent-based resource API (/api/v1/consents/...) which
|
||||
* provides typed, normalized access to any Swedish accounting provider.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ArcimProvider,
|
||||
ConsentRecord,
|
||||
PaginatedResponse,
|
||||
CompanyInformationDto,
|
||||
CustomerDto,
|
||||
} from '../types'
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const url = process.env.ARCIM_SYNC_GATEWAY_URL
|
||||
if (!url) throw new Error('ARCIM_SYNC_GATEWAY_URL is not configured')
|
||||
return url.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
const key = process.env.ARCIM_SYNC_API_KEY
|
||||
if (!key) throw new Error('ARCIM_SYNC_API_KEY is not configured')
|
||||
return key
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 2
|
||||
const RETRY_DELAY_MS = 1_000
|
||||
const RETRYABLE_STATUSES = [429, 502, 503, 504]
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
timeoutMs: number = 120_000
|
||||
): Promise<T> {
|
||||
const url = `${getBaseUrl()}${path}`
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${getApiKey()}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const isAbort = err instanceof DOMException || (err instanceof Error && err.name === 'AbortError')
|
||||
if (attempt < MAX_RETRIES && isAbort) {
|
||||
console.warn(`[arcim] ${path} timed out, retrying (attempt ${attempt + 1})`)
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
if (isAbort) {
|
||||
throw new Error(`Arcim API timeout after ${Math.round(timeoutMs / 1000)}s: ${path}`)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRIES && RETRYABLE_STATUSES.includes(response.status)) {
|
||||
console.warn(`[arcim] ${path} returned ${response.status}, retrying (attempt ${attempt + 1})`)
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new Error(`Arcim API ${response.status}: ${body || response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
throw new Error(`Arcim API failed after ${MAX_RETRIES + 1} attempts: ${path}`)
|
||||
}
|
||||
|
||||
// ── Consent lifecycle ───────────────────────────────────────────────
|
||||
|
||||
export async function createConsent(
|
||||
provider: ArcimProvider,
|
||||
name: string,
|
||||
orgNumber?: string,
|
||||
companyName?: string
|
||||
): Promise<ConsentRecord> {
|
||||
return request<ConsentRecord>('/api/v1/consents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, provider, orgNumber, companyName }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getConsent(consentId: string): Promise<ConsentRecord> {
|
||||
return request<ConsentRecord>(`/api/v1/consents/${consentId}`)
|
||||
}
|
||||
|
||||
// ── Resource fetching (paginated) ───────────────────────────────────
|
||||
|
||||
async function fetchAllPages<T>(
|
||||
consentId: string,
|
||||
resource: string,
|
||||
params?: Record<string, string>,
|
||||
pageSize: number = 100,
|
||||
maxPages: number = 500
|
||||
): Promise<T[]> {
|
||||
const all: T[] = []
|
||||
let page = 1
|
||||
|
||||
while (page <= maxPages) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
...params,
|
||||
})
|
||||
const result = await request<PaginatedResponse<T>>(
|
||||
`/api/v1/consents/${consentId}/${resource}?${query}`
|
||||
)
|
||||
all.push(...result.data)
|
||||
|
||||
if (!result.hasMore || result.data.length === 0) break
|
||||
page++
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
// ── Typed resource accessors ────────────────────────────────────────
|
||||
|
||||
export async function fetchCompanyInfo(
|
||||
consentId: string
|
||||
): Promise<CompanyInformationDto | null> {
|
||||
// CompanyInformation is a singleton resource: gateway returns { data: object }
|
||||
const result = await request<{ data: CompanyInformationDto }>(
|
||||
`/api/v1/consents/${consentId}/companyinformation`
|
||||
)
|
||||
return result.data ?? null
|
||||
}
|
||||
|
||||
export async function fetchCustomers(consentId: string): Promise<CustomerDto[]> {
|
||||
return fetchAllPages<CustomerDto>(consentId, 'customers')
|
||||
}
|
||||
|
||||
// The gateway SIE export path (fetchSIEExport/SIEExportFile) was deliberately
|
||||
// removed: it returned SIE as a pre-decoded string, and the gateway's decode of
|
||||
// CP437 bytes as windows-1252 caused the 2026-03-17 mojibake incident. Provider
|
||||
// SIE now travels as raw bytes through lib/sie-fetcher.ts and the repo's own
|
||||
// encoding detection. Do not re-add a string-typed SIE fetch here.
|
||||
Reference in New Issue
Block a user