Files
accounted/lib/auth/__tests__/scope-catalog.test.ts
T
Jakob Wennberg b8605aabfc fix(settings): derive API-key scope groups and tool counts from the scope catalogue (#1924)
The API-key settings panel carried a hand-copied list of scope groups that
had drifted to 24 of the 30 scopes in API_KEY_SCOPES: articles:read/write,
companies:write and the three reconciliation scopes were missing, so a key
minted in the dashboard could not call gnubok_create_company, the article
tools, the seven reconciliation tools or the matching v1 endpoints. The
per-scope "N verktyg" counts in the panel and in the API_KEY_SCOPES
descriptions were hand-maintained and wrong (reports:read said 18, actual
30; bookkeeping:write said 11, actual 22).

- Move the pure scope catalogue (API_KEY_SCOPES, scope lists, SCOPE_GROUPS,
  TOOL_SCOPE_MAP) into lib/auth/scope-catalog.ts with no server imports, so
  the client-side panel can bundle it. api-keys.ts re-exports everything,
  so existing imports are unchanged.
- SCOPE_GROUPS becomes a list of { domain, label, scopes } covering every
  scope (reconciliation has three), shared by the panel and the OAuth
  consent page. scopeKind() replaces the ad hoc suffix checks.
- TOOL_COUNT_BY_SCOPE is derived from TOOL_SCOPE_MAP at module load; the
  hand-written counts are removed from the catalogue descriptions.
- The panel renders groups and cards from the catalogue; i18n keys are
  derived from domain and scope id. The "(REST API)" heading suffix is
  computed from the counts instead of baked into the labels.
- New unit test asserts every scope belongs to exactly one group and that
  counts equal TOOL_SCOPE_MAP occurrences.
- New sv/en strings for the articles, companies:write and reconciliation
  scopes.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:42 +02:00

100 lines
3.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
ALL_SCOPES,
API_KEY_SCOPES,
SCOPE_GROUPS,
TOOL_COUNT_BY_SCOPE,
TOOL_SCOPE_MAP,
scopeKind,
type ApiKeyScope,
} from '../scope-catalog'
import * as apiKeys from '../api-keys'
describe('SCOPE_GROUPS', () => {
it('covers every scope in API_KEY_SCOPES exactly once', () => {
const occurrences = new Map<ApiKeyScope, number>()
for (const group of SCOPE_GROUPS) {
for (const scope of group.scopes) {
occurrences.set(scope, (occurrences.get(scope) ?? 0) + 1)
}
}
const missing = ALL_SCOPES.filter((s) => !occurrences.has(s))
const duplicated = [...occurrences].filter(([, n]) => n > 1).map(([s]) => s)
expect(missing).toEqual([])
expect(duplicated).toEqual([])
})
it('only references scopes that exist in the catalogue', () => {
for (const group of SCOPE_GROUPS) {
for (const scope of group.scopes) {
expect(scope in API_KEY_SCOPES).toBe(true)
}
}
})
it('has unique domains and lists the read scope first', () => {
const domains = SCOPE_GROUPS.map((g) => g.domain)
expect(new Set(domains).size).toBe(domains.length)
for (const group of SCOPE_GROUPS) {
const readIndex = group.scopes.findIndex((s) => scopeKind(s) === 'read')
if (readIndex !== -1) expect(readIndex).toBe(0)
}
})
})
describe('TOOL_COUNT_BY_SCOPE', () => {
it('has an entry for every scope and none for anything else', () => {
expect(Object.keys(TOOL_COUNT_BY_SCOPE).sort()).toEqual([...ALL_SCOPES].sort())
})
it('equals the number of TOOL_SCOPE_MAP entries mapped to each scope', () => {
for (const scope of ALL_SCOPES) {
const expected = Object.values(TOOL_SCOPE_MAP).filter((s) => s === scope).length
expect(TOOL_COUNT_BY_SCOPE[scope], scope).toBe(expected)
}
const total = Object.values(TOOL_COUNT_BY_SCOPE).reduce((a, b) => a + b, 0)
expect(total).toBe(Object.keys(TOOL_SCOPE_MAP).length)
})
it('only maps tools to scopes that exist', () => {
for (const [tool, scope] of Object.entries(TOOL_SCOPE_MAP)) {
expect(scope in API_KEY_SCOPES, tool).toBe(true)
}
})
})
describe('API_KEY_SCOPES labels', () => {
it('carries no hand-written tool counts (they are derived)', () => {
for (const [scope, meta] of Object.entries(API_KEY_SCOPES)) {
expect(meta.description, scope).not.toMatch(/\(\d+ verktyg\)/)
expect(meta.label, scope).not.toMatch(/\(\d+ verktyg\)/)
}
})
it('formats every label as "Område: verb"', () => {
for (const meta of Object.values(API_KEY_SCOPES)) {
expect(meta.label).toMatch(/^[^:]+: .+$/)
}
})
})
describe('scopeKind', () => {
it('treats :read as read and everything else as an elevated grant', () => {
expect(scopeKind('transactions:read')).toBe('read')
expect(scopeKind('transactions:write')).toBe('write')
expect(scopeKind('webhooks:manage')).toBe('write')
expect(scopeKind('pending_operations:approve')).toBe('write')
expect(scopeKind('reconciliation:signoff')).toBe('write')
})
})
describe('api-keys re-exports', () => {
it('exposes the same catalogue objects so server imports keep working', () => {
expect(apiKeys.API_KEY_SCOPES).toBe(API_KEY_SCOPES)
expect(apiKeys.SCOPE_GROUPS).toBe(SCOPE_GROUPS)
expect(apiKeys.TOOL_SCOPE_MAP).toBe(TOOL_SCOPE_MAP)
expect(apiKeys.TOOL_COUNT_BY_SCOPE).toBe(TOOL_COUNT_BY_SCOPE)
expect(apiKeys.ALL_SCOPES).toBe(ALL_SCOPES)
})
})