import { describe, it, expect, afterEach, beforeEach } from 'vitest' import { mkdtemp, readFile, rm, readdir } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' const { rollingFallbackFilename, getWebhookFallbackConfig, writeWebhookFallback, sanitizeHeaders, } = await import('../../src/services/webhook-log.service.js') describe('webhook-log.service helpers', () => { describe('sanitizeHeaders', () => { it('redacts secret-bearing headers but keeps their names', () => { const out = sanitizeHeaders({ 'x-callback-token': 'shhhh', 'Authorization': 'Bearer abc', 'cookie': 'sid=xyz', 'content-type': 'application/json', 'x-request-id': 'req-1', }) expect(out['x-callback-token']).toBe('[REDACTED]') expect(out['authorization']).toBe('[REDACTED]') expect(out['cookie']).toBe('[REDACTED]') expect(out['content-type']).toBe('application/json') expect(out['x-request-id']).toBe('req-1') }) it('lowercases keys so lookups are consistent', () => { const out = sanitizeHeaders({ 'X-Callback-Token': 't', 'Content-Type': 'app/json' }) expect(out['x-callback-token']).toBe('[REDACTED]') expect(out['content-type']).toBe('app/json') expect(out['X-Callback-Token']).toBeUndefined() }) }) describe('rollingFallbackFilename', () => { it('produces "-YYYY-MM-DD.jsonl" using UTC day boundary', () => { const d = new Date(Date.UTC(2026, 4, 25, 10, 30)) expect(rollingFallbackFilename('xendit', d)).toBe('xendit-2026-05-25.jsonl') }) it('zero-pads single-digit months and days', () => { const d = new Date(Date.UTC(2026, 0, 3, 0, 0)) expect(rollingFallbackFilename('wh', d)).toBe('wh-2026-01-03.jsonl') }) it('a date right before UTC midnight stays on the current day', () => { const d = new Date(Date.UTC(2026, 4, 25, 23, 59, 59)) expect(rollingFallbackFilename('x', d)).toBe('x-2026-05-25.jsonl') }) }) describe('getWebhookFallbackConfig', () => { const originalEnv = { ...process.env } afterEach(() => { // Restore only the vars this suite touches — leaving the rest alone. for (const k of ['XENDIT_WEBHOOK_FALLBACK_ENABLED', 'XENDIT_WEBHOOK_FALLBACK_DIR', 'XENDIT_WEBHOOK_FALLBACK_NAME']) { if (originalEnv[k] === undefined) delete process.env[k] else process.env[k] = originalEnv[k] } }) it('defaults to disabled with sensible dir/name', () => { delete process.env.XENDIT_WEBHOOK_FALLBACK_ENABLED delete process.env.XENDIT_WEBHOOK_FALLBACK_DIR delete process.env.XENDIT_WEBHOOK_FALLBACK_NAME expect(getWebhookFallbackConfig()).toEqual({ enabled: false, dir: './logs', name: 'xendit-webhook-fallback', }) }) it('only the literal string "true" enables the sink', () => { process.env.XENDIT_WEBHOOK_FALLBACK_ENABLED = '1' expect(getWebhookFallbackConfig().enabled).toBe(false) process.env.XENDIT_WEBHOOK_FALLBACK_ENABLED = 'yes' expect(getWebhookFallbackConfig().enabled).toBe(false) process.env.XENDIT_WEBHOOK_FALLBACK_ENABLED = 'true' expect(getWebhookFallbackConfig().enabled).toBe(true) }) it('honors custom dir/name', () => { process.env.XENDIT_WEBHOOK_FALLBACK_ENABLED = 'true' process.env.XENDIT_WEBHOOK_FALLBACK_DIR = '/var/log/webhooks' process.env.XENDIT_WEBHOOK_FALLBACK_NAME = 'xendit-prod' expect(getWebhookFallbackConfig()).toEqual({ enabled: true, dir: '/var/log/webhooks', name: 'xendit-prod', }) }) }) describe('writeWebhookFallback', () => { let workDir const originalEnv = { ...process.env } beforeEach(async () => { workDir = await mkdtemp(path.join(tmpdir(), 'webhook-fallback-')) }) afterEach(async () => { for (const k of ['XENDIT_WEBHOOK_FALLBACK_ENABLED', 'XENDIT_WEBHOOK_FALLBACK_DIR', 'XENDIT_WEBHOOK_FALLBACK_NAME']) { if (originalEnv[k] === undefined) delete process.env[k] else process.env[k] = originalEnv[k] } await rm(workDir, { recursive: true, force: true }) }) it('returns false and writes nothing when disabled', async () => { delete process.env.XENDIT_WEBHOOK_FALLBACK_ENABLED const ok = await writeWebhookFallback({ provider: 'xendit', headers: { 'content-type': 'application/json' }, rawBody: { id: 'inv_x' }, callbackTokenValid: true, }) expect(ok).toBe(false) expect(await readdir(workDir)).toEqual([]) }) it('appends one JSONL line per call to the rolling daily file', async () => { process.env.XENDIT_WEBHOOK_FALLBACK_ENABLED = 'true' process.env.XENDIT_WEBHOOK_FALLBACK_DIR = workDir process.env.XENDIT_WEBHOOK_FALLBACK_NAME = 'test-fallback' await writeWebhookFallback({ provider: 'xendit', headers: { 'content-type': 'application/json', 'x-callback-token': '[REDACTED]' }, rawBody: { id: 'inv_a', external_id: 'pr-1', status: 'PAID' }, callbackTokenValid: true, dbErrorMessage: 'connection refused', }) await writeWebhookFallback({ provider: 'xendit', headers: {}, rawBody: { id: 'inv_b', status: 'EXPIRED' }, callbackTokenValid: false, }) const files = await readdir(workDir) expect(files).toHaveLength(1) expect(files[0]).toMatch(/^test-fallback-\d{4}-\d{2}-\d{2}\.jsonl$/) const contents = await readFile(path.join(workDir, files[0]), 'utf8') const lines = contents.trim().split('\n') expect(lines).toHaveLength(2) const first = JSON.parse(lines[0]) expect(first.provider).toBe('xendit') expect(first.callback_token_valid).toBe(true) expect(first.raw_body.id).toBe('inv_a') expect(first.raw_body.external_id).toBe('pr-1') expect(first.db_error).toBe('connection refused') expect(first.headers['x-callback-token']).toBe('[REDACTED]') expect(first.received_at).toMatch(/^\d{4}-\d{2}-\d{2}T/) const second = JSON.parse(lines[1]) expect(second.raw_body.id).toBe('inv_b') expect(second.callback_token_valid).toBe(false) expect(second.db_error).toBeNull() }) it('creates the destination directory if it does not exist', async () => { process.env.XENDIT_WEBHOOK_FALLBACK_ENABLED = 'true' const nested = path.join(workDir, 'does', 'not', 'exist', 'yet') process.env.XENDIT_WEBHOOK_FALLBACK_DIR = nested process.env.XENDIT_WEBHOOK_FALLBACK_NAME = 'nested' const ok = await writeWebhookFallback({ provider: 'xendit', headers: {}, rawBody: { id: 'inv_n' }, callbackTokenValid: true, }) expect(ok).toBe(true) const files = await readdir(nested) expect(files).toHaveLength(1) }) }) })