Files
accounted/lib/currency/__tests__/riksbanken.test.ts
T
Jakob WennbergandClaude Fable 5 aec81cb7ad fix(db): lock down exchange_rates writes, drop duplicate JEL index, receipts anon read (#969)
Three Supabase-advisor findings from the 2026-07-09 production log triage:

1. exchange_rates (rls_policy_always_true): the exchange_rates_insert
   policy was WITH CHECK (true) for authenticated, letting any signed-in
   user poison the shared FX cache that feeds money math (amount_sek on
   ingested transactions, invoice SEK conversion). Migration
   20260710100000 drops the policy and revokes INSERT from
   anon/authenticated; only the service role writes the cache now (the
   05:00 enable-banking sync cron and the v1 API-key paths both use the
   service client). writeCachedRate() in lib/currency/riksbanken.ts was
   already fail-soft and never inspects the upsert result, so
   user-client paths (bank file import, refresh-exchange-rate) keep
   returning the fetched rate unchanged when the cache write is
   rejected; documented and covered by a new unit test.

2. journal_entry_lines (duplicate_index): idx_journal_entry_lines_entry
   and idx_journal_entry_lines_entry_id are byte-identical btree indexes
   on (journal_entry_id), verified via pg_indexes on prod. Migration
   20260710101000 drops idx_journal_entry_lines_entry (created outside
   the migration history); the repo-defined _entry_id stays.

3. receipts bucket (public_bucket_allows_listing): receipts_public_read
   gave anon SELECT over every object in the bucket, enabling anonymous
   listing. The bucket is unused: no code references it, public.receipts
   has 0 rows in prod, 2 orphan objects from 2026-02-26. Migration
   20260710102000 drops the anon policy; authenticated own-folder
   policies stay untouched.

New tests/pg/db-advisor-lockdowns.pg.test.ts covers all three
(authenticated INSERT rejected, SELECT still works, privilege revoked,
duplicate index gone, anon cannot list receipts).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:05:40 +02:00

434 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
fetchExchangeRate,
fetchMultipleRates,
fetchRateRange,
fetchLatestRate,
convertToSEK,
formatCurrencyAmount,
} from '../riksbanken'
// Mock logger to suppress output
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}))
describe('fetchExchangeRate', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns rate 1 for SEK without fetching', async () => {
const fetchSpy = vi.spyOn(global, 'fetch')
const result = await fetchExchangeRate('SEK')
expect(result).toEqual({
currency: 'SEK',
rate: 1,
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
})
expect(fetchSpy).not.toHaveBeenCalled()
})
it('parses EUR rate from API response', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
)
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'))
expect(result).toEqual({
currency: 'EUR',
rate: 11.42,
date: '2025-01-15',
})
})
it('returns null on fetch error — never a hardcoded rate on the booking path', async () => {
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
const result = await fetchExchangeRate('EUR')
expect(result).toBeNull()
})
it('tries fallback URL when primary returns 404 (no observation for the date)', async () => {
vi.spyOn(global, 'fetch')
.mockResolvedValueOnce(new Response('Not Found', { status: 404 }))
.mockResolvedValueOnce(
new Response(JSON.stringify([
{ value: '10.80', date: '2025-01-13' },
{ value: '10.85', date: '2025-01-14' },
]), { status: 200 })
)
const result = await fetchExchangeRate('USD', new Date('2025-01-15'))
expect(result).toEqual({
currency: 'USD',
rate: 10.85,
date: '2025-01-14',
})
})
it('retries once on 429 and does NOT fire the range-fallback request', async () => {
vi.useFakeTimers()
try {
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockResolvedValueOnce(
new Response('Too Many Requests', { status: 429, headers: { 'retry-after': '1' } })
)
.mockResolvedValueOnce(new Response('Too Many Requests', { status: 429 }))
const promise = fetchExchangeRate('EUR', new Date('2025-01-15'))
await vi.runAllTimersAsync()
const result = await promise
// Two calls to the SAME single-day URL (initial + retry); the 7-day
// range endpoint is never hit — the old code fired it on 429 and
// doubled the load on an already rate-limited API.
expect(fetchSpy).toHaveBeenCalledTimes(2)
const urls = fetchSpy.mock.calls.map((c) => String(c[0]))
expect(urls[0]).toBe(urls[1])
expect(urls[0]).toContain('/2025-01-15/2025-01-15')
expect(result).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('recovers when the 429 retry succeeds', async () => {
vi.useFakeTimers()
try {
vi.spyOn(global, 'fetch')
.mockResolvedValueOnce(new Response('Too Many Requests', { status: 429 }))
.mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
)
const promise = fetchExchangeRate('EUR', new Date('2025-01-15'))
await vi.runAllTimersAsync()
const result = await promise
expect(result).toEqual({ currency: 'EUR', rate: 11.42, date: '2025-01-15' })
} finally {
vi.useRealTimers()
}
})
describe('with the persistent exchange_rates cache (supabase passed)', () => {
type CacheRow = { rate: number; observation_date: string }
function makeSupabase(opts: {
exactHit?: CacheRow | null
latestHit?: CacheRow | null
onUpsert?: (row: Record<string, unknown>) => void
upsertError?: { code: string; message: string }
}) {
// .maybeSingle() terminates both the exact lookup and the latest
// lookup. Shared across from() calls so the once-queue holds: the
// first maybeSingle in a test is the exact lookup, subsequent ones
// are the latest-cached lookup.
const maybeSingle = vi
.fn()
.mockResolvedValueOnce({ data: opts.exactHit ?? null, error: null })
.mockResolvedValue({ data: opts.latestHit ?? null, error: null })
return {
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle,
upsert: vi.fn((row: Record<string, unknown>) => {
opts.onUpsert?.(row)
return Promise.resolve({ data: null, error: opts.upsertError ?? null })
}),
})),
} as never
}
it('serves a cache hit without touching Riksbanken', async () => {
const fetchSpy = vi.spyOn(global, 'fetch')
const supabase = makeSupabase({ exactHit: { rate: 11.11, observation_date: '2025-01-15' } })
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
expect(result).toEqual({ currency: 'EUR', rate: 11.11, date: '2025-01-15' })
expect(fetchSpy).not.toHaveBeenCalled()
})
it('writes a fetched rate into the cache', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
)
let upserted: Record<string, unknown> | undefined
const supabase = makeSupabase({ onUpsert: (row) => (upserted = row) })
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
expect(result).toEqual({ currency: 'EUR', rate: 11.42, date: '2025-01-15' })
expect(upserted).toMatchObject({
currency: 'EUR',
rate_date: '2025-01-15',
rate: 11.42,
observation_date: '2025-01-15',
source: 'riksbanken',
})
})
it('still returns the fetched rate when the cache write is rejected by RLS', async () => {
// Since migration 20260710100000, INSERT on exchange_rates is
// service-role only. supabase-js reports the RLS rejection as a
// resolved { error }, not a throw: the rate must come back anyway.
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
)
const supabase = makeSupabase({
upsertError: { code: '42501', message: 'permission denied for table exchange_rates' },
})
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
expect(result).toEqual({ currency: 'EUR', rate: 11.42, date: '2025-01-15' })
})
it('falls back to the most recent cached observation when Riksbanken is down', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
const supabase = makeSupabase({
exactHit: null,
latestHit: { rate: 11.38, observation_date: '2025-01-10' },
})
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
// An honest, dated observation — not a hardcoded 11.5.
expect(result).toEqual({ currency: 'EUR', rate: 11.38, date: '2025-01-10' })
})
it('returns null when Riksbanken is down and the cache is empty', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
const supabase = makeSupabase({ exactHit: null, latestHit: null })
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
expect(result).toBeNull()
})
})
})
describe('fetchMultipleRates', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns Map with all requested currencies', async () => {
vi.spyOn(global, 'fetch')
.mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
)
.mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '10.50', date: '2025-01-15' }]), { status: 200 })
)
const result = await fetchMultipleRates(['EUR', 'USD'])
expect(result.size).toBe(3) // EUR, USD, + always SEK
expect(result.get('SEK')!.rate).toBe(1)
expect(result.get('EUR')!.rate).toBe(11.42)
expect(result.get('USD')!.rate).toBe(10.50)
})
it('handles partial failure: returns fallback for failed currencies', async () => {
vi.spyOn(global, 'fetch')
.mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
)
.mockRejectedValueOnce(new Error('Network error'))
const result = await fetchMultipleRates(['EUR', 'GBP'])
expect(result.size).toBe(3)
expect(result.get('EUR')!.rate).toBe(11.42)
// GBP gets fallback rate (from the catch in fetchExchangeRate)
expect(result.get('GBP')).toBeDefined()
expect(result.get('GBP')!.rate).toBeGreaterThan(0)
})
it('returns only SEK when given empty array', async () => {
const result = await fetchMultipleRates([])
expect(result.size).toBe(1)
expect(result.get('SEK')!.rate).toBe(1)
})
it('handles SEK in the input array without duplicate fetch', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
)
const result = await fetchMultipleRates(['SEK', 'EUR'])
expect(result.size).toBe(2)
expect(result.get('SEK')!.rate).toBe(1)
expect(result.get('EUR')!.rate).toBe(11.42)
})
})
describe('fetchRateRange', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns sorted array of rates', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify([
{ value: '11.40', date: '2025-01-13' },
{ value: '11.45', date: '2025-01-15' },
{ value: '11.42', date: '2025-01-14' },
]), { status: 200 })
)
const result = await fetchRateRange(
'EUR',
new Date('2025-01-13'),
new Date('2025-01-15')
)
expect(result).toHaveLength(3)
expect(result[0].date).toBe('2025-01-13')
expect(result[1].date).toBe('2025-01-14')
expect(result[2].date).toBe('2025-01-15')
})
it('returns [rate:1] for SEK', async () => {
const fetchSpy = vi.spyOn(global, 'fetch')
const result = await fetchRateRange(
'SEK',
new Date('2025-01-13'),
new Date('2025-01-15')
)
expect(result).toHaveLength(1)
expect(result[0].rate).toBe(1)
expect(fetchSpy).not.toHaveBeenCalled()
})
it('returns empty array on error', async () => {
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
const result = await fetchRateRange(
'EUR',
new Date('2025-01-13'),
new Date('2025-01-15')
)
expect(result).toEqual([])
})
it('returns empty array on non-200 response', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response('Not Found', { status: 404 })
)
const result = await fetchRateRange(
'EUR',
new Date('2025-01-13'),
new Date('2025-01-15')
)
expect(result).toEqual([])
})
})
describe('fetchLatestRate', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns the last item from API response', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify([
{ value: '11.40', date: '2025-01-13' },
{ value: '11.42', date: '2025-01-14' },
{ value: '11.45', date: '2025-01-15' },
]), { status: 200 })
)
const result = await fetchLatestRate('EUR')
expect(result).toEqual({
currency: 'EUR',
rate: 11.45,
date: '2025-01-15',
})
})
it('returns rate 1 for SEK', async () => {
const fetchSpy = vi.spyOn(global, 'fetch')
const result = await fetchLatestRate('SEK')
expect(result).toEqual({
currency: 'SEK',
rate: 1,
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
})
expect(fetchSpy).not.toHaveBeenCalled()
})
it('returns fallback on error', async () => {
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
const result = await fetchLatestRate('EUR')
expect(result).not.toBeNull()
expect(result!.currency).toBe('EUR')
expect(result!.rate).toBeGreaterThan(0)
})
it('returns null on empty API response', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify([]), { status: 200 })
)
const result = await fetchLatestRate('EUR')
expect(result).toBeNull()
})
})
describe('convertToSEK', () => {
it('converts amount correctly', () => {
expect(convertToSEK(100, 11.42)).toBe(1142)
})
it('handles zero amount', () => {
expect(convertToSEK(0, 11.42)).toBe(0)
})
})
describe('formatCurrencyAmount', () => {
it('formats EUR with symbol prefix', () => {
const result = formatCurrencyAmount(1234.56, 'EUR')
// sv-SE uses non-breaking space as thousands separator
expect(result).toContain('€')
expect(result).toContain('1')
expect(result).toContain('234')
})
it('formats SEK with currency suffix', () => {
const result = formatCurrencyAmount(1234.56, 'SEK')
expect(result).toContain('SEK')
})
it('formats NOK with currency suffix', () => {
const result = formatCurrencyAmount(100, 'NOK')
expect(result).toContain('NOK')
})
})