* feat: add option to exclude year-end closing entries in SIE export and related reports * delete docs * fix: allow Chrome's PDF viewer in verifikat document preview The /api/documents/:id/inline route shipped with `object-src 'none'` in its CSP, which blocked Chrome's built-in PDF viewer (it renders inline PDFs via an internal <embed>). Users on Chrome saw "Det här innehållet har blockerats" when expanding a PDF attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own viewer) were unaffected, and JPGs worked because <img> isn't subject to object-src. Drops the CSP for this route to the minimum needed for embeddability: `frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the fixed Content-Type from the handler already block MIME confusion; X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auth): add webmail deep link to email confirmation screens Mirrors Stripe's signup UX: after asking the user to verify their email, detect their webmail provider from the domain and show a button that opens the inbox in a new tab. Gmail gets a from:<sender> search pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly. Unknown / custom domains fall back to the existing copy. Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM (default noreply@gnubok.se) so white-label installs can match their Supabase Auth SMTP config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): unblock first-time password set for BankID users with MFA Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session is required" whenever a TOTP factor is enrolled. BankID magic-link logins produce AAL1, and middleware skips MFA enforcement for bankid_linked users, so they had no path to AAL2 — leaving them unable to set a backup password or disable MFA without going through the email-recovery escape hatch. - /api/account/password: branch on app_metadata.has_password. First-time set writes via service.auth.admin.updateUserById (no existing credential to protect, AAL2 guard does not apply). Change-password keeps the user-session updateUser so AAL2 still fires for credential rotation. - /mfa/verify: accept a safeReturnTo query param and route there after successful verify, so step-up flows can land back where they came from. - SecuritySettings: detect the AAL2 error from both change-password and mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account instead of toasting a dead-end error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add tests and rounding utility for öre precision in bokslut calculations - Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations. - Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries. - Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency. - Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies. - Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios. * fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility * fix: enhance security by rejecting data URIs in safeReturnTo function tests * fix: improve rounding logic in roundOre function and add customer_type migration * fix: add customer_type column to customers and enforce CHECK constraint --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
296 lines
9.5 KiB
TypeScript
296 lines
9.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(),
|
|
createServiceClient: vi.fn(),
|
|
}))
|
|
|
|
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
|
import { POST } from '../route'
|
|
|
|
const mockCreateClient = vi.mocked(createClient)
|
|
const mockCreateServiceClient = vi.mocked(createServiceClient)
|
|
|
|
type AuthMetadata = Record<string, unknown>
|
|
|
|
function mockUserClient(opts: {
|
|
user: { id: string; app_metadata?: AuthMetadata } | null
|
|
updateUserError?: { message: string; status?: number; code?: string } | null
|
|
}) {
|
|
const updateUser = vi.fn().mockResolvedValue({
|
|
data: {},
|
|
error: opts.updateUserError ?? null,
|
|
})
|
|
|
|
mockCreateClient.mockResolvedValue({
|
|
auth: {
|
|
getUser: vi.fn().mockResolvedValue({ data: { user: opts.user } }),
|
|
updateUser,
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any)
|
|
|
|
return { updateUser }
|
|
}
|
|
|
|
function mockService(opts: {
|
|
priorAppMetadata?: AuthMetadata
|
|
// Returned-error from admin.updateUserById when called with { password }
|
|
passwordSetError?: { message: string; status?: number; code?: string } | null
|
|
// Thrown error from admin.updateUserById when called with { app_metadata }
|
|
flagFlipError?: Error | null
|
|
}) {
|
|
const updateUserById = vi
|
|
.fn()
|
|
.mockImplementation((_id: string, args: Record<string, unknown>) => {
|
|
if ('password' in args) {
|
|
return Promise.resolve({
|
|
data: {},
|
|
error: opts.passwordSetError ?? null,
|
|
})
|
|
}
|
|
if (opts.flagFlipError) return Promise.reject(opts.flagFlipError)
|
|
return Promise.resolve({ data: {}, error: null })
|
|
})
|
|
|
|
const getUserById = vi.fn().mockResolvedValue({
|
|
data: { user: { app_metadata: opts.priorAppMetadata ?? {} } },
|
|
})
|
|
|
|
mockCreateServiceClient.mockReturnValue({
|
|
auth: { admin: { getUserById, updateUserById } },
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any)
|
|
|
|
return { getUserById, updateUserById }
|
|
}
|
|
|
|
const STRONG_PASSWORD = 'StrongP@ssword1'
|
|
|
|
function flagFlipCall(updateUserById: ReturnType<typeof vi.fn>) {
|
|
return updateUserById.mock.calls.find(
|
|
([, args]) => args && typeof args === 'object' && 'app_metadata' in args,
|
|
)
|
|
}
|
|
|
|
function passwordSetCall(updateUserById: ReturnType<typeof vi.fn>) {
|
|
return updateUserById.mock.calls.find(
|
|
([, args]) => args && typeof args === 'object' && 'password' in args,
|
|
)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('POST /api/account/password', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
mockUserClient({ user: null })
|
|
mockService({})
|
|
|
|
const req = createMockRequest('/api/account/password', {
|
|
method: 'POST',
|
|
body: { password: STRONG_PASSWORD },
|
|
})
|
|
const { status } = await parseJsonResponse(await POST(req))
|
|
expect(status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 when password is too weak', async () => {
|
|
mockUserClient({ user: { id: 'user-1', app_metadata: { has_password: true } } })
|
|
mockService({ priorAppMetadata: { has_password: true } })
|
|
|
|
const req = createMockRequest('/api/account/password', {
|
|
method: 'POST',
|
|
body: { password: 'weak' },
|
|
})
|
|
const { status } = await parseJsonResponse(await POST(req))
|
|
expect(status).toBe(400)
|
|
})
|
|
|
|
describe('first-time set (has_password !== true)', () => {
|
|
it('writes the password via admin API and flips the flag', async () => {
|
|
const { updateUser } = mockUserClient({
|
|
user: {
|
|
id: 'user-1',
|
|
app_metadata: { has_password: false, bankid_linked: true },
|
|
},
|
|
})
|
|
const { updateUserById } = mockService({
|
|
priorAppMetadata: { has_password: false, bankid_linked: true },
|
|
})
|
|
|
|
const req = createMockRequest('/api/account/password', {
|
|
method: 'POST',
|
|
body: { password: STRONG_PASSWORD },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{
|
|
data?: { ok: boolean }
|
|
}>(await POST(req))
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data?.ok).toBe(true)
|
|
// Did NOT go through the user session — that path would fail with AAL2.
|
|
expect(updateUser).not.toHaveBeenCalled()
|
|
// Password set via admin
|
|
expect(passwordSetCall(updateUserById)).toEqual([
|
|
'user-1',
|
|
{ password: STRONG_PASSWORD },
|
|
])
|
|
// Flag flipped, siblings preserved
|
|
expect(flagFlipCall(updateUserById)).toEqual([
|
|
'user-1',
|
|
{
|
|
app_metadata: {
|
|
has_password: true,
|
|
bankid_linked: true,
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
it('treats unset has_password as first-time set', async () => {
|
|
const { updateUser } = mockUserClient({
|
|
user: { id: 'user-1' /* no app_metadata */ },
|
|
})
|
|
const { updateUserById } = mockService({})
|
|
|
|
const req = createMockRequest('/api/account/password', {
|
|
method: 'POST',
|
|
body: { password: STRONG_PASSWORD },
|
|
})
|
|
const { status } = await parseJsonResponse(await POST(req))
|
|
|
|
expect(status).toBe(200)
|
|
expect(updateUser).not.toHaveBeenCalled()
|
|
expect(passwordSetCall(updateUserById)).toBeDefined()
|
|
})
|
|
|
|
it('returns 400 and skips flag flip when the admin password set fails', async () => {
|
|
const { updateUser } = mockUserClient({
|
|
user: { id: 'user-1', app_metadata: { has_password: false } },
|
|
})
|
|
const { updateUserById } = mockService({
|
|
priorAppMetadata: { has_password: false },
|
|
passwordSetError: { message: 'Password too weak', status: 400 },
|
|
})
|
|
|
|
const req = createMockRequest('/api/account/password', {
|
|
method: 'POST',
|
|
body: { password: STRONG_PASSWORD },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ error?: string }>(
|
|
await POST(req),
|
|
)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error).toContain('Password too weak')
|
|
expect(updateUser).not.toHaveBeenCalled()
|
|
expect(flagFlipCall(updateUserById)).toBeUndefined()
|
|
})
|
|
|
|
it('still returns success when the flag flip fails after admin password set', async () => {
|
|
mockUserClient({
|
|
user: { id: 'user-1', app_metadata: { has_password: false } },
|
|
})
|
|
mockService({
|
|
priorAppMetadata: { has_password: false },
|
|
flagFlipError: new Error('admin down'),
|
|
})
|
|
|
|
const req = createMockRequest('/api/account/password', {
|
|
method: 'POST',
|
|
body: { password: STRONG_PASSWORD },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{
|
|
data?: { ok: boolean }
|
|
}>(await POST(req))
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data?.ok).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('change-password (has_password === true)', () => {
|
|
it('writes via the user session so Supabase enforces AAL2', async () => {
|
|
const { updateUser } = mockUserClient({
|
|
user: { id: 'user-1', app_metadata: { has_password: true } },
|
|
})
|
|
const { updateUserById } = mockService({
|
|
priorAppMetadata: { has_password: true, provider: 'email' },
|
|
})
|
|
|
|
const req = createMockRequest('/api/account/password', {
|
|
method: 'POST',
|
|
body: { password: STRONG_PASSWORD },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{
|
|
data?: { ok: boolean }
|
|
}>(await POST(req))
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data?.ok).toBe(true)
|
|
// Used user session, NOT admin API for the password itself
|
|
expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
|
|
expect(passwordSetCall(updateUserById)).toBeUndefined()
|
|
// Flag is still flipped (idempotent) with siblings preserved
|
|
expect(flagFlipCall(updateUserById)).toEqual([
|
|
'user-1',
|
|
{
|
|
app_metadata: {
|
|
has_password: true,
|
|
provider: 'email',
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
it('returns 400 and skips flag flip when Supabase rejects the password update', async () => {
|
|
const { updateUser } = mockUserClient({
|
|
user: { id: 'user-1', app_metadata: { has_password: true } },
|
|
updateUserError: { message: 'Password too similar to old', status: 400 },
|
|
})
|
|
const { updateUserById } = mockService({
|
|
priorAppMetadata: { has_password: true },
|
|
})
|
|
|
|
const req = createMockRequest('/api/account/password', {
|
|
method: 'POST',
|
|
body: { password: STRONG_PASSWORD },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ error?: string }>(
|
|
await POST(req),
|
|
)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error).toContain('Password too similar')
|
|
expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
|
|
expect(flagFlipCall(updateUserById)).toBeUndefined()
|
|
})
|
|
|
|
it('surfaces the AAL2 error verbatim so the client can step up via /mfa/verify', async () => {
|
|
mockUserClient({
|
|
user: { id: 'user-1', app_metadata: { has_password: true } },
|
|
updateUserError: {
|
|
message:
|
|
'AAL2 session is required to update email or password when MFA is enabled',
|
|
status: 422,
|
|
},
|
|
})
|
|
mockService({ priorAppMetadata: { has_password: true } })
|
|
|
|
const req = createMockRequest('/api/account/password', {
|
|
method: 'POST',
|
|
body: { password: STRONG_PASSWORD },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ error?: string }>(
|
|
await POST(req),
|
|
)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error).toContain('AAL2')
|
|
})
|
|
})
|
|
})
|