Files
accounted/app/api/mcp-oauth/authorize/__tests__/route.test.ts
T
31e0cd6e05 feat(onboarding): company setup from the conversation and POST /api/v1/companies (#1814 PR 3) (#1864)
* feat(onboarding): company setup from the conversation and POST /api/v1/companies

Third PR of agent-first onboarding (#1814). Once connected, the agent can
now set up a company end to end without the web wizard, and partner
platforms can provision companies over REST.

- create_company_for_user: service-role-only SECURITY DEFINER twin of
  create_company_with_owner taking the owner explicitly (service clients
  have no auth.uid()). pg-real test covers creation, role gating, unknown
  owner and foreign team.
- lib/company/create-company.ts: the wizard's creation sequence (org
  number, TIC snapshot, BAS chart, settings, first fiscal period, tax
  deadlines, rollback) extracted into createCompanyCore; the Server
  Action delegates to it, behaviour unchanged.
- lib/company/onboarding-input.ts: one Zod schema + planner for the
  agent/API paths; a VAT-registered company without moms_period is
  refused (a missing period silently yields zero VAT deadlines).
- MCP: gnubok_create_company (two-phase: preview, then confirm=true;
  companies:write, company-independent), gnubok_connect_bank and
  gnubok_connect_skatteverket (status + the browser link, gated on
  bank_sync / skatteverket, search-only in the catalog), the
  "onboarding" skill, and initialize instructions pointing at it.
- Consent page pre-ticks companies:write for an account with no company
  yet, so the setup does not dead-end on insufficient scope after signup.
- POST /api/v1/companies (companies:write, dry-run aware) on the same
  core; scope map, registry, spec snapshot and the generated API skill
  updated.
- tools/list payload ceiling raised 59.95K -> 60.4K for the one new
  default-catalog tool (documented in the guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(onboarding): explicit f_skatt, org number when VAT-registered, EF first year ends 31 Dec

Review findings on #1864 (Swedish compliance review):
- f_skatt is required, never defaulted to approved (SE-R-005 risk).
- org_number is required when vat_registered: the invoice
  momsregistreringsnummer derives from it (ML 17 kap 24 §).
- An enskild firma's first fiscal year must end on 31 December and its
  start month is forced to 1 even with first_fiscal_year set, mirroring
  the wizard's own rule text (BFL 3 kap. 1 §).
- POST /api/v1/companies no longer claims Idempotency-Key support (the
  wrapper only honours it on company-scoped routes).
- pg-real: createCompanyCore's chart seed runs under the real
  service_role, which the unit tests could not prove.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* test(pg): starter chart has 41 accounts, assert non-empty

The service_role chart-seed proof passed the part that mattered (no
42501 from seed_chart_of_accounts) and failed on a wrong row-count
guess: the seeded chart is a curated starter set, not the full BAS list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(migrations): move create_company_for_user to 20260825120000

main gained 20260824170000_bulk_book_transactions_service_actor.sql with
the same version while this branch was open; two files on one version
abort every Supabase branch apply and the prod auto-apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* chore(api): refresh spec snapshot and generated skill after rebasing onto main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp): flat create_company result, refuse localhost connect links, test hygiene

CodeRabbit on #1864: the confirmed-create result was wrapped in the
{ data, next } envelope while its outputSchema promised top-level
fields; it now returns the fields with next as a sibling. The two
connect-link tools refuse to build a link when NEXT_PUBLIC_APP_URL is
unset instead of handing a remote user a localhost URL. Tests clear
mocks and the event bus in beforeEach. Not changed: the rollback
already survives user_preferences.active_company_id (that FK is ON
DELETE SET NULL since 20260331010000), and v1 error details stay in the
surface's English developer convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:41:02 +02:00

533 lines
21 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
const mocks = vi.hoisted(() => ({
createClient: vi.fn(),
isAllowedRedirectUri: vi.fn(),
getActiveCompanyId: vi.fn(),
getBranding: vi.fn(),
}))
vi.mock('@/lib/auth/oauth-codes', () => ({
createAuthCode: vi.fn(() => 'test-auth-code'),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: () => mocks.createClient(),
}))
vi.mock('@/lib/auth/oauth-allowlist', () => ({
isAllowedRedirectUri: (...args: unknown[]) => mocks.isAllowedRedirectUri(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: (...args: unknown[]) => mocks.getActiveCompanyId(...args),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => mocks.getBranding(),
}))
import { GET, POST } from '../route'
function buildAuthorizeUrl(params: Record<string, string>): string {
const url = new URL('http://localhost/api/mcp-oauth/authorize')
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
return url.toString()
}
function buildSupabase(
user: { id: string; email?: string } | null,
companyName = 'Test AB',
aal: { currentLevel: string; nextLevel: string } = { currentLevel: 'aal2', nextLevel: 'aal2' },
verifiedFactors: number = aal.nextLevel === 'aal2' ? 1 : 0,
) {
return {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user }, error: null }),
mfa: {
getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: aal, error: null }),
listFactors: vi.fn().mockResolvedValue({
data: {
totp: Array.from({ length: verifiedFactors }, (_, i) => ({
id: `factor-${i}`,
status: 'verified',
})),
},
error: null,
}),
},
},
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { company_name: companyName },
error: null,
}),
}),
}),
}),
}
}
describe('GET /api/mcp-oauth/authorize: CSP', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' }))
mocks.isAllowedRedirectUri.mockResolvedValue(true)
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
it("form-action includes the redirect_uri origin so the post-consent redirect isn't blocked", async () => {
// Regression: the consent form POSTs same-origin, but the server's 303
// response redirects to the client callback. CSP form-action re-checks
// every hop in the chain, so 'self' alone blocks the post-consent step.
const request = new Request(
buildAuthorizeUrl({
response_type: 'code',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
state: 'xyz',
})
)
const response = await GET(request)
expect(response.status).toBe(200)
const csp = response.headers.get('Content-Security-Policy')
expect(csp).toBeTruthy()
expect(csp).toMatch(/form-action 'self' https:\/\/claude\.ai(;|$)/)
// 'self' is preserved so the same-origin POST still works.
expect(csp).toContain("form-action 'self'")
})
it('form-action uses the redirect origin only (no path/query leakage)', async () => {
const request = new Request(
buildAuthorizeUrl({
response_type: 'code',
redirect_uri: 'https://claude.com/api/oauth/callback?env=prod',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
})
)
const response = await GET(request)
expect(response.status).toBe(200)
const csp = response.headers.get('Content-Security-Policy') ?? ''
expect(csp).toContain('https://claude.com')
// Origin only: no path, no query string in the source expression.
expect(csp).not.toContain('/api/oauth/callback')
expect(csp).not.toContain('env=prod')
})
it('HTML-escapes the reflected query string in the form action', async () => {
// The consent form posts back to the same URL, so url.search is echoed into
// an HTML attribute, and only redirect_uri/client_id/scope are validated:
// any extra parameter reaches that attribute.
//
// Two layers, and it is worth being precise about which does what. WHATWG
// URL parsing already percent-encodes " < > in the query component, so an
// injected tag arrives inert and CodeQL's js/reflected-xss report is not a
// live exploit. But `&` is NOT in that encode set, so without escaping the
// attribute carries raw ampersands, which is invalid HTML and leaves the
// page one refactor (a raw header, a non-WHATWG parser) away from a real
// breakout. This asserts the escaping layer, independent of the parser.
const request = new Request(
buildAuthorizeUrl({
response_type: 'code',
redirect_uri: 'https://claude.com/api/oauth/callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
}) + '&evil=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E'
)
const response = await GET(request)
expect(response.status).toBe(200)
const html = await response.text()
const action = html.match(/<form method="POST" action="([^"]*)"/)?.[1]
expect(action).toBeDefined()
// Separators are entity-encoded: proof escapeHtml ran over the whole thing.
expect(action).toContain('&amp;evil=')
expect(action).not.toMatch(/&(?!amp;|quot;|lt;|gt;)/)
// The attribute is never closed early, so no raw markup escapes into the page.
expect(html).not.toContain('"><script>')
expect(html).not.toContain('<script>alert(1)</script>')
})
it('renders both read and write rows when client passes only the legacy `mcp` scope marker', async () => {
// Claude's connector sends scope=mcp today. The consent UI must render
// every scope group so the user can opt into write/approval rows if they
// want, but each write/approve row MUST start unchecked. Affirmative
// opt-in is the access-control gate (GDPR Art. 25(2), ISO 27001:2022
// A.5.18 / A.8.2, SOC 2 CC6.3, ASVS V10.2.2 / V2.3.1).
const request = new Request(
buildAuthorizeUrl({
response_type: 'code',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
})
)
const response = await GET(request)
expect(response.status).toBe(200)
const html = await response.text()
// Every scope row is rendered so the user can opt into / out of each one.
expect(html).toMatch(/value="transactions:write"/)
expect(html).toMatch(/value="bookkeeping:write"/)
expect(html).toMatch(/value="invoices:write"/)
expect(html).toMatch(/value="pending_operations:approve"/)
// Write and approval scopes MUST render unchecked. Users have to make an
// affirmative, deliberate selection for each destructive permission.
const writeRow = html.match(/<input[^>]*value="transactions:write"[^>]*>/)?.[0]
expect(writeRow).toBeDefined()
expect(writeRow!).not.toContain('checked')
const approveRow = html.match(/<input[^>]*value="pending_operations:approve"[^>]*>/)?.[0]
expect(approveRow).toBeDefined()
expect(approveRow!).not.toContain('checked')
const bookkeepingRow = html.match(/<input[^>]*value="bookkeeping:write"[^>]*>/)?.[0]
expect(bookkeepingRow).toBeDefined()
expect(bookkeepingRow!).not.toContain('checked')
// The :read counterpart is pre-checked (safe default).
const readRow = html.match(/<input[^>]*value="transactions:read"[^>]*>/)?.[0]
expect(readRow).toBeDefined()
expect(readRow!).toContain('checked')
})
it('renders only the requested scopes when the client passes them explicitly', async () => {
// RFC 6749 §3.3 strict least-privilege: an explicit `scope=` shrinks the
// ceiling, so a client that asked for read-only cannot have a write box
// surface at consent time.
const request = new Request(
buildAuthorizeUrl({
response_type: 'code',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'transactions:read invoices:read',
})
)
const response = await GET(request)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('value="transactions:read"')
expect(html).toContain('value="invoices:read"')
expect(html).not.toContain('value="transactions:write"')
expect(html).not.toContain('value="bookkeeping:write"')
})
it('rejects disallowed redirect_uri before any CSP would be emitted', async () => {
mocks.isAllowedRedirectUri.mockResolvedValue(false)
const request = new Request(
buildAuthorizeUrl({
response_type: 'code',
redirect_uri: 'https://evil.example/cb',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
})
)
const response = await GET(request)
expect(response.status).toBe(400)
// Important: the form-action whitelist must never be populated from an
// untrusted origin. A 400 here keeps the allowlist as the single source
// of truth for which origins can land at this endpoint.
})
})
describe('MFA step-up on /api/mcp-oauth/authorize', () => {
// Consent here ultimately mints a long-lived API key that bypasses MFA on
// every subsequent request, so an AAL1 (password-only) session must never
// reach the consent page or approve it. The middleware MFA gate exempts
// /api/mcp-oauth/*, making the route responsible for its own step-up.
const authorizeParams = {
response_type: 'code',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
state: 'xyz',
}
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
mocks.isAllowedRedirectUri.mockResolvedValue(true)
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('GET redirects an AAL1 session to /mfa/verify with returnTo', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal2' }),
)
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
expect(response.status).toBeGreaterThanOrEqual(300)
expect(response.status).toBeLessThan(400)
const location = new URL(response.headers.get('location')!)
expect(location.pathname).toBe('/mfa/verify')
const returnTo = new URL(location.searchParams.get('returnTo')!, location.origin)
expect(returnTo.pathname).toBe('/api/mcp-oauth/authorize')
expect(returnTo.searchParams.get('state')).toBe('xyz')
})
it('POST rejects an AAL1 session even when the consent form is forged', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal2' }),
)
const formData = new FormData()
formData.set('consent', 'allow')
const response = await POST(
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
)
expect(response.status).toBeGreaterThanOrEqual(300)
expect(response.status).toBeLessThan(400)
expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/verify')
// No auth code must be minted: the redirect target is the step-up page,
// never the client callback.
expect(response.headers.get('location')).not.toContain('code=')
})
it('GET renders consent for an AAL2 session', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal2', nextLevel: 'aal2' }),
)
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
expect(response.status).toBe(200)
})
it('GET fails closed to /mfa/verify when the assurance lookup returns nothing', async () => {
// A transient auth error must never read as "no MFA needed": consent
// here mints a key that bypasses MFA on every later call.
const supabase = buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal1' }, 1)
;(supabase.auth.mfa.getAuthenticatorAssuranceLevel as ReturnType<typeof vi.fn>).mockResolvedValue({
data: null,
error: { message: 'boom' },
})
mocks.createClient.mockResolvedValue(supabase)
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/verify')
})
it('GET steps up (not enroll) when a verified factor exists despite an AAL1 answer', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal1' }, 1),
)
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/verify')
})
it('GET sends a password account with no factor to /mfa/enroll with returnTo', async () => {
// A brand-new account created inside the OAuth popup (issue #1814) has no
// company, so the middleware never forced enrollment. Without this leg the
// consent would mint an MFA-exempt key for an account with no second factor.
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal1' }, 0),
)
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
expect(response.status).toBeGreaterThanOrEqual(300)
expect(response.status).toBeLessThan(400)
const location = new URL(response.headers.get('location')!)
expect(location.pathname).toBe('/mfa/enroll')
const returnTo = new URL(location.searchParams.get('returnTo')!, location.origin)
expect(returnTo.pathname).toBe('/api/mcp-oauth/authorize')
expect(returnTo.searchParams.get('state')).toBe('xyz')
})
it('POST refuses consent from a password account with no factor', async () => {
mocks.createClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal1' }, 0),
)
const formData = new FormData()
formData.set('consent', 'allow')
const response = await POST(
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
)
expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/enroll')
expect(response.headers.get('location')).not.toContain('code=')
})
it('GET skips step-up for BankID-linked users (inherently 2FA)', async () => {
const supabase = buildSupabase(
{ id: 'user-1' },
'Test AB',
{ currentLevel: 'aal1', nextLevel: 'aal2' },
)
;(supabase.auth.getUser as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { user: { id: 'user-1', app_metadata: { bankid_linked: true } } },
error: null,
})
mocks.createClient.mockResolvedValue(supabase)
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
expect(response.status).toBe(200)
})
})
describe('account with no company yet (issue #1814)', () => {
// Someone who signed up inside the MCP client's OAuth popup has an account
// but no company. Consent must still complete: the key is minted unbound
// and binds itself once the company exists.
const authorizeParams = {
response_type: 'code',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
state: 'xyz',
}
function signScope(scopeParam: string): string {
const key = crypto.createHash('sha256').update('oauth-scope:test-service-key').digest()
return crypto.createHmac('sha256', key).update(scopeParam).digest('base64url')
}
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
mocks.isAllowedRedirectUri.mockResolvedValue(true)
mocks.getActiveCompanyId.mockResolvedValue(null)
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
it('GET renders consent labelled with the account instead of a company', async () => {
const supabase = buildSupabase({ id: 'user-1', email: 'ny@example.se' })
mocks.createClient.mockResolvedValue(supabase)
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('ny@example.se')
expect(html).toContain('inget företag')
expect(html).not.toContain('Test AB')
// No company to look up: company_settings is never queried.
expect(supabase.from).not.toHaveBeenCalled()
})
it('pre-ticks companies:write so the agent can create the company, other writes stay unticked', async () => {
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1', email: 'ny@example.se' }))
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
const html = await response.text()
const companiesWrite = html.match(/<input[^>]*value="companies:write"[^>]*>/)?.[0]
expect(companiesWrite).toBeDefined()
expect(companiesWrite!).toContain('checked')
const transactionsWrite = html.match(/<input[^>]*value="transactions:write"[^>]*>/)?.[0]
expect(transactionsWrite).toBeDefined()
expect(transactionsWrite!).not.toContain('checked')
})
it('POST still issues an authorization code', async () => {
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1', email: 'ny@example.se' }))
const formData = new FormData()
formData.set('consent', 'allow')
formData.set('scope_binding', 'mcp')
formData.set('scope_binding_sig', signScope('mcp'))
const response = await POST(
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
)
expect(response.status).toBe(303)
const location = new URL(response.headers.get('location')!)
expect(location.searchParams.get('code')).toBe('test-auth-code')
expect(location.searchParams.get('state')).toBe('xyz')
})
})
describe('RFC 9207 iss parameter on authorization responses', () => {
const authorizeParams = {
response_type: 'code',
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
code_challenge: 'abc',
code_challenge_method: 'S256',
scope: 'mcp',
state: 'xyz',
}
// Mirrors getScopeSigningKey/signScopeBinding in the route so the POST can
// present a scope binding that verifies against the test service key.
function signScope(scopeParam: string): string {
const key = crypto.createHash('sha256').update('oauth-scope:test-service-key').digest()
return crypto.createHmac('sha256', key).update(scopeParam).digest('base64url')
}
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test.example')
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' }))
mocks.isAllowedRedirectUri.mockResolvedValue(true)
mocks.getActiveCompanyId.mockResolvedValue('company-1')
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('includes iss alongside code and state on the success redirect', async () => {
const formData = new FormData()
formData.set('consent', 'allow')
formData.set('scope_binding', 'mcp')
formData.set('scope_binding_sig', signScope('mcp'))
const response = await POST(
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
)
expect(response.status).toBe(303)
const location = new URL(response.headers.get('location')!)
expect(location.searchParams.get('code')).toBe('test-auth-code')
expect(location.searchParams.get('state')).toBe('xyz')
expect(location.searchParams.get('iss')).toBe('https://app.test.example')
})
it('includes iss on error redirects (access_denied)', async () => {
const formData = new FormData()
formData.set('consent', 'deny')
const response = await POST(
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
)
expect(response.status).toBe(303)
const location = new URL(response.headers.get('location')!)
expect(location.searchParams.get('error')).toBe('access_denied')
expect(location.searchParams.get('iss')).toBe('https://app.test.example')
})
})