Files
accounted/app/(auth)/auth/callback/__tests__/email-change-status.test.ts
T
MattssonandClaude Fable 5 341d61131a fix(auth): email-change recovery re-send and confirmation feedback (#2034)
* fix(auth): email-change recovery re-send and confirmation feedback

A half-completed secure email change was a dead end: the pending-address
short-circuit in /api/account/email swallowed every retry without
re-sending mails, so once the confirmation links expired the user could
never recover, and confirmation clicks landed on the dashboard with no
feedback at all.

- /api/account/email: only short-circuit a repeat request while the
  pending mails are fresh (30 min); a stale pending change falls through
  to GoTrue, which restarts the change and re-sends both mails
- /auth/callback: type=email_change now redirects to a status page
  (/auth/email-change) that says whether one click remains, the change
  is complete, or the link was dead, instead of landing silently
- auth mail templates: both email-change mails explain that two mails
  are sent and both links must be clicked
- settings: the save button re-enables for the pending address as
  Skicka igen, so users can trigger the re-send themselves

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

* fix(auth): exempt email-change confirmations from the authenticated /auth bounce (skeptic findings)

- middleware: let /auth/email-change and /auth/callback?type=email_change
  through for authenticated users; the bounce to / swallowed confirmation
  clicks before verifyOtp ran (pre-existing since #2017)
- email-change done page resolves the WL-14 landing destination for the CTA
- /api/account/email returns resent flag; settings toast says mails were
  already sent instead of claiming a fresh send

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:21:16 +02:00

100 lines
3.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
// The email_change branch returns before any of the invite/team/landing
// machinery runs; these mocks only keep the module importable in the test
// environment.
vi.mock('@/lib/auth/invite-tokens', () => ({ hashInviteToken: vi.fn() }))
vi.mock('@/lib/auth/consume-invite-cookie', () => ({
INVITE_COOKIE_NAME: 'gnubok-invite-token',
}))
vi.mock('@/lib/company/landing-server', () => ({
resolveLandingDestination: vi.fn().mockResolvedValue('/'),
}))
vi.mock('@/lib/company/pending-invites', () => ({
acceptPendingTeamInviteByToken: vi.fn(),
}))
const verifyOtp = vi.fn()
vi.mock('@supabase/ssr', () => ({
createServerClient: () => ({
auth: {
verifyOtp: (...args: unknown[]) => verifyOtp(...args),
exchangeCodeForSession: vi.fn(),
getUser: vi.fn().mockResolvedValue({ data: { user: null } }),
},
}),
}))
import { GET } from '../route'
function makeRequest(params: Record<string, string>) {
const url = new URL('https://app.testbrand.example/auth/callback')
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
return new NextRequest(url)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /auth/callback type=email_change', () => {
it('redirects a first confirmation (no session) to the partial status page', async () => {
verifyOtp.mockResolvedValue({
data: { user: null, session: null },
error: null,
})
const res = await GET(makeRequest({ token_hash: 'th', type: 'email_change' }))
expect(res.headers.get('location')).toBe(
'https://app.testbrand.example/auth/email-change?status=partial',
)
expect(verifyOtp).toHaveBeenCalledWith({
token_hash: 'th',
type: 'email_change',
})
})
it('redirects the completing confirmation (session minted) to the done status page', async () => {
verifyOtp.mockResolvedValue({
data: { user: { id: 'u1' }, session: { access_token: 'at' } },
error: null,
})
const res = await GET(makeRequest({ token_hash: 'th', type: 'email_change' }))
expect(res.headers.get('location')).toBe(
'https://app.testbrand.example/auth/email-change?status=done',
)
})
it('redirects an invalid or expired link to the failed status page', async () => {
verifyOtp.mockResolvedValue({
data: { user: null, session: null },
error: { message: 'Email link is invalid or has expired' },
})
const res = await GET(makeRequest({ token_hash: 'th', type: 'email_change' }))
expect(res.headers.get('location')).toBe(
'https://app.testbrand.example/auth/email-change?status=failed',
)
})
it('keeps the generic login-error redirect for other failed types', async () => {
verifyOtp.mockResolvedValue({
data: { user: null, session: null },
error: { message: 'Email link is invalid or has expired' },
})
const res = await GET(makeRequest({ token_hash: 'th', type: 'signup' }))
const location = res.headers.get('location') ?? ''
expect(location).toContain('/login?error=auth_error')
expect(location).not.toContain('/auth/email-change')
})
})