Files
accounted/lib/providers/fortnox/__tests__/oauth.test.ts
T
Pierre Grönbergandpgronberg 8b0aa80ea0 feat(arcim-migration): import the Fortnox asset register during migration (#1999)
The Fortnox migration now imports the asset register (GET /3/assets + /3/assets/types) as local register rows via createAsset: category from the type's anskaffningskonto BAS class, useful life from the source's depreciation window (K2 schablon fallback), never any journal entries (values arrived via SIE; the source's depreciated-to date is recorded in notes for review of the first proposal). Sold/scrapped/voided assets are skipped, re-runs dedupe, one bad asset counts as skipped. Gated behind FORTNOX_ASSET_SCOPES_APPROVED=false until the portal registration for integration 39254 carries the Assets scope, so hosted consents are unchanged and the wizard shows an honest skipped row.

Co-authored-by: pgronberg <pgronberg@users.noreply.github.com>
2026-08-31 09:25:55 +01:00

93 lines
3.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildFortnoxAuthUrl,
fortnoxConsentScopes,
FORTNOX_DOCUMENT_SCOPES,
FORTNOX_DOCUMENT_SCOPES_APPROVED,
FORTNOX_ASSET_SCOPES,
FORTNOX_ASSET_SCOPES_APPROVED,
} from '../oauth';
describe('Fortnox OAuth scopes', () => {
// Pins the 2026-08-13 incident fix: the registered Fortnox app does not
// have the archive/connectfile scopes approved, and requesting a scope the
// app lacks makes Fortnox reject the authorize request with invalid_scope
// before the user can even log in. Do not add them back here until the
// Fortnox Developer Portal registration includes them.
it('does not request archive or connectfile until the Fortnox app has them approved', () => {
const url = new URL(
buildFortnoxAuthUrl({
clientId: 'client-id',
clientSecret: 'client-secret',
redirectUri: 'https://accounted.example.test/callback',
}),
);
const scopes = new Set(url.searchParams.get('scope')?.split(' ') ?? []);
expect(scopes).toContain('bookkeeping');
expect(scopes).not.toContain('archive');
expect(scopes).not.toContain('connectfile');
});
// The document-import error message is derived from this flag, so that a
// user is never told to reconnect for a permission the connect request does
// not ask for. Flipping it here without enabling the scopes in the Fortnox
// Developer Portal reintroduces the 2026-08-13 invalid_scope outage.
it('has the document scopes approved in the portal, and names both of them', () => {
expect(FORTNOX_DOCUMENT_SCOPES_APPROVED).toBe(true);
expect(FORTNOX_DOCUMENT_SCOPES).toEqual(['archive', 'connectfile']);
});
// The whole point of the opt-in consent: this is the only scope list that
// carries the attachment permissions, and the ordinary connect above still
// must not, so no customer is asked for an Arkivplats licence to connect.
it('puts the attachment scopes in the document consent only', () => {
expect(fortnoxConsentScopes({ documents: true })).toContain('archive');
expect(fortnoxConsentScopes({ documents: true })).toContain('connectfile');
expect(fortnoxConsentScopes()).not.toContain('archive');
expect(fortnoxConsentScopes()).not.toContain('connectfile');
});
// The asset register scope is gated on its own portal approval. While the
// flag is false, no consent may request it: an unapproved scope in the
// authorize request is rejected with invalid_scope BEFORE login (the same
// outage mode the document flag above guards against).
it('keeps the asset scope out of every consent until the portal approves it', () => {
expect(FORTNOX_ASSET_SCOPES).toEqual(['assets']);
if (FORTNOX_ASSET_SCOPES_APPROVED) {
expect(fortnoxConsentScopes()).toContain('assets');
expect(fortnoxConsentScopes({ documents: true })).toContain('assets');
} else {
expect(fortnoxConsentScopes()).not.toContain('assets');
expect(fortnoxConsentScopes({ documents: true })).not.toContain('assets');
}
});
// Even once the portal registration lands, opting in must never cost the
// consent its ledger access: the callback overwrites its tokens in place.
it('keeps a document consent a superset of an ordinary one', () => {
const withDocuments = fortnoxConsentScopes({ documents: true });
for (const scope of fortnoxConsentScopes()) {
expect(withDocuments).toContain(scope);
}
});
it('requests the document scopes only when they are explicitly passed in', () => {
const url = new URL(
buildFortnoxAuthUrl(
{
clientId: 'client-id',
clientSecret: 'client-secret',
redirectUri: 'https://accounted.example.test/callback',
},
{ scopes: ['bookkeeping', ...FORTNOX_DOCUMENT_SCOPES] },
),
);
const scopes = new Set(url.searchParams.get('scope')?.split(' ') ?? []);
expect(scopes).toContain('archive');
expect(scopes).toContain('connectfile');
});
});