Files
accounted/app/api/user/preferences/__tests__/route.test.ts
T
Mattsson 11995b1b0c feat(auth): make automatic logout an opt-in per-user setting (#1536)
* feat(auth): make automatic logout an opt-in per-user setting

Session timeouts (30 min idle / 12 h absolute on hosted) now apply only
to users who enable "Automatic logout" in Settings > Security. Default
is off: sessions live for the full Supabase refresh-token lifetime, the
behavior from before the 2026-07 session hardening.

- user_preferences.auto_logout (migration, default false), toggled via
  the extended /api/user/preferences route
- The opt-in is snapshotted into the signed timeout cookie at mint, so
  enforcement stays DB-read-free per request; the preferences route
  clears the cookie on change so a toggle takes effect immediately
- Pre-toggle cookies are authentic-but-stale: re-minted preserving
  their timers, never routed down the tamper path, so the rollout does
  not log anyone out
- NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true enforces timeouts for
  every user regardless of preference (emergency lever, also plumbed
  through the Docker image); self-hosted stays disabled by default

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): resolve PR #1536 review findings

- Replace the spread upsert in /api/user/preferences with one literal
  payload per field: the phantom-column schema guard cannot resolve
  spread payloads (Unit tests 3/4 ceiling failure)
- Map the preferences 500 through getErrorMessage so the user-facing
  text is Swedish (CodeRabbit)
- fetchAutoLogoutPreference now returns null on a FAILED read instead
  of a fail-open false: callers skip minting so an unknown preference
  is never persisted into the year-long signed cookie, and the next
  request retries; failures log at error level, distinct from the
  normal opt-out path (compliance swarm GDPR Art.32(1)(b) / ISO A.8.5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): write multi-field preference updates as one atomic upsert

A request carrying both hide_assistant_fab and auto_logout previously
issued two sequential writes, so a failure of the second returned 500
after half the request had persisted (CodeRabbit, PR #1536). One
literal upsert per accepted field combination keeps the write atomic
and stays resolvable for the phantom-column schema guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:45:41 +02:00

156 lines
5.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { parseJsonResponse } from '@/tests/helpers'
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
import { GET, PATCH } from '../route'
beforeEach(() => {
vi.clearAllMocks()
})
function unauthed() {
requireAuthMock.mockResolvedValue({
user: null,
supabase: null,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
function authedForGet(
row: { hide_assistant_fab?: boolean; auto_logout?: boolean } | null,
) {
const maybeSingle = vi.fn().mockResolvedValue({ data: row, error: null })
const supabase = {
from: vi.fn(() => ({
select: vi.fn(() => ({
eq: vi.fn(() => ({ maybeSingle })),
})),
})),
}
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
return { supabase }
}
function authedForPatch(upsertError: { message: string } | null = null) {
const upsert = vi.fn().mockResolvedValue({ error: upsertError })
const supabase = { from: vi.fn(() => ({ upsert })) }
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
return { upsert }
}
function patchRequest(body: unknown) {
return new Request('http://localhost/api/user/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('GET /api/user/preferences', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await GET()
expect(res.status).toBe(401)
})
it('returns the stored preferences', async () => {
authedForGet({ hide_assistant_fab: true, auto_logout: true })
const res = await GET()
const { status, body } = await parseJsonResponse<{ data: unknown }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ hide_assistant_fab: true, auto_logout: true })
})
it('defaults to false when no preferences row exists', async () => {
authedForGet(null)
const res = await GET()
const { body } = await parseJsonResponse<{ data: unknown }>(res)
expect(body.data).toEqual({ hide_assistant_fab: false, auto_logout: false })
})
})
describe('PATCH /api/user/preferences', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await PATCH(patchRequest({ hide_assistant_fab: true }))
expect(res.status).toBe(401)
})
it('rejects an invalid body with 400', async () => {
authedForPatch()
const res = await PATCH(patchRequest({ hide_assistant_fab: 'yes' }))
expect(res.status).toBe(400)
})
it('rejects unknown keys with 400', async () => {
authedForPatch()
const res = await PATCH(patchRequest({ hide_assistant_fab: true, locale: 'en' }))
expect(res.status).toBe(400)
})
it('upserts the preference for the authenticated user', async () => {
const { upsert } = authedForPatch()
const res = await PATCH(patchRequest({ hide_assistant_fab: true }))
const { status, body } = await parseJsonResponse<{ data: unknown }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ hide_assistant_fab: true })
expect(upsert).toHaveBeenCalledWith(
{ user_id: 'user-1', hide_assistant_fab: true },
{ onConflict: 'user_id' }
)
})
it('writes a multi-field request as one atomic upsert', async () => {
const { upsert } = authedForPatch()
const res = await PATCH(
patchRequest({ hide_assistant_fab: true, auto_logout: false })
)
expect(res.status).toBe(200)
expect(upsert).toHaveBeenCalledTimes(1)
expect(upsert).toHaveBeenCalledWith(
{ user_id: 'user-1', hide_assistant_fab: true, auto_logout: false },
{ onConflict: 'user_id' }
)
})
it('rejects an empty body with 400', async () => {
authedForPatch()
const res = await PATCH(patchRequest({}))
expect(res.status).toBe(400)
})
it('upserts auto_logout and resets the session timeout cookie', async () => {
const { upsert } = authedForPatch()
const res = await PATCH(patchRequest({ auto_logout: true }))
const { status, body } = await parseJsonResponse<{ data: unknown }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ auto_logout: true })
expect(upsert).toHaveBeenCalledWith(
{ user_id: 'user-1', auto_logout: true },
{ onConflict: 'user_id' }
)
// The signed timeout cookie caches the opt-in; a change must clear it so
// the middleware re-mints with the new preference on the next request.
const setCookie = res.headers.get('set-cookie') ?? ''
expect(setCookie).toContain('gnubok-session-timeout=;')
})
it('does not touch the session timeout cookie for unrelated preferences', async () => {
authedForPatch()
const res = await PATCH(patchRequest({ hide_assistant_fab: true }))
expect(res.status).toBe(200)
expect(res.headers.get('set-cookie')).toBeNull()
})
it('returns 500 when the upsert fails', async () => {
authedForPatch({ message: 'boom' })
const res = await PATCH(patchRequest({ hide_assistant_fab: false }))
expect(res.status).toBe(500)
})
})