test(skatteverket): guard LEGACY_DISCOVERY_HOSTS against config drift (#1093) (#1107)

Add validateLegacyDiscoveryHosts() next to the allowlist in
lib/api/v1/base-url.ts and a unit test that pins the registered
production configuration (app.accounted.se canonical,
app.gnubok.se SKV OAuth pin). The invariant is checked both ways:
the NEXT_PUBLIC_SKV_OAUTH_BASE_URL host must be reflectable by
discovery (canonical or allowlisted), and every allowlist member
must be accounted for by the registered configuration. Drift is
now a red CI test instead of a silent production re-auth failure
near a filing deadline. No runtime behavior changes.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-22 18:11:04 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 826513b4fd
commit 8294899543
3 changed files with 176 additions and 0 deletions
+1
View File
@@ -272,3 +272,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-21] Restrict annual-report signature evidence transitions to the server service role and structured opaque references: browser RLS may manage only unbound pending roster rows, so route validation cannot be bypassed and evidence references cannot carry free-text personal data.
[2026-07-21] Card-descriptor normalization keys on the pre-star merchant segment (post-star for processor prefixes) plus a token_subset match tier, instead of the deferred AI descriptor normalization (data_quality_master Appendix B): deterministic, mirrors into normalize_counterparty_key() so ledger-context template joins stay exact, and fixes the reported Anthropic no-signal case with no new infrastructure. Merchant history now falls back to description because card purchases never carry merchant_name.
[2026-07-22] Issue #313 fix limited to the meals warning; left "Representationsgåvor max 180 kr" on the gåvor line untouched: scope rule (only the inverted-VAT claim and repealed ML 8:9 reference), even though the swedish-vat skill lists 300 SEK as the representationsgåvor base; flagged as follow-up in the PR.
[2026-07-22] LEGACY_DISCOVERY_HOSTS drift guard (#1093) is an exported validateLegacyDiscoveryHosts() returning a violations list, exercised only by a unit test that pins the registered prod config (app.accounted.se canonical + app.gnubok.se SKV pin), not a startup assertion: CI does not set the prod env vars, so a runtime assertion would either no-op in CI or crash self-hosted deploys with different domains; the test-pinned constants make any allowlist or pin change a deliberate, reviewed edit.
@@ -0,0 +1,110 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
/**
* Drift guard for LEGACY_DISCOVERY_HOSTS (issue #1093).
*
* The constants below pin the configuration actually registered in
* production: the canonical app host after the accounted.se cutover, and
* the Skatteverket OAuth redirect_uri host registered in Utvecklarportalen
* (pinned via NEXT_PUBLIC_SKV_OAUTH_BASE_URL on Vercel, kept on the legacy
* domain because the registration is slow to change).
*
* If either registration changes, update these constants in the same PR
* that changes LEGACY_DISCOVERY_HOSTS or the env pin. A red test here means
* the discovery allowlist and the registered OAuth configuration disagree,
* which in production surfaces as MCP clients re-authenticating against a
* mismatched issuer and AGI/moms staging failing silently.
*/
const CANONICAL = 'https://app.accounted.se'
const SKV_OAUTH_PIN = 'https://app.gnubok.se'
/**
* CI does not set these env vars, so every test stubs them explicitly and
* re-imports the module to be robust against env reads being hoisted to
* module scope in a future refactor.
*/
async function loadValidator() {
vi.resetModules()
const mod = await import('../base-url')
return mod.validateLegacyDiscoveryHosts
}
describe('validateLegacyDiscoveryHosts', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('passes for the registered production configuration', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', SKV_OAUTH_PIN)
const validate = await loadValidator()
expect(validate()).toEqual([])
})
it('reports an allowlisted host orphaned from the registered configuration', async () => {
// Direction (b): with the SKV pin moved elsewhere and the canonical host
// on accounted.se, nothing registered accounts for app.gnubok.se anymore,
// so the validator must flag the allowlist entry instead of passing.
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://oauth.elsewhere.example')
const validate = await loadValidator()
const violations = validate()
expect(
violations.some((v) => v.startsWith('LEGACY_DISCOVERY_HOSTS entry "app.gnubok.se"')),
).toBe(true)
})
it('reports a pinned SKV OAuth host that discovery would not reflect', async () => {
// Direction (a): the registered callback host must be either the
// canonical host or allowlisted, otherwise re-auth gets a wrong issuer.
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://oauth.elsewhere.example')
const validate = await loadValidator()
const violations = validate()
expect(
violations.some((v) =>
v.startsWith('NEXT_PUBLIC_SKV_OAUTH_BASE_URL host "oauth.elsewhere.example"'),
),
).toBe(true)
})
it('accepts the SKV pin pointing at the canonical host', async () => {
// If Utvecklarportalen is ever re-registered on the canonical domain and
// the allowlist is trimmed in the same PR, the invariant holds; only the
// orphaned app.gnubok.se entry is reported until the allowlist catches up.
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', CANONICAL)
const validate = await loadValidator()
const violations = validate()
expect(violations.some((v) => v.startsWith('NEXT_PUBLIC_SKV_OAUTH_BASE_URL host'))).toBe(
false,
)
expect(
violations.some((v) => v.startsWith('LEGACY_DISCOVERY_HOSTS entry "app.gnubok.se"')),
).toBe(true)
})
it('accepts a pre-cutover configuration where the canonical host is the legacy host', async () => {
// Self-hosted or pre-cutover: NEXT_PUBLIC_APP_URL still on app.gnubok.se
// and no SKV pin set. The canonical host accounts for the allowlist entry.
vi.stubEnv('NEXT_PUBLIC_APP_URL', SKV_OAUTH_PIN)
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', '')
const validate = await loadValidator()
expect(validate()).toEqual([])
})
it('is case-insensitive and ignores a trailing slash on the pin URL', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://App.Gnubok.SE/')
const validate = await loadValidator()
expect(validate()).toEqual([])
})
it('reports an unparseable NEXT_PUBLIC_SKV_OAUTH_BASE_URL instead of passing silently', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL)
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'not a url')
const validate = await loadValidator()
const violations = validate()
expect(violations.some((v) => v.includes('not a parseable URL'))).toBe(true)
})
})
+65
View File
@@ -34,6 +34,71 @@ export function getCanonicalBaseUrl(): string {
*/
const LEGACY_DISCOVERY_HOSTS = new Set(['app.gnubok.se'])
/**
* Guards LEGACY_DISCOVERY_HOSTS against drifting from the registered OAuth
* configuration (issue #1093). Two invariants, checked both ways:
*
* (a) When `NEXT_PUBLIC_SKV_OAUTH_BASE_URL` (the redirect_uri host
* registered with Skatteverket in Utvecklarportalen) is set, its host
* must be reflectable by discovery: either the canonical app host or a
* member of LEGACY_DISCOVERY_HOSTS. Otherwise MCP clients that
* re-authenticate through the pinned host receive an issuer that does
* not match, and AGI/moms staging fails silently.
*
* (b) Every member of LEGACY_DISCOVERY_HOSTS must be accounted for by the
* known registered configuration: the SKV OAuth pin host or the
* canonical app host. An orphan entry means someone added or kept a
* host that nothing registered actually uses.
*
* Returns human-readable violations; an empty array means the allowlist and
* the registered configuration agree. Called only from
* `__tests__/legacy-discovery-hosts.test.ts` (which pins the production
* registration), so drift is caught in CI instead of during a production
* re-auth near a filing deadline. No runtime behavior depends on it.
*/
export function validateLegacyDiscoveryHosts(): string[] {
const violations: string[] = []
let canonicalHost: string | null = null
try {
canonicalHost = new URL(getCanonicalBaseUrl()).host.toLowerCase()
} catch {
violations.push(
`NEXT_PUBLIC_APP_URL is not a parseable URL: "${process.env.NEXT_PUBLIC_APP_URL}"`,
)
}
const skvBase = process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL?.trim()
let skvHost: string | null = null
if (skvBase) {
try {
skvHost = new URL(skvBase).host.toLowerCase()
} catch {
violations.push(`NEXT_PUBLIC_SKV_OAUTH_BASE_URL is not a parseable URL: "${skvBase}"`)
}
}
if (skvHost && skvHost !== canonicalHost && !LEGACY_DISCOVERY_HOSTS.has(skvHost)) {
violations.push(
`NEXT_PUBLIC_SKV_OAUTH_BASE_URL host "${skvHost}" is neither the canonical host ` +
`("${canonicalHost}") nor in LEGACY_DISCOVERY_HOSTS; discovery would hand ` +
`re-authenticating clients a mismatched issuer`,
)
}
for (const legacyHost of LEGACY_DISCOVERY_HOSTS) {
if (legacyHost !== canonicalHost && legacyHost !== skvHost) {
violations.push(
`LEGACY_DISCOVERY_HOSTS entry "${legacyHost}" matches neither the canonical host ` +
`("${canonicalHost}") nor the NEXT_PUBLIC_SKV_OAUTH_BASE_URL host ` +
`("${skvHost ?? 'unset'}"); it is orphaned from the registered configuration`,
)
}
}
return violations
}
export function resolveDiscoveryBaseUrl(request: Request): string {
const canonical = getCanonicalBaseUrl()
const host = request.headers.get('host')?.trim().toLowerCase()