FORTNOX_DOCUMENT_SCOPES_APPROVED and FORTNOX_ASSET_SCOPES_APPROVED read env vars of the same name with the hosted registration's values as defaults, so a self-hosted Fortnox app avoids invalid_scope at authorize and the impossible reconnect loop without patching source. Supersedes #2001. Co-authored-by: Pierre Gronberg <pierre@hbgdesignlab.se>
109 lines
4.8 KiB
TypeScript
109 lines
4.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildFortnoxAuthUrl,
|
|
fortnoxConsentScopes,
|
|
fortnoxScopeFlag,
|
|
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 env override exists for self-hosted deployments running their own
|
|
// Fortnox app, whose portal registration differs from hosted's. Unset (or
|
|
// empty, which is how a commented-out .env line arrives) means the hosted
|
|
// default; anything but the string "true" is false, so a typo fails toward
|
|
// not requesting a scope rather than toward invalid_scope at authorize.
|
|
it('lets env override the hosted scope-approval defaults', () => {
|
|
expect(fortnoxScopeFlag(undefined, true)).toBe(true);
|
|
expect(fortnoxScopeFlag(undefined, false)).toBe(false);
|
|
expect(fortnoxScopeFlag('', true)).toBe(true);
|
|
expect(fortnoxScopeFlag('true', false)).toBe(true);
|
|
expect(fortnoxScopeFlag(' true ', false)).toBe(true);
|
|
expect(fortnoxScopeFlag('false', true)).toBe(false);
|
|
expect(fortnoxScopeFlag('yes', true)).toBe(false);
|
|
});
|
|
|
|
// 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');
|
|
});
|
|
});
|