chore: remove Sentry, consolidate migrations, add test coverage (#244)

* 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>
This commit is contained in:
Jakob Wennberg
2026-04-15 10:52:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a3fea6fb7c
commit b387a77bfd
62 changed files with 2461 additions and 5486 deletions
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(),
}))
import {
generateApiKey,
hashApiKey,
extractBearerToken,
validateScopes,
hasScope,
validateApiKey,
DEFAULT_SCOPES,
} from '../api-keys'
import { createClient } from '@supabase/supabase-js'
const mockCreateClient = vi.mocked(createClient)
beforeEach(() => {
vi.clearAllMocks()
})
// ============================================================
// generateApiKey
// ============================================================
describe('generateApiKey', () => {
it('returns key starting with "gnubok_sk_"', () => {
const { key } = generateApiKey()
expect(key.startsWith('gnubok_sk_')).toBe(true)
})
it('returns 64-char hex SHA-256 hash', () => {
const { hash } = generateApiKey()
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('returns prefix of KEY_PREFIX + 8 chars', () => {
const { key, prefix } = generateApiKey()
expect(prefix).toBe(key.slice(0, 'gnubok_sk_'.length + 8))
})
it('generates unique keys on successive calls', () => {
const a = generateApiKey()
const b = generateApiKey()
expect(a.key).not.toBe(b.key)
expect(a.hash).not.toBe(b.hash)
})
it('hash matches hashApiKey(key)', () => {
const { key, hash } = generateApiKey()
expect(hashApiKey(key)).toBe(hash)
})
})
// ============================================================
// hashApiKey
// ============================================================
describe('hashApiKey', () => {
it('returns 64-char hex string', () => {
const hash = hashApiKey('gnubok_sk_test-key')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashApiKey('gnubok_sk_deterministic')
const hash2 = hashApiKey('gnubok_sk_deterministic')
expect(hash1).toBe(hash2)
})
it('produces different hashes for different inputs', () => {
const hash1 = hashApiKey('gnubok_sk_key-a')
const hash2 = hashApiKey('gnubok_sk_key-b')
expect(hash1).not.toBe(hash2)
})
})
// ============================================================
// extractBearerToken
// ============================================================
describe('extractBearerToken', () => {
it('extracts token from valid Bearer header', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Bearer my-secret-token' },
})
expect(extractBearerToken(request)).toBe('my-secret-token')
})
it('returns null when no authorization header', () => {
const request = new Request('http://localhost')
expect(extractBearerToken(request)).toBeNull()
})
it('returns null when header is not Bearer scheme', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Basic dXNlcjpwYXNz' },
})
expect(extractBearerToken(request)).toBeNull()
})
it('handles token with special characters', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Bearer gnubok_sk_abc+def/ghi=jkl' },
})
expect(extractBearerToken(request)).toBe('gnubok_sk_abc+def/ghi=jkl')
})
})
// ============================================================
// validateScopes
// ============================================================
describe('validateScopes', () => {
it('returns null for null input', () => {
expect(validateScopes(null)).toBeNull()
})
it('returns null for undefined input', () => {
expect(validateScopes(undefined)).toBeNull()
})
it('returns null for non-array input', () => {
expect(validateScopes('transactions:read')).toBeNull()
expect(validateScopes(42)).toBeNull()
expect(validateScopes({ scope: 'transactions:read' })).toBeNull()
})
it('filters to only valid API_KEY_SCOPES', () => {
const result = validateScopes(['transactions:read', 'invalid:scope', 'reports:read'])
expect(result).toEqual(['transactions:read', 'reports:read'])
})
it('returns null when no valid scopes remain after filter', () => {
expect(validateScopes(['invalid:scope', 'also:invalid'])).toBeNull()
})
it('preserves valid scopes from mixed input', () => {
const result = validateScopes(['customers:write', 'bogus', 'invoices:read'])
expect(result).toEqual(['customers:write', 'invoices:read'])
})
})
// ============================================================
// hasScope
// ============================================================
describe('hasScope', () => {
it('returns true when scope present in array', () => {
expect(hasScope(['transactions:read', 'reports:read'], 'transactions:read')).toBe(true)
})
it('returns false when scope absent', () => {
expect(hasScope(['transactions:read', 'reports:read'], 'invoices:write')).toBe(false)
})
})
// ============================================================
// validateApiKey
// ============================================================
describe('validateApiKey', () => {
function setupMockRpc(response: { data: unknown; error: unknown }) {
const mockRpc = vi.fn().mockResolvedValue(response)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCreateClient.mockReturnValue({ rpc: mockRpc } as any)
}
it('rejects keys not starting with "gnubok_sk_"', async () => {
const result = await validateApiKey('invalid-key-format')
expect(result).toEqual({ error: 'Invalid API key format', status: 401 })
})
it('rejects when RPC returns error', async () => {
setupMockRpc({ data: null, error: { message: 'db error' } })
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
})
it('rejects when RPC returns empty data array', async () => {
setupMockRpc({ data: [], error: null })
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
})
it('returns rate limit error when rate_limited is true', async () => {
setupMockRpc({
data: [{ user_id: 'u1', company_id: 'c1', scopes: null, rate_limited: true }],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Rate limit exceeded', status: 429 })
})
it('returns userId, companyId, scopes on success', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: ['transactions:read', 'reports:read'],
rate_limited: false,
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
scopes: ['transactions:read', 'reports:read'],
})
})
it('falls back to DEFAULT_SCOPES when row.scopes is null', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: null,
rate_limited: false,
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
scopes: DEFAULT_SCOPES,
})
})
})
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { generateInviteToken, hashInviteToken, getInviteExpiry } from '../invite-tokens'
describe('generateInviteToken', () => {
it('returns token starting with "gnubok_inv_"', () => {
const { token } = generateInviteToken()
expect(token.startsWith('gnubok_inv_')).toBe(true)
})
it('returns a 64-char hex SHA-256 hash', () => {
const { hash } = generateInviteToken()
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('hash matches hashInviteToken(token)', () => {
const { token, hash } = generateInviteToken()
expect(hashInviteToken(token)).toBe(hash)
})
it('generates unique tokens on successive calls', () => {
const a = generateInviteToken()
const b = generateInviteToken()
expect(a.token).not.toBe(b.token)
expect(a.hash).not.toBe(b.hash)
})
})
describe('hashInviteToken', () => {
it('returns 64-char hex string', () => {
const hash = hashInviteToken('gnubok_inv_test-token')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashInviteToken('gnubok_inv_deterministic')
const hash2 = hashInviteToken('gnubok_inv_deterministic')
expect(hash1).toBe(hash2)
})
})
describe('getInviteExpiry', () => {
afterEach(() => {
vi.useRealTimers()
})
it('returns a Date exactly 7 days in the future', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
const expiry = getInviteExpiry()
expect(expiry.toISOString()).toBe('2026-04-21T12:00:00.000Z')
})
})
+135
View File
@@ -0,0 +1,135 @@
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)
})
})