# -*- coding: utf-8 -*- import hashlib import hmac import json from unittest.mock import patch from odoo.exceptions import ValidationError from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestWebhookEngine(TransactionCase): def setUp(self): super().setUp() self.service = self.env['fusion.billing.service'].sudo().create({ 'name': 'NexaCloud', 'code': 'nexacloud', 'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook', 'webhook_secret': 'whsec_test', }) self.Webhook = self.env['fusion.billing.webhook'].sudo() def test_enqueue_signs_payload(self): wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-1'}) self.assertEqual(wh.state, 'pending') body = json.dumps({'invoice': 'INV-1'}, sort_keys=True, separators=(',', ':')) expected = hmac.new(b'whsec_test', body.encode(), hashlib.sha256).hexdigest() self.assertEqual(wh.signature, expected) def test_dispatch_marks_sent_on_2xx(self): wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-2'}) class _Resp: status_code = 200 text = 'ok' with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', return_value=_Resp()) as mock_post: self.Webhook._cron_dispatch() self.assertTrue(mock_post.called) self.assertEqual(wh.state, 'sent') def test_dispatch_retries_then_deadletters(self): wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-3'}) wh.write({'attempts': 7}) # already past max class _Resp: status_code = 500 text = 'err' with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', return_value=_Resp()): self.Webhook._cron_dispatch() self.assertEqual(wh.state, 'dead') # ── item 8 (H5): dispatch POSTs the stored body verbatim + event-id header ── def test_dispatch_posts_stored_body_and_event_id(self): wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-9'}) class _Resp: status_code = 200 text = 'ok' with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', return_value=_Resp()) as mock_post: self.Webhook._cron_dispatch() self.assertTrue(mock_post.called) _args, kwargs = mock_post.call_args # the exact stored body is POSTed (not a re-serialized payload) self.assertEqual(kwargs['data'], wh.body) self.assertEqual(wh.body, json.dumps( {'invoice': 'INV-9'}, sort_keys=True, separators=(',', ':'))) # signature matches the bytes on the wire expected = hmac.new(b'whsec_test', wh.body.encode(), hashlib.sha256).hexdigest() self.assertEqual(kwargs['headers']['X-Fusion-Signature'], expected) # event id header present and correct self.assertEqual(kwargs['headers']['X-Fusion-Event-Id'], str(wh.id)) # ── item 9 (H6): SSRF guard on webhook_url ── def test_webhook_url_rejects_loopback(self): with self.assertRaises(ValidationError): self.env['fusion.billing.service'].sudo().create({ 'name': 'Evil', 'code': 'evil', 'webhook_url': 'http://127.0.0.1/x'}) def test_webhook_url_rejects_private_and_http(self): for bad in ('http://10.0.0.5/hook', # private + non-https 'https://192.168.1.10/hook', # private 'https://localhost/hook', # localhost host 'https://169.254.169.254/latest', # link-local metadata 'http://api.example.com/hook'): # non-https with self.assertRaises(ValidationError): self.env['fusion.billing.service'].sudo().create({ 'name': 'Bad', 'code': 'bad-%s' % bad, 'webhook_url': bad}) def test_webhook_url_allows_public_https(self): svc = self.env['fusion.billing.service'].sudo().create({ 'name': 'Good', 'code': 'good', 'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook'}) self.assertTrue(svc.id)