b387a77bfd
* chore: remove Sentry, consolidate migrations, add test coverage Remove @sentry/nextjs and all Sentry integration code — error tracking now handled by Recapt. Consolidate 22 incremental migrations into a single schema sync migration. Add 6 new test suites (auth, invoice matching, VAT rules, opening balances) and extend report tests with edge cases. Update Docker image name to gnubok, sync crontabs and extension presets, fix CSP missing space, simplify journal entry missing-document dialog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove viewer bank import migration never applied to production 20260413150000_viewer_bank_import_permissions.sql (PR #234) was merged to main but never applied to the production database. It references current_active_company_id() which does not exist in production either. This breaks fresh installs and Supabase preview branches because the migration runs before the consolidated schema sync. Remove it so the migration chain matches production. The viewer bank import RLS policies should be re-added in a future migration alongside the helper functions they depend on. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: correct delete policies for tables without company_id column Seven tables in the generic delete-policy loop don't have a direct company_id column, causing fresh installs to fail with "column company_id does not exist". Fix by moving them out of the loop: - invoice_items, journal_entry_lines, receipt_line_items, supplier_invoice_items → join through parent table - extension_toggles, notification_settings, push_subscriptions → user-scoped (auth.uid() = user_id) All policies match their existing production definitions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
136 lines
4.1 KiB
TypeScript
136 lines
4.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import crypto from 'crypto'
|
|
import { createAuthCode, decryptAuthCode, verifyPkce, hashAuthCode } from '../oauth-codes'
|
|
|
|
beforeEach(() => {
|
|
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'test-secret-for-oauth-tests')
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
// ============================================================
|
|
// createAuthCode + decryptAuthCode round-trip
|
|
// ============================================================
|
|
|
|
describe('createAuthCode + decryptAuthCode round-trip', () => {
|
|
it('encrypts and decrypts preserving userId, codeChallenge, redirectUri', () => {
|
|
const payload = {
|
|
userId: 'user-123',
|
|
codeChallenge: 'challenge-abc',
|
|
redirectUri: 'https://claude.ai/api/callback',
|
|
}
|
|
|
|
const code = createAuthCode(payload)
|
|
const decrypted = decryptAuthCode(code)
|
|
|
|
expect(decrypted).not.toBeNull()
|
|
expect(decrypted!.userId).toBe('user-123')
|
|
expect(decrypted!.codeChallenge).toBe('challenge-abc')
|
|
expect(decrypted!.redirectUri).toBe('https://claude.ai/api/callback')
|
|
})
|
|
|
|
it('sets exp approximately 5 minutes in future', () => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
|
|
|
|
const code = createAuthCode({
|
|
userId: 'user-1',
|
|
codeChallenge: 'ch',
|
|
redirectUri: 'http://localhost',
|
|
})
|
|
const decrypted = decryptAuthCode(code)
|
|
|
|
expect(decrypted).not.toBeNull()
|
|
// exp should be Date.now() + 5 * 60 * 1000
|
|
const expectedExp = new Date('2026-04-14T12:00:00Z').getTime() + 5 * 60 * 1000
|
|
expect(decrypted!.exp).toBe(expectedExp)
|
|
})
|
|
|
|
it('returns null for expired code', () => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
|
|
|
|
const code = createAuthCode({
|
|
userId: 'user-1',
|
|
codeChallenge: 'ch',
|
|
redirectUri: 'http://localhost',
|
|
})
|
|
|
|
// Advance past 5 minute TTL
|
|
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
|
|
|
|
const decrypted = decryptAuthCode(code)
|
|
expect(decrypted).toBeNull()
|
|
})
|
|
|
|
it('returns null for tampered ciphertext', () => {
|
|
const code = createAuthCode({
|
|
userId: 'user-1',
|
|
codeChallenge: 'ch',
|
|
redirectUri: 'http://localhost',
|
|
})
|
|
|
|
// Flip a character in the middle of the encrypted string
|
|
const chars = code.split('')
|
|
const mid = Math.floor(chars.length / 2)
|
|
chars[mid] = chars[mid] === 'A' ? 'B' : 'A'
|
|
const tampered = chars.join('')
|
|
|
|
expect(decryptAuthCode(tampered)).toBeNull()
|
|
})
|
|
|
|
it('returns null for completely invalid base64url', () => {
|
|
expect(decryptAuthCode('not-a-valid-code!!!')).toBeNull()
|
|
})
|
|
|
|
it('throws when SUPABASE_SERVICE_ROLE_KEY is not set', () => {
|
|
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', '')
|
|
|
|
expect(() =>
|
|
createAuthCode({
|
|
userId: 'user-1',
|
|
codeChallenge: 'ch',
|
|
redirectUri: 'http://localhost',
|
|
})
|
|
).toThrow('SUPABASE_SERVICE_ROLE_KEY is required')
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// verifyPkce
|
|
// ============================================================
|
|
|
|
describe('verifyPkce', () => {
|
|
it('returns true when SHA256(verifier) matches challenge', () => {
|
|
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
|
|
// Compute expected challenge using base64url(SHA-256(verifier))
|
|
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
|
|
|
|
expect(verifyPkce(verifier, challenge)).toBe(true)
|
|
})
|
|
|
|
it('returns false when verifier does not match challenge', () => {
|
|
expect(verifyPkce('correct-verifier', 'wrong-challenge')).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// hashAuthCode
|
|
// ============================================================
|
|
|
|
describe('hashAuthCode', () => {
|
|
it('returns 64-char hex string', () => {
|
|
const hash = hashAuthCode('some-auth-code')
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
})
|
|
|
|
it('is deterministic for same input', () => {
|
|
const hash1 = hashAuthCode('deterministic-code')
|
|
const hash2 = hashAuthCode('deterministic-code')
|
|
expect(hash1).toBe(hash2)
|
|
})
|
|
})
|