Arkivplats and Koppla filer are enabled for integration 39254 in the Fortnox Developer Portal, so FORTNOX_DOCUMENT_SCOPES_APPROVED goes true and the opt-in underlag reconnect starts asking for archive and connectfile. The ordinary connect is untouched, so no customer is put in front of an Arkivplats licence to connect at all. This is the half that actually delivers the files. #1761 only stopped the loop that told people to grant a permission we never requested; until now no Fortnox token in production has ever carried these scopes, and no voucher attachment has ever imported (166 companies, 24 consents since the feature shipped). Set the flag back to false if the portal ever loses the scopes: authorize then rejects with invalid_scope before login, which now costs the underlag flow rather than every Fortnox connection. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
76 lines
3.1 KiB
TypeScript
76 lines
3.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildFortnoxAuthUrl,
|
|
fortnoxConsentScopes,
|
|
FORTNOX_DOCUMENT_SCOPES,
|
|
FORTNOX_DOCUMENT_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');
|
|
});
|
|
|
|
// 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');
|
|
});
|
|
});
|