Files
accounted/extensions/general/skatteverket/__tests__/token-store.test.ts
T
18c20e68e6 fix(scoping): Skatteverket per företag + nåbara startkort + företags-scopade val (#1610)
* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals

Skatteverket connections become per (user, company): the token table
carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half
migrations), so one connection leaked "connected" onto every company the
user belongs to, sync ran the token against the wrong orgnr (behorighet
403), and reconnecting from another company silently moved the row and
went dark on the first company's crons. Token reads/writes are now scoped
by company through the whole chain (token-store, api-client refresh
coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP),
/skattekonto/saldo answers 401 NOT_CONNECTED for companies without their
own row (which is what the page's startkort keys on), and the dashboard
connect-nudge counts only the active company's row.

Bookkeeping's pristine start card now keys on all-years emptiness via a
count probe instead of "no active filters": the default fiscal-year
selection counted as a filter, which made the card unreachable on
brand-new companies (it showed "inga traffar" instead).

Two browser-global localStorage keys become company-scoped with legacy
fallbacks: the inbox onboarding dismissal (dismissing on one company hid
the card everywhere) and the periodisering auto-detect toggle.

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

* fix(scoping): dedupe cron work per (user, company) + guard the ledger probe

CodeRabbit findings on #1610: the skattekonto sync cron still deduped
token rows by user_id alone, which would drop every company but one for
multi-company operators (the exact scenario the PR fixes); and the
all-years ledger probe could leave a stale false behind on a failed
refetch, letting the pristine card render unconfirmed. The probe now
resets to unknown in flight and carries the fetch generation guard.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:21:57 +02:00

82 lines
3.0 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
// Stub the supabase service-role client so token-store doesn't need real env.
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(() => ({
from: vi.fn(),
})),
}))
import { getTokens } from '../lib/token-store'
const fakeSupabase = {} as never
beforeEach(() => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
process.env.SKATTEVERKET_TOKEN_ENCRYPTION_KEY = 'test-encryption-key'
vi.restoreAllMocks()
})
async function mockSelectReturning(row: unknown) {
const { createClient } = await import('@supabase/supabase-js')
// getTokens chains .eq('user_id', …).eq('company_id', …).maybeSingle():
// a self-referencing eq keeps the chain length out of the mock's contract.
const eqChain: { eq: ReturnType<typeof vi.fn>; maybeSingle: ReturnType<typeof vi.fn> } = {
eq: vi.fn(() => eqChain),
maybeSingle: vi.fn(async () => row),
}
;(createClient as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue({
from: vi.fn(() => ({
select: vi.fn(() => eqChain),
})),
})
}
describe('getTokens', () => {
it('returns null when no row exists (NOT_CONNECTED state)', async () => {
await mockSelectReturning({ data: null, error: { message: 'no rows' } })
// Module-level _serviceClient cache means we need a fresh import after
// mock changes. Use vitest's resetModules to force re-import.
vi.resetModules()
const { getTokens: fresh } = await import('../lib/token-store')
const result = await fresh(fakeSupabase, 'user-1', 'comp-1')
expect(result).toBeNull()
})
it('throws TOKEN_CORRUPTED when stored ciphertext cannot be decrypted', async () => {
// Arrange: the row exists but its access_token is not valid AES-256-GCM
// ciphertext (e.g. encryption key was rotated). With vi.resetModules()
// the SkatteverketAuthError class identity diverges across re-imports,
// so we assert by .name + .code instead of instanceof.
await mockSelectReturning({
data: {
access_token: 'this-is-not-valid-base64url-ciphertext',
refresh_token: null,
expires_at: new Date(Date.now() + 60_000).toISOString(),
refresh_count: 0,
scope: 'momsdeklaration',
},
error: null,
})
vi.resetModules()
const { getTokens: fresh } = await import('../lib/token-store')
try {
await fresh(fakeSupabase, 'user-1', 'comp-1')
expect.fail('expected throw')
} catch (e) {
const err = e as { name: string; code: string; message: string }
expect(err.name).toBe('SkatteverketAuthError')
expect(err.code).toBe('TOKEN_CORRUPTED')
expect(err.message).toMatch(/BankID/)
}
})
})
// Export-ensure: getTokens itself is the only exported symbol we need to
// verify the new behavior. The full storeTokens flow (DELETE+INSERT, SELECT
// failure handling per commit 8865f61) is exercised by integration tests
// against pg-real and not duplicated here.
void getTokens