Files
hope-events/backend/tests/yoco.test.js
T
joshuaandClaude Opus 5.5 865a16abe9 Store Yoco secrets in the database; add Yoco settings tab
The Yoco secret key and webhook secret now live in AppSetting (AES-256-GCM
encrypted), with .env as a fallback. A new Site Settings → Yoco tab shows
them masked card-style, registers a webhook with Yoco (storing the secret it
returns) or accepts one entered by hand, and lists/deletes the account's
webhooks. Generic settings endpoints never read or write these keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:50 +02:00

100 lines
3.6 KiB
JavaScript

jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() },
}));
const prisma = require('../src/config/db');
const { encrypt } = require('../src/utils/encryption');
const settingsCache = require('../src/utils/settingsCache');
const {
maskSecret, yocoMode, getYocoSecretKey, getYocoWebhookSecret, yocoAuthHeader,
defaultWebhookUrl, webhookUrlProblem,
} = require('../src/utils/yoco');
const ENV = { ...process.env };
afterEach(() => { process.env = { ...ENV }; });
function dbHas(rows) {
prisma.appSetting.findMany.mockResolvedValue(rows);
settingsCache.invalidate();
}
describe('maskSecret', () => {
test('keeps the prefix and the last 3 characters', () => {
expect(maskSecret('sk_test_0123456789abcdefaba')).toBe('sk_test_****************aba');
expect(maskSecret('sk_live_0123456789xyz')).toBe('sk_live_**********xyz');
expect(maskSecret('whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw')).toBe(`whsec_${'*'.repeat(29)}aSw`);
});
test('masks the length exactly, like a card number', () => {
const v = 'sk_test_0123456789abcdefaba';
expect(maskSecret(v)).toHaveLength(v.length);
});
test('short values show nothing after the prefix', () => {
expect(maskSecret('sk_test_abc')).toBe('sk_test_***');
expect(maskSecret('whsec_')).toBe('whsec_');
});
test('unknown formats keep the first 4 characters', () => {
expect(maskSecret('abcd0123456789')).toBe('abcd*******789');
});
test('empty stays empty', () => {
expect(maskSecret('')).toBe('');
expect(maskSecret(undefined)).toBe('');
});
});
test('yocoMode reads the key prefix', () => {
expect(yocoMode('sk_test_x')).toBe('test');
expect(yocoMode('sk_live_x')).toBe('live');
expect(yocoMode('pk_test_x')).toBe('unknown');
expect(yocoMode('')).toBe('unknown');
});
describe('stored secrets', () => {
test('the database wins over .env, and is decrypted', async () => {
process.env.YOCO_SECRET_KEY = 'sk_test_fromenv';
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_test_fromdb') }]);
expect(await getYocoSecretKey()).toBe('sk_test_fromdb');
});
test('.env is the fallback when the database has no value', async () => {
process.env.YOCO_WEBHOOK_SECRET = 'whsec_ZW52';
dbHas([{ key: 'yoco_webhook_secret', value: '' }]);
expect(await getYocoWebhookSecret()).toBe('whsec_ZW52');
});
test('checkouts fail clearly when no key is configured', async () => {
delete process.env.YOCO_SECRET_KEY;
dbHas([]);
await expect(yocoAuthHeader()).rejects.toThrow(/not configured/);
});
test('the auth header uses the stored key', async () => {
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_live_abc') }]);
expect(await yocoAuthHeader()).toBe('Bearer sk_live_abc');
});
});
describe('webhook URL', () => {
const req = { protocol: 'https', get: () => 'events-demo-api.crosscode.co.za' };
test('built from BACKEND_URL', () => {
process.env.BACKEND_URL = 'https://api.example.com/';
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/webhooks/yoco');
});
test('falls back to the request origin', () => {
delete process.env.BACKEND_URL;
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/webhooks/yoco');
});
test('must be public https', () => {
expect(webhookUrlProblem('https://api.example.com/api/webhooks/yoco')).toBeNull();
expect(webhookUrlProblem('http://api.example.com/api/webhooks/yoco')).toMatch(/https/);
expect(webhookUrlProblem('https://localhost:5000/api/webhooks/yoco')).toMatch(/localhost/);
expect(webhookUrlProblem('not a url')).toMatch(/full webhook URL/);
});
});