SHKeeper
This commit is contained in:
@@ -8,6 +8,7 @@ from django.test import TransactionTestCase, override_settings
|
||||
|
||||
from members.billing import sync_account
|
||||
from members.models import Account, ManagedGroup
|
||||
from members.models import CryptoInvoice
|
||||
|
||||
|
||||
@skipUnless(connection.vendor == "postgresql", "Requires real PostgreSQL row locks")
|
||||
@@ -53,3 +54,27 @@ class PostgreSQLLockTests(TransactionTestCase):
|
||||
# A later failed external side effect rolls back its own transaction,
|
||||
# not the previously committed record of the managed group.
|
||||
self.assertEqual(ManagedGroup.objects.get(account=self.account).dn, "cn=paid,dc=example,dc=org")
|
||||
|
||||
@override_settings(SHKEEPER_ENABLED=True, MEMBERSHIP_TIERS={"test": {"group_dn": "cn=paid,dc=example,dc=org"}})
|
||||
def test_concurrent_crypto_verification_credits_one_month(self):
|
||||
invoice = CryptoInvoice.objects.create(account=self.account, tier="test", usd_amount="5.00", crypto="BTC", provider_id="61", wallet="test-wallet", crypto_amount="0.0001", status="UNPAID")
|
||||
data = {"status": "success", "invoices": [{"external_id": f"www-membership-{invoice.pk}", "fiat": "USD", "amount_fiat": "5.00", "balance_fiat": "5.00", "status": "PAID", "txs": [{"addr": "test-wallet", "crypto": "BTC", "status": "CONFIRMED", "txid": "tx-confirmed", "amount": "0.0001"}]}]}
|
||||
|
||||
def verify():
|
||||
close_old_connections()
|
||||
try:
|
||||
return sync_account(self.account.pk)
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
from members.shkeeper import add_month
|
||||
|
||||
with patch("members.shkeeper.request", return_value=data), patch("members.billing.reconcile_groups", return_value=self.account.ldap_dn):
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda _: verify(), range(2)))
|
||||
self.assertTrue(all(results))
|
||||
invoice.refresh_from_db()
|
||||
self.assertIsNotNone(invoice.credited_at)
|
||||
assert invoice.access_start is not None
|
||||
self.assertEqual(invoice.access_end, add_month(invoice.access_start))
|
||||
self.assertEqual(CryptoInvoice.objects.count(), 1)
|
||||
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.test import Client, SimpleTestCase, TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from members import billing, shkeeper
|
||||
from members.models import Account, CryptoInvoice, ManagedGroup, Subscription
|
||||
from .test_membership import DN, GROUP, OTHER_GROUP, TIERS, account, paged
|
||||
|
||||
CRYPTO_TIERS = {key: {**value, "usd_amount": "5.00" if key == "supporter" else "15.00"} for key, value in TIERS.items()}
|
||||
SHKEEPER_SETTINGS = {
|
||||
"SHKEEPER_ENABLED": True, "SHKEEPER_URL": "https://pay.example.org",
|
||||
"SHKEEPER_API_KEY": "test-only-key", "SHKEEPER_CA_CERT_FILE": "",
|
||||
"SHKEEPER_CURRENCIES": ["BTC", "XMR"], "MEMBERSHIP_TIERS": CRYPTO_TIERS,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(**SHKEEPER_SETTINGS)
|
||||
class SHKeeperTests(TestCase):
|
||||
def setUp(self):
|
||||
self.member = account()
|
||||
session = self.client.session
|
||||
session["account_id"] = str(self.member.pk)
|
||||
session.save()
|
||||
|
||||
def invoice(self, **kwargs):
|
||||
return CryptoInvoice.objects.create(account=self.member, tier="supporter", usd_amount="5.00", crypto="BTC", **kwargs)
|
||||
|
||||
def creation(self):
|
||||
return {"status": "success", "id": 61, "wallet": "bc1-test-wallet", "amount": "0.0001", "recalculate_after": 0}
|
||||
|
||||
def remote(self, invoice, **changes):
|
||||
return {"status": "success", "invoices": [{
|
||||
"external_id": shkeeper.external_id(invoice), "fiat": "USD",
|
||||
"amount_fiat": "5.00", "balance_fiat": "5.00", "status": "PAID",
|
||||
"txs": [{"status": "CONFIRMED", "addr": "bc1-test-wallet", "crypto": "BTC", "amount": "0.0001", "txid": "confirmed-tx"}], **changes,
|
||||
}]}
|
||||
|
||||
def prepared(self):
|
||||
return self.invoice(provider_id="61", wallet="bc1-test-wallet", crypto_amount="0.0001", status="UNPAID")
|
||||
|
||||
def login_other(self):
|
||||
other = Account.objects.create(uid="bob", email="bob@example.org", ldap_dn="uid=bob,dc=example", ldap_uuid="uuid-bob")
|
||||
session = self.client.session
|
||||
session["account_id"] = str(other.pk)
|
||||
session.save()
|
||||
|
||||
def test_crypto_buttons_only_when_configured(self):
|
||||
self.assertContains(self.client.get("/members/"), "Pay with crypto", count=2)
|
||||
with override_settings(SHKEEPER_ENABLED=False):
|
||||
self.assertNotContains(self.client.get("/members/"), "Pay with crypto")
|
||||
self.assertEqual(self.client.get("/members/crypto/checkout/supporter/").status_code, 404)
|
||||
|
||||
def test_checkout_form_csrf_login_and_currency_allowlist(self):
|
||||
self.assertContains(self.client.get("/members/crypto/checkout/supporter/"), "not an automatically renewing")
|
||||
self.assertEqual(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "../../evil"}).status_code, 400)
|
||||
self.assertFalse(CryptoInvoice.objects.exists())
|
||||
strict = Client(enforce_csrf_checks=True)
|
||||
self.assertEqual(strict.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}).status_code, 403)
|
||||
self.client.post("/members/logout/")
|
||||
self.assertRedirects(self.client.get("/members/crypto/checkout/supporter/"), "/members/")
|
||||
|
||||
@patch("members.shkeeper.request")
|
||||
def test_checkout_uses_server_price_and_stable_uuid(self, request):
|
||||
request.return_value = self.creation()
|
||||
response = self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC", "usd_amount": "0.01", "account": "victim"})
|
||||
invoice = CryptoInvoice.objects.get()
|
||||
self.assertRedirects(response, f"/members/crypto/invoice/{invoice.pk}/")
|
||||
self.assertEqual(invoice.usd_amount, Decimal("5.00"))
|
||||
self.assertEqual(invoice.account, self.member)
|
||||
method, path, params = request.call_args.args
|
||||
self.assertEqual((method, path), ("POST", "/api/v1/BTC/payment_request"))
|
||||
self.assertEqual(params["amount"], "5.00")
|
||||
self.assertEqual(params["external_id"], shkeeper.external_id(invoice))
|
||||
self.assertEqual(params["callback_url"], settings.PUBLIC_URL + "/members/shkeeper/callback/")
|
||||
self.assertIsNone(invoice.credited_at)
|
||||
self.assertEqual(ManagedGroup.objects.count(), 2)
|
||||
# Another crypto/tier request reopens the previous immutable order.
|
||||
self.assertRedirects(self.client.post("/members/crypto/checkout/patron/", {"crypto": "XMR"}), f"/members/crypto/invoice/{invoice.pk}/")
|
||||
self.assertEqual(request.call_count, 1)
|
||||
self.assertEqual(CryptoInvoice.objects.count(), 1)
|
||||
|
||||
@patch("members.shkeeper.request", side_effect=OSError("timeout"))
|
||||
def test_creation_timeout_keeps_reserved_order_for_worker(self, request):
|
||||
self.assertContains(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}), "checkout unavailable")
|
||||
invoice = CryptoInvoice.objects.get()
|
||||
self.assertEqual(invoice.status, "creating")
|
||||
first_id = request.call_args.args[2]["external_id"]
|
||||
request.side_effect = None
|
||||
request.return_value = self.creation()
|
||||
shkeeper.prepare_invoice(self.member.pk, invoice.pk)
|
||||
self.assertEqual(request.call_args.args[2]["external_id"], first_id)
|
||||
|
||||
def test_only_one_uncredited_order_constraint(self):
|
||||
self.invoice()
|
||||
with self.assertRaises(IntegrityError), transaction.atomic():
|
||||
self.invoice()
|
||||
|
||||
def test_invoice_is_account_scoped_and_get_never_calls_provider(self):
|
||||
invoice = self.prepared()
|
||||
with patch("members.shkeeper.request") as request:
|
||||
self.assertContains(self.client.get(f"/members/crypto/invoice/{invoice.pk}/"), "bc1-test-wallet")
|
||||
request.assert_not_called()
|
||||
self.login_other()
|
||||
self.assertEqual(self.client.get(f"/members/crypto/invoice/{invoice.pk}/").status_code, 404)
|
||||
|
||||
@patch("members.shkeeper.request")
|
||||
def test_spoofed_callback_cannot_grant_or_trigger_network(self, request):
|
||||
invoice = self.prepared()
|
||||
for _ in range(3):
|
||||
self.assertEqual(self.client.post("/members/shkeeper/callback/", json.dumps({"external_id": shkeeper.external_id(invoice), "paid": True, "status": "PAID", "balance_fiat": "999999"}), content_type="application/json").status_code, 200)
|
||||
request.assert_not_called()
|
||||
invoice.refresh_from_db()
|
||||
self.assertIsNone(invoice.credited_at)
|
||||
self.assertEqual(invoice.status, "UNPAID")
|
||||
self.assertEqual(self.client.get("/members/shkeeper/callback/").status_code, 405)
|
||||
|
||||
@patch("members.shkeeper.request")
|
||||
def test_canonical_paid_once_repeated_callbacks_do_not_extend(self, request):
|
||||
invoice = self.prepared()
|
||||
request.return_value = self.remote(invoice)
|
||||
with transaction.atomic():
|
||||
shkeeper.refresh_invoice(invoice)
|
||||
first_end = invoice.access_end
|
||||
self.assertEqual(invoice.access_end, shkeeper.add_month(invoice.access_start))
|
||||
shkeeper.refresh_invoice(invoice)
|
||||
self.assertEqual(invoice.access_end, first_end)
|
||||
self.assertEqual(invoice.status, "PAID")
|
||||
|
||||
@patch("members.shkeeper.request")
|
||||
def test_partial_unpaid_unconfirmed_underpaid_cannot_grant(self, request):
|
||||
invoice = self.prepared()
|
||||
for changes in ({"status": "UNPAID", "balance_fiat": "0"}, {"status": "PARTIAL", "balance_fiat": "1"}, {"balance_fiat": "4.99"}, {"txs": []}, {"txs": [{"status": "UNCONFIRMED"}]}, {"txs": [{"status": "CONFIRMED", "addr": "wrong", "crypto": "BTC", "txid": "x"}]}):
|
||||
request.return_value = self.remote(invoice, **changes)
|
||||
shkeeper.refresh_invoice(invoice)
|
||||
self.assertIsNone(invoice.credited_at)
|
||||
|
||||
@patch("members.shkeeper.request")
|
||||
def test_mismatched_invalid_amount_or_ambiguous_data_rejected(self, request):
|
||||
invoice = self.prepared()
|
||||
for changes in ({"external_id": "another-order"}, {"fiat": "EUR"}, {"amount_fiat": "0.01"}, {"amount_fiat": "NaN"}, {"balance_fiat": "Infinity"}, {"status": "unknown"}):
|
||||
request.return_value = self.remote(invoice, **changes)
|
||||
with self.assertRaises(ValueError):
|
||||
shkeeper.refresh_invoice(invoice)
|
||||
data = self.remote(invoice)
|
||||
data["invoices"] *= 2
|
||||
request.return_value = data
|
||||
with self.assertRaises(ValueError):
|
||||
shkeeper.refresh_invoice(invoice)
|
||||
invoice.refresh_from_db()
|
||||
self.assertIsNone(invoice.credited_at)
|
||||
|
||||
@patch("members.shkeeper.request")
|
||||
def test_renewal_stacks_calendar_months_and_overpayment_once(self, request):
|
||||
first = self.prepared()
|
||||
request.return_value = self.remote(first)
|
||||
shkeeper.refresh_invoice(first)
|
||||
second = self.prepared()
|
||||
request.return_value = self.remote(second, status="OVERPAID", balance_fiat="50")
|
||||
shkeeper.refresh_invoice(second)
|
||||
self.assertEqual(second.access_start, first.access_end)
|
||||
self.assertEqual(second.access_end, shkeeper.add_month(first.access_end))
|
||||
|
||||
@patch("members.billing.reconcile_groups", return_value=DN)
|
||||
@patch("members.shkeeper.request")
|
||||
def test_paid_crypto_grant_expiry_and_outage(self, request, ldap):
|
||||
invoice = self.prepared()
|
||||
request.return_value = self.remote(invoice)
|
||||
self.assertTrue(billing.sync_account(self.member.pk))
|
||||
self.assertEqual(ldap.call_args.args[1], {GROUP})
|
||||
request.side_effect = OSError("SHKeeper offline")
|
||||
self.assertFalse(billing.sync_account(self.member.pk))
|
||||
self.assertEqual(ldap.call_args.args[1], {GROUP})
|
||||
CryptoInvoice.objects.update(access_end=timezone.now() - timedelta(seconds=1))
|
||||
billing.sync_account(self.member.pk, refresh_stripe=False)
|
||||
self.assertEqual(ldap.call_args.args[1], set())
|
||||
|
||||
@patch("members.billing.reconcile_groups", return_value=DN)
|
||||
@patch("members.shkeeper.request")
|
||||
def test_reorg_revokes_and_recovery_does_not_restart_month(self, request, ldap):
|
||||
invoice = self.prepared()
|
||||
request.return_value = self.remote(invoice)
|
||||
billing.sync_account(self.member.pk)
|
||||
invoice.refresh_from_db()
|
||||
original_end = invoice.access_end
|
||||
request.return_value = self.remote(invoice, status="UNPAID", balance_fiat="0", txs=[])
|
||||
billing.sync_account(self.member.pk)
|
||||
self.assertEqual(ldap.call_args.args[1], set())
|
||||
request.return_value = self.remote(invoice)
|
||||
billing.sync_account(self.member.pk)
|
||||
invoice.refresh_from_db()
|
||||
self.assertEqual(invoice.access_end, original_end)
|
||||
self.assertEqual(ldap.call_args.args[1], {GROUP})
|
||||
|
||||
@patch("members.billing.reconcile_groups", return_value=DN)
|
||||
@patch("members.billing.refresh_billing", side_effect=OSError("Stripe offline"))
|
||||
@patch("members.shkeeper.request")
|
||||
def test_stripe_failure_does_not_prevent_crypto_and_union(self, request, stripe, ldap):
|
||||
invoice = self.prepared()
|
||||
request.return_value = self.remote(invoice)
|
||||
Subscription.objects.create(account=self.member, stripe_id="sub_other", tier="patron", status="active", paid_until=timezone.now() + timedelta(days=1))
|
||||
self.assertFalse(billing.sync_account(self.member.pk))
|
||||
self.assertEqual(ldap.call_args.args[1], {GROUP, OTHER_GROUP})
|
||||
with override_settings(MEMBERSHIP_TIERS={}):
|
||||
billing.sync_account(self.member.pk, refresh_stripe=False)
|
||||
self.assertEqual(ldap.call_args.args[1], set())
|
||||
self.assertIn(GROUP, ldap.call_args.args[2])
|
||||
|
||||
@patch("members.shkeeper.request")
|
||||
@patch("members.views.billing.client")
|
||||
def test_active_stripe_and_open_checkout_block_crypto(self, stripe, request):
|
||||
self.member.stripe_customer = "cus_alice"
|
||||
self.member.save()
|
||||
stripe.return_value.subscriptions.list.return_value = paged([{"status": "active"}])
|
||||
self.assertContains(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}), "Recurring membership already exists")
|
||||
stripe.return_value.subscriptions.list.return_value = paged([])
|
||||
Account.objects.filter(pk=self.member.pk).update(checkout_id="cs_open")
|
||||
stripe.return_value.checkout.sessions.retrieve.return_value = {"status": "open"}
|
||||
self.assertContains(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}), "Stripe checkout is still open")
|
||||
request.assert_not_called()
|
||||
self.assertFalse(CryptoInvoice.objects.exists())
|
||||
|
||||
@patch("members.views.billing.client")
|
||||
def test_unfinished_or_active_crypto_blocks_stripe(self, stripe):
|
||||
invoice = self.prepared()
|
||||
self.assertContains(self.client.post("/members/checkout/supporter/"), "Prepaid membership already exists")
|
||||
stripe.assert_not_called()
|
||||
CryptoInvoice.objects.filter(pk=invoice.pk).update(credited_at=timezone.now(), access_end=timezone.now() + timedelta(days=1))
|
||||
self.assertContains(self.client.post("/members/crypto/checkout/patron/", {"crypto": "BTC"}), "Another prepaid tier is active")
|
||||
|
||||
|
||||
@override_settings(**SHKEEPER_SETTINGS)
|
||||
class SHKeeperTransportTests(SimpleTestCase):
|
||||
@patch("members.shkeeper.requests.request")
|
||||
def test_api_auth_tls_timeout_no_redirects_and_limits(self, request):
|
||||
response = request.return_value.__enter__.return_value
|
||||
response.status_code = 200
|
||||
response.iter_content.return_value = [b'{"status":"success","invoices":[]}']
|
||||
self.assertEqual(shkeeper.request("GET", "/api/v1/invoices/order")["invoices"], [])
|
||||
self.assertEqual(request.call_args.args[:2], ("GET", "https://pay.example.org/api/v1/invoices/order"))
|
||||
self.assertEqual(request.call_args.kwargs["headers"]["X-Shkeeper-Api-Key"], "test-only-key")
|
||||
self.assertIs(request.call_args.kwargs["verify"], True)
|
||||
self.assertFalse(request.call_args.kwargs["allow_redirects"])
|
||||
self.assertEqual(request.call_args.kwargs["timeout"], (5, 15))
|
||||
response.status_code = 302
|
||||
with self.assertRaises(ValueError):
|
||||
shkeeper.request("GET", "/api/v1/invoices/order")
|
||||
response.status_code = 200
|
||||
response.iter_content.return_value = [b"x" * 1048577]
|
||||
with self.assertRaises(ValueError):
|
||||
shkeeper.request("GET", "/api/v1/invoices/order")
|
||||
|
||||
def test_calendar_month_handles_leap_year_and_december(self):
|
||||
january = datetime(2028, 1, 31, tzinfo=dt_timezone.utc)
|
||||
self.assertEqual(shkeeper.add_month(january), datetime(2028, 2, 29, tzinfo=dt_timezone.utc))
|
||||
self.assertEqual(shkeeper.add_month(datetime(2027, 12, 15, tzinfo=dt_timezone.utc)), datetime(2028, 1, 15, tzinfo=dt_timezone.utc))
|
||||
Reference in New Issue
Block a user