← back to Costa Rica

test/payments.test.js

122 lines

'use strict';
// Payment-adapter sandbox + webhook-signature tests (node:test), zero deps.
// Run: node --test  (node runs each test file in its own process, so setting
// *_WEBHOOK_SECRET here does not leak into the other test files).
//
// Env is set BEFORE requiring the adapters: they read WEBHOOK_SECRET at module
// load. Setting ONLY the webhook secret keeps liveMode=false (live needs the
// API user/pass/key), so these exercise the sandbox charge paths AND the real
// HMAC verify branch in one process — no live creds, no real network, no money.
process.env.TILOPAY_WEBHOOK_SECRET = 'whsec_test_tilopay';
process.env.ONVO_WEBHOOK_SECRET = 'whsec_test_onvo';

const { test } = require('node:test');
const assert = require('node:assert');
const crypto = require('crypto');

const tilopay = require('../lib/payments/tilopay');
const onvo = require('../lib/payments/onvo');

const CHARGE = { amount: 12000, currency: 'USD', booking: { code: 'BK-1' },
  customer: { email: 'a@b.co', name: 'A' }, returnUrl: 'https://x.test/return' };

for (const p of [tilopay, onvo]) {
  test(`${p.name}: sandbox even with a webhook secret (no live API creds)`, () => {
    assert.equal(p.liveMode, false);
  });

  test(`${p.name}: createCharge card -> redirect clientAction with a ref`, async () => {
    const r = await p.createCharge({ ...CHARGE, method: 'card' });
    assert.equal(r.status, 'requires_action');
    assert.equal(r.clientAction.type, 'redirect');
    assert.ok(r.providerRef, 'has a providerRef');
    assert.ok(r.clientAction.url.startsWith(CHARGE.returnUrl), 'redirect points at returnUrl');
    assert.match(r.clientAction.url, /result=success/);
    assert.equal(r.raw.sandbox, true);
  });

  test(`${p.name}: createCharge sinpe -> sinpe_instructions with a phone`, async () => {
    const r = await p.createCharge({ ...CHARGE, method: 'sinpe' });
    assert.equal(r.clientAction.type, 'sinpe_instructions');
    assert.ok(r.clientAction.sinpe_phone, 'has a SINPE phone');
  });

  test(`${p.name}: getCharge on the sandbox ref reads succeeded`, async () => {
    const c = await p.createCharge({ ...CHARGE, method: 'card' });
    const g = await p.getCharge(c.providerRef);
    assert.equal(g.status, 'succeeded'); // sandbox refs contain _sbx_
  });

  test(`${p.name}: getCharge on an unknown ref stays processing (not succeeded)`, async () => {
    const g = await p.getCharge('unknown-ref-000');
    assert.equal(g.status, 'processing');
  });

  test(`${p.name}: refund (sandbox) -> refunded`, async () => {
    const r = await p.refund('some-ref', 100);
    assert.equal(r.status, 'refunded');
  });

  test(`${p.name}: verifyWebhook accepts a correctly-signed body, rejects tamper`, () => {
    const secret = p.name === 'tilopay' ? process.env.TILOPAY_WEBHOOK_SECRET : process.env.ONVO_WEBHOOK_SECRET;
    const sigHeader = p.name === 'tilopay' ? 'x-tilopay-signature' : 'onvo-signature';
    const body = JSON.stringify({ event: 'payment.succeeded', id: 'pay_1' });
    const good = crypto.createHmac('sha256', secret).update(body).digest('hex');

    const ok = p.verifyWebhook({ [sigHeader]: good }, body);
    assert.equal(ok.ok, true, 'valid signature accepted');
    assert.equal(ok.event.id, 'pay_1', 'event parsed on accept');

    // Tampered body, same signature -> reject, no event leaked.
    const bad = p.verifyWebhook({ [sigHeader]: good }, body + ' ');
    assert.equal(bad.ok, false);
    assert.equal(bad.event, null);

    // Wrong-length signature must NOT throw (timingSafeEqual guard) -> reject.
    const shortSig = p.verifyWebhook({ [sigHeader]: 'abc' }, body);
    assert.equal(shortSig.ok, false);

    // Missing signature header -> reject.
    const noSig = p.verifyWebhook({}, body);
    assert.equal(noSig.ok, false);
  });
}

test('tilopay: payout (sandbox SINPE) returns a ref and processes', async () => {
  const r = await tilopay.payout({ method: { sinpe_phone: '8888-0000' }, amount: 5000, currency: 'CRC', reference: 'PO-1' });
  assert.ok(r.providerRef);
  assert.equal(r.status, 'processing');
});

test('tilopay.mapStatus: terminal-failure + terminal-other statuses resolve correctly (shared by createCharge + getCharge)', () => {
  // Cody gate, cycle 7: createCharge used to only check j.status==='success',
  // falling declined/etc through to 'processing' — a synchronous decline (HTTP
  // 200 body) stranded the booking, mirroring the ONVO fallthrough bug above.
  // Hoisted so both createCharge and getCharge recognize the same vocabulary.
  assert.equal(tilopay.mapStatus('success'), 'succeeded');
  assert.equal(tilopay.mapStatus('pending'), 'processing');
  assert.equal(tilopay.mapStatus('declined'), 'failed');
  assert.equal(tilopay.mapStatus('reversed'), 'refunded');
  assert.equal(tilopay.mapStatus('some_unknown_status'), 'processing'); // safe default
});

test('onvo.mapStatus: terminal-failure statuses resolve to failed (not processing)', () => {
  // Regression: a live declined/failed intent used to fall through to
  // 'processing' and trap the booking forever (Tilopay mapped declined->failed
  // but ONVO did not). Failure statuses must be terminal.
  assert.equal(onvo.mapStatus('succeeded'), 'succeeded');
  assert.equal(onvo.mapStatus('processing'), 'processing');
  assert.equal(onvo.mapStatus('requires_action'), 'processing');
  for (const s of ['canceled', 'declined', 'failed', 'requires_payment_method']) {
    assert.equal(onvo.mapStatus(s), 'failed', `${s} must map to failed`);
  }
  assert.equal(onvo.mapStatus('some_unknown_status'), 'processing'); // safe default
});

test('payments registry: unknown provider throws, known ones resolve', () => {
  const { getProvider } = require('../lib/payments');
  assert.equal(getProvider('tilopay').name, 'tilopay');
  assert.equal(getProvider('onvo').name, 'onvo');
  assert.throws(() => getProvider('stripe'), /unknown payment provider/);
});