Files
accounted/lib/providers/wint/__tests__/oauth.test.ts
T
MattssonandClaude Fable 5 93f81f03e8 feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED (#1446)
* feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED

Adds WINT (wint.se) as a sixth migration provider, built against the
OpenAPI specs WINT's own API host serves publicly. Tier A scope: only the
partner-facing v1 endpoints are used; the general ledger is fetched as
vouchers/accounts and rendered as SIE 4E by our own sie-builder, with
opening balances for earlier years derived backward from the current-year
Ib anchor. Auth is the user's WINT login exchanged once for a JWT pair;
the password is never stored.

Ships dark: the wizard shows a disabled "Kommer snart" card, and the
server-side /connect gate rejects WINT until WINT_MIGRATION_ENABLED=true.
Live verification against a real WINT account is still outstanding.

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

* fix(providers): harden WINT provider per PR #1446 review findings

Addresses CodeRabbit and Swedish accounting review feedback in one pass:

- Ib anchor selection now uses WINT's unfiltered fiscal-year list, so an
  active year outside the allowed import window can never silently anchor
  the wrong year; the voucher chain is extended through the anchor and a
  per-year fetch failure fails that year loudly instead of sinking the
  whole migration.
- Auth token exchange is strict: only LoginState Success with a complete
  access+refresh pair mints a consent (a pair without a refresh token is
  unrefreshable and would break days later).
- WintApiError no longer retains full response bodies (bounded 300-char
  diagnostic; bodies can carry customer data and errors get logged).
- sie-builder refuses to render structurally invalid vouchers (missing
  account number or booking date) and documents deleted-voucher gaps in a
  #PROSA record per BFL 5 kap 6-7 §.
- Account classification: 20xx is equity, 83xx is financial income.
- SIE validator accepts EUBAS97 as BAS-based (standard kontoplanstyp; it
  previously produced a false non-BAS warning on every WINT/Bollbok file).
- New tests: resolveConsent WINT refresh flow, credential upsert payload
  (no mail/password persisted), WINT fetch failure path, EUBAS97 warning
  regression, builder invalid-data rejection, vi.clearAllMocks hygiene.

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

* fix(import): pin EUBAS97 acceptance to the exact SIE spec value

Review follow-up on PR #1446: match EUBAS97 exactly instead of any
EUBAS* prefix, so the non-BAS kontoplan warning stays pinned to the four
kontoplanstyp values the SIE 4B spec enumerates (BAS95, BAS96, EUBAS97,
NE2007) rather than silently accepting unknown future variants.

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

---------

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

138 lines
5.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { loginWint, refreshWintToken, jwtExpiresInSeconds, WintLoginRejectedError } from '../oauth';
import { WintApiError } from '../client';
function makeJwt(payload: Record<string, unknown>): string {
const b64 = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');
return `${b64({ alg: 'HS256' })}.${b64(payload)}.signature`;
}
function authResponse(overrides: Record<string, unknown> = {}): Response {
return new Response(
JSON.stringify({
State: 'Success',
AuthTokens: { AccessToken: makeJwt({ exp: Math.floor(Date.now() / 1000) + 900 }), RefreshToken: 'refresh-1' },
...overrides,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
describe('WINT auth', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('loginWint', () => {
it('posts Mail/Password to /api/Auth/jwt and returns the token pair', async () => {
fetchSpy.mockResolvedValueOnce(authResponse());
const tokens = await loginWint('user@example.se', 'hemligt');
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toContain('/api/Auth/jwt');
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
Mail: 'user@example.se',
Password: 'hemligt',
});
expect(tokens.refresh_token).toBe('refresh-1');
expect(tokens.token_type).toBe('Bearer');
expect(tokens.expires_in).toBeGreaterThan(800);
});
it('throws WintLoginRejectedError on a definitive LoginState string', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ State: 'WrongUsernameOrPassword', AuthTokens: null }));
const err = await loginWint('user@example.se', 'fel').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintLoginRejectedError);
expect((err as WintLoginRejectedError).state).toBe('WrongUsernameOrPassword');
});
it('normalizes ordinal LoginState values (7 -> ForceLoginWithBankId)', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ State: 7, AuthTokens: null }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintLoginRejectedError);
expect((err as WintLoginRejectedError).state).toBe('ForceLoginWithBankId');
});
it('carries the HTTP status on auth-endpoint errors', async () => {
fetchSpy.mockResolvedValueOnce(new Response('bad request', { status: 400 }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintApiError);
expect((err as WintApiError).statusCode).toBe(400);
});
it('fails cleanly when Success carries no access token', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ AuthTokens: { AccessToken: null, RefreshToken: null } }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintApiError);
expect((err as WintApiError).message).toContain('incomplete token pair');
});
it('rejects a Success response missing the refresh token (unrefreshable consent)', async () => {
const jwt = makeJwt({ exp: Math.floor(Date.now() / 1000) + 900 });
fetchSpy.mockResolvedValueOnce(authResponse({ AuthTokens: { AccessToken: jwt, RefreshToken: null } }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintApiError);
expect((err as WintApiError).message).toContain('incomplete token pair');
});
it('rejects an unrecognized LoginState instead of assuming success', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ State: 'SomethingNewFromWint' }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintLoginRejectedError);
expect((err as WintLoginRejectedError).state).toBe('SomethingNewFromWint');
});
});
describe('refreshWintToken', () => {
it('posts the refresh token as a bare JSON string and returns the rotated pair', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ AuthTokens: { AccessToken: makeJwt({ exp: Math.floor(Date.now() / 1000) + 600 }), RefreshToken: 'refresh-2' } }));
const tokens = await refreshWintToken('refresh-1');
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toContain('/api/Auth/refresh');
// The swagger types the request body as a plain string.
expect((init as RequestInit).body).toBe('"refresh-1"');
expect(tokens.refresh_token).toBe('refresh-2');
});
});
describe('jwtExpiresInSeconds', () => {
it('reads exp from the JWT payload', () => {
const now = 1_700_000_000_000;
const token = makeJwt({ exp: 1_700_000_000 + 1200 });
expect(jwtExpiresInSeconds(token, now)).toBe(1200);
});
it('falls back to 15 minutes for opaque tokens', () => {
expect(jwtExpiresInSeconds('not-a-jwt')).toBe(900);
});
it('falls back to 15 minutes for an already-expired exp', () => {
const now = 1_700_000_000_000;
const token = makeJwt({ exp: 1_700_000_000 - 60 });
expect(jwtExpiresInSeconds(token, now)).toBe(900);
});
});
});