diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 258b8ca8692ac..d8fc75e312438 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -1370,7 +1370,7 @@ def create_stripe_invoice_and_charge( if isinstance(e, stripe.CardError): raise StripeCardError("card error", e.user_message) else: # nocoverage - raise e + raise assert stripe_invoice.id is not None return stripe_invoice.id @@ -4151,7 +4151,7 @@ def create_complimentary_access_plan( plan_tier = CustomerPlan.TIER_SELF_HOSTED_LEGACY if isinstance(self, RealmBillingSession): # nocoverage # TODO implement a complimentary access plan/tier for Zulip Cloud. - return None + return customer = self.update_or_create_customer() complimentary_access_plan = self.create_customer_plan( @@ -6012,7 +6012,7 @@ def invoice_plans_as_needed(event_time: datetime | None = None) -> None: stack_info=True, ) else: - billing_logger.exception(e, stack_info=True) # nocoverage + billing_logger.exception("Error while invoicing", stack_info=True) # nocoverage def is_realm_on_free_trial(realm: Realm) -> bool: diff --git a/corporate/lib/test_stripe_class.py b/corporate/lib/test_stripe_class.py index e055f00855a6d..9859fa5c0fcbf 100644 --- a/corporate/lib/test_stripe_class.py +++ b/corporate/lib/test_stripe_class.py @@ -78,6 +78,7 @@ stripe_get_customer, ) from corporate.models.customers import Customer +from corporate.models.licenses import LicenseLedger from corporate.models.plans import CustomerPlan from corporate.models.stripe_state import Invoice from zerver.actions.users import do_deactivate_user @@ -1046,3 +1047,12 @@ def client_billing_patch(self, url_suffix: str, info: Mapping[str, Any] = {}) -> else: response = self.client_patch(url, info) return response + + def check_last_ledger_entry_license_counts( + self, plan: CustomerPlan, licenses: int, licenses_at_next_renewal: int + ) -> LicenseLedger: + ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by("-id").first() + assert ledger_entry is not None + self.assertEqual(ledger_entry.licenses, licenses) + self.assertEqual(ledger_entry.licenses_at_next_renewal, licenses_at_next_renewal) + return ledger_entry diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 4cf6ad1968a39..a2d8a12d67a8a 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -102,6 +102,17 @@ class StripeTest(StripeTestCase): + def check_initial_ledger_entry( + self, plan: CustomerPlan, licenses_purchased: int + ) -> LicenseLedger: + return LicenseLedger.objects.get( + plan=plan, + is_renewal=True, + event_time=self.now, + licenses=licenses_purchased, + licenses_at_next_renewal=licenses_purchased, + ) + def test_catch_stripe_errors(self) -> None: @catch_stripe_errors def raise_invalid_request_error() -> None: @@ -225,13 +236,7 @@ def test_upgrade_by_card_to_plus_plan(self, *mocks: Mock) -> None: tier=CustomerPlan.TIER_CLOUD_PLUS, status=CustomerPlan.ACTIVE, ) - LicenseLedger.objects.get( - plan=plan, - is_renewal=True, - event_time=self.now, - licenses=licenses_purchased, - licenses_at_next_renewal=licenses_purchased, - ) + self.check_initial_ledger_entry(plan, licenses_purchased) # Check RealmAuditLog audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) @@ -348,13 +353,7 @@ def test_upgrade_by_invoice_to_plus_plan(self, *mocks: Mock) -> None: tier=CustomerPlan.TIER_CLOUD_PLUS, status=CustomerPlan.ACTIVE, ) - LicenseLedger.objects.get( - plan=plan, - is_renewal=True, - event_time=self.now, - licenses=123, - licenses_at_next_renewal=123, - ) + self.check_initial_ledger_entry(plan, 123) # Check RealmAuditLog audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) @@ -474,13 +473,7 @@ def test_upgrade_by_card(self, *mocks: Mock) -> None: tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.ACTIVE, ) - LicenseLedger.objects.get( - plan=plan, - is_renewal=True, - event_time=self.now, - licenses=self.seat_count, - licenses_at_next_renewal=self.seat_count, - ) + self.check_initial_ledger_entry(plan, self.seat_count) # Check RealmAuditLog audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) @@ -608,13 +601,7 @@ def test_upgrade_by_invoice(self, *mocks: Mock) -> None: tier=CustomerPlan.TIER_CLOUD_STANDARD, status=CustomerPlan.ACTIVE, ) - LicenseLedger.objects.get( - plan=plan, - is_renewal=True, - event_time=self.now, - licenses=123, - licenses_at_next_renewal=123, - ) + self.check_initial_ledger_entry(plan, 123) # Check RealmAuditLog audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) @@ -722,13 +709,7 @@ def test_free_trial_upgrade_by_card(self, *mocks: Mock) -> None: # For payment through card. charge_automatically=True, ) - LicenseLedger.objects.get( - plan=plan, - is_renewal=True, - event_time=self.now, - licenses=self.seat_count, - licenses_at_next_renewal=self.seat_count, - ) + self.check_initial_ledger_entry(plan, self.seat_count) audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") @@ -782,21 +763,11 @@ def test_free_trial_upgrade_by_card(self, *mocks: Mock) -> None: billing_session = RealmBillingSession(user=user, realm=realm) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=12): billing_session.update_license_ledger_if_needed(self.now) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (12, 12), - ) + self.check_last_ledger_entry_license_counts(plan, 12, 12) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=15): billing_session.update_license_ledger_if_needed(self.next_month) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (15, 15), - ) + self.check_last_ledger_entry_license_counts(plan, 15, 15) invoice_plans_as_needed(self.next_month) self.assertFalse(stripe.Invoice.list(customer=stripe_customer.id)) @@ -837,12 +808,8 @@ def test_free_trial_upgrade_by_card(self, *mocks: Mock) -> None: with patch("corporate.lib.stripe.get_latest_seat_count", return_value=19): billing_session.update_license_ledger_if_needed(add_months(free_trial_end_date, 10)) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (19, 19), - ) + self.check_last_ledger_entry_license_counts(plan, 19, 19) + # Fast forward next_invoice_date to 10 months from the free_trial_end_date plan.next_invoice_date = add_months(free_trial_end_date, 10) plan.save(update_fields=["next_invoice_date"]) @@ -940,13 +907,7 @@ def test_free_trial_upgrade_by_invoice(self, *mocks: Mock) -> None: charge_automatically=False, ) - LicenseLedger.objects.get( - plan=plan, - is_renewal=True, - event_time=self.now, - licenses=123, - licenses_at_next_renewal=123, - ) + self.check_initial_ledger_entry(plan, 123) audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") @@ -1140,13 +1101,7 @@ def test_free_trial_upgrade_by_invoice_with_additional_users_after_payment( charge_automatically=False, ) - LicenseLedger.objects.get( - plan=plan, - is_renewal=True, - event_time=self.now, - licenses=123, - licenses_at_next_renewal=123, - ) + self.check_initial_ledger_entry(plan, 123) audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") @@ -1244,14 +1199,10 @@ def test_free_trial_upgrade_by_invoice_with_additional_users_after_payment( schedule=None, toggle_license_management=False, ) - self.assertEqual( - LicenseLedger.objects.filter(plan=plan).latest("id").licenses_at_next_renewal, 123 - ) + self.check_last_ledger_entry_license_counts(plan, 123, 123) with time_machine.travel(self.now, tick=False): self.billing_session.do_update_plan(update_plan_request) - self.assertEqual( - LicenseLedger.objects.filter(plan=plan).latest("id").licenses_at_next_renewal, 125 - ) + self.check_last_ledger_entry_license_counts(plan, 123, 125) invoice_plans_as_needed(free_trial_end_date) customer_plan.refresh_from_db() @@ -1333,13 +1284,7 @@ def test_free_trial_upgrade_by_invoice_customer_fails_to_pay(self, *mocks: Mock) charge_automatically=False, ) - LicenseLedger.objects.get( - plan=plan, - is_renewal=True, - event_time=self.now, - licenses=123, - licenses_at_next_renewal=123, - ) + self.check_initial_ledger_entry(plan, 123) audit_log_entries = list( RealmAuditLog.objects.filter(acting_user=user) .values_list("event_type", "event_time") @@ -1466,10 +1411,9 @@ def test_upgrade_by_card_with_outdated_seat_count(self, *mocks: Mock) -> None: [item.amount for item in additional_license_invoice.lines], ) # Check LicenseLedger has the new amount - ledger_entry = LicenseLedger.objects.last() - assert ledger_entry is not None - self.assertEqual(ledger_entry.licenses, new_seat_count) - self.assertEqual(ledger_entry.licenses_at_next_renewal, new_seat_count) + plan = get_current_plan_by_customer(customer) + assert plan is not None + self.check_last_ledger_entry_license_counts(plan, new_seat_count, new_seat_count) @mock_stripe() def test_upgrade_by_card_with_outdated_lower_seat_count(self, *mocks: Mock) -> None: @@ -1511,10 +1455,9 @@ def test_upgrade_by_card_with_outdated_lower_seat_count(self, *mocks: Mock) -> N [item.amount for item in upgrade_invoice.lines], ) # Check LicenseLedger has the reduced license count at renewal - ledger_entry = LicenseLedger.objects.last() - assert ledger_entry is not None - self.assertEqual(ledger_entry.licenses, self.seat_count) - self.assertEqual(ledger_entry.licenses_at_next_renewal, new_seat_count) + plan = get_current_plan_by_customer(customer) + assert plan is not None + self.check_last_ledger_entry_license_counts(plan, self.seat_count, new_seat_count) # Check that we informed the support team about the potential billing error. from django.core.mail import outbox @@ -1576,10 +1519,11 @@ def test_upgrade_by_card_with_outdated_seat_count_and_minimum_for_plan_tier( [item.amount for item in upgrade_invoice.lines], ) # Check LicenseLedger has the minimum license count - ledger_entry = LicenseLedger.objects.last() - assert ledger_entry is not None - self.assertEqual(ledger_entry.licenses, minimum_for_plan_tier) - self.assertEqual(ledger_entry.licenses_at_next_renewal, minimum_for_plan_tier) + plan = get_current_plan_by_customer(customer) + assert plan is not None + self.check_last_ledger_entry_license_counts( + plan, minimum_for_plan_tier, minimum_for_plan_tier + ) @mock_stripe() def test_customer_minimum_licenses_for_plan(self, *mocks: Mock) -> None: @@ -1599,10 +1543,9 @@ def test_customer_minimum_licenses_for_plan(self, *mocks: Mock) -> None: assert customer is not None assert customer.stripe_customer_id is not None # Check LicenseLedger has the current seat count. - ledger_entry = LicenseLedger.objects.last() - assert ledger_entry is not None - self.assertEqual(ledger_entry.licenses, self.seat_count) - self.assertEqual(ledger_entry.licenses_at_next_renewal, self.seat_count) + plan = get_current_plan_by_customer(customer) + assert plan is not None + self.check_last_ledger_entry_license_counts(plan, self.seat_count, self.seat_count) # We manually set customer.minimum_licenses to the current seat count, # which is below the general Plus plan minimum licenses. @@ -2258,13 +2201,7 @@ def test_redirect_for_billing_page_downgrade_at_free_trial_end(self, *mocks: Moc # For payment through card. charge_automatically=True, ) - LicenseLedger.objects.get( - plan=plan, - is_renewal=True, - event_time=self.now, - licenses=self.seat_count, - licenses_at_next_renewal=self.seat_count, - ) + self.check_initial_ledger_entry(plan, self.seat_count) realm = get_realm("zulip") self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) @@ -2612,7 +2549,9 @@ def test_downgrade(self) -> None: self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) - plan = get_current_plan_by_realm(user.realm) + customer = get_customer_by_realm(user.realm) + assert customer is not None + plan = get_current_plan_by_customer(customer) assert plan is not None self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count) @@ -2624,10 +2563,7 @@ def test_downgrade(self) -> None: "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() @@ -2655,12 +2591,7 @@ def test_downgrade(self) -> None: billing_session = RealmBillingSession(user=user, realm=user.realm) with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (20, 20), - ) + self.check_last_ledger_entry_license_counts(plan, 20, 20) # Verify that we invoice them for the additional users mocked = self.setup_mocked_stripe(invoice_plans_as_needed, self.next_month) @@ -2671,16 +2602,11 @@ def test_downgrade(self) -> None: # Check that we downgrade properly if the cycle is over with patch("corporate.lib.stripe.get_latest_seat_count", return_value=30): billing_session.update_license_ledger_if_needed(self.next_year) - plan = CustomerPlan.objects.first() - assert plan is not None + plan.refresh_from_db() self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED) self.assertEqual(plan.status, CustomerPlan.ENDED) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (20, 20), - ) + self.check_last_ledger_entry_license_counts(plan, 20, 20) + realm_audit_log = RealmAuditLog.objects.latest("id") self.assertEqual(realm_audit_log.event_type, AuditLogEventType.REALM_PLAN_TYPE_CHANGED) self.assertEqual(realm_audit_log.acting_user, None) @@ -2688,17 +2614,11 @@ def test_downgrade(self) -> None: # Verify that we don't write LicenseLedger rows once we've downgraded with patch("corporate.lib.stripe.get_latest_seat_count", return_value=40): billing_session.update_license_ledger_if_needed(self.next_year) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (20, 20), - ) + self.check_last_ledger_entry_license_counts(plan, 20, 20) # Verify that we call invoice_plan once more after cycle end but # don't invoice them for users added after the cycle end - plan = CustomerPlan.objects.first() - assert plan is not None + plan.refresh_from_db() self.assertIsNotNone(plan.next_invoice_date) mocked = self.setup_mocked_stripe( @@ -2709,8 +2629,7 @@ def test_downgrade(self) -> None: mocked["Invoice"].create.assert_not_called() # Check that we updated next_invoice_date in invoice_plan - plan = CustomerPlan.objects.first() - assert plan is not None + plan.refresh_from_db() self.assertIsNone(plan.next_invoice_date) # Check that we don't call invoice_plan after that final call @@ -2732,15 +2651,13 @@ def test_switch_from_monthly_plan_to_annual_plan_for_automatic_license_managemen self.login_user(user) self.add_card_and_upgrade(user, schedule="monthly") - monthly_plan = get_current_plan_by_realm(user.realm) + customer = get_customer_by_realm(user.realm) + assert customer is not None + monthly_plan = get_current_plan_by_customer(customer) assert monthly_plan is not None self.assertEqual(monthly_plan.automanage_licenses, True) self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY) - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None - with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), @@ -2749,7 +2666,7 @@ def test_switch_from_monthly_plan_to_annual_plan_for_automatic_license_managemen "/billing/plan", {"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}, ) - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}" + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {monthly_plan.id}, status: {CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) monthly_plan.refresh_from_db() @@ -2764,12 +2681,7 @@ def test_switch_from_monthly_plan_to_annual_plan_for_automatic_license_managemen with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 2) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (20, 20), - ) + self.check_last_ledger_entry_license_counts(monthly_plan, 20, 20) with ( time_machine.travel(self.next_month, tick=False), @@ -2914,13 +2826,12 @@ def test_switch_from_monthly_plan_to_annual_plan_for_manual_license_management( self.add_card_and_upgrade( user, schedule="monthly", license_management="manual", licenses=num_licenses ) - monthly_plan = get_current_plan_by_realm(user.realm) + customer = get_customer_by_realm(user.realm) + assert customer is not None + monthly_plan = get_current_plan_by_customer(customer) assert monthly_plan is not None self.assertEqual(monthly_plan.automanage_licenses, False) self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY) - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None with ( self.assertLogs("corporate.stripe", "INFO") as m, time_machine.travel(self.now, tick=False), @@ -2931,7 +2842,7 @@ def test_switch_from_monthly_plan_to_annual_plan_for_manual_license_management( ) self.assertEqual( m.output[0], - f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}", + f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {monthly_plan.id}, status: {CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}", ) self.assert_json_success(response) monthly_plan.refresh_from_db() @@ -3016,15 +2927,13 @@ def test_switch_from_annual_plan_to_monthly_plan_for_automatic_license_managemen user = self.example_user("hamlet") self.login_user(user) self.add_card_and_upgrade(user, schedule="annual") - annual_plan = get_current_plan_by_realm(user.realm) + customer = get_customer_by_realm(user.realm) + assert customer is not None + annual_plan = get_current_plan_by_customer(customer) assert annual_plan is not None self.assertEqual(annual_plan.automanage_licenses, True) self.assertEqual(annual_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL) - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None - assert self.now is not None with ( self.assertLogs("corporate.stripe", "INFO") as m, @@ -3034,7 +2943,7 @@ def test_switch_from_annual_plan_to_monthly_plan_for_automatic_license_managemen "/billing/plan", {"status": CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE}, ) - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE}" + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {annual_plan.id}, status: {CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) annual_plan.refresh_from_db() @@ -3049,12 +2958,7 @@ def test_switch_from_annual_plan_to_monthly_plan_for_automatic_license_managemen with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) self.assertEqual(LicenseLedger.objects.filter(plan=annual_plan).count(), 2) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (20, 20), - ) + self.check_last_ledger_entry_license_counts(annual_plan, 20, 20) # Check that we don't switch to monthly plan at next invoice date (which is used to charge user for # additional licenses) but at the end of current billing cycle. @@ -3186,14 +3090,14 @@ def test_reupgrade_after_plan_status_changed_to_downgrade_at_end_of_cycle(self) "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" + customer = get_customer_by_realm(user.realm) + assert customer is not None + plan = get_current_plan_by_customer(customer) + assert plan is not None + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) - plan = CustomerPlan.objects.first() - assert plan is not None + plan.refresh_from_db() self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE) with ( self.assertLogs("corporate.stripe", "INFO") as m, @@ -3203,11 +3107,10 @@ def test_reupgrade_after_plan_status_changed_to_downgrade_at_end_of_cycle(self) "/billing/plan", {"status": CustomerPlan.ACTIVE}, ) - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.ACTIVE}" + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.ACTIVE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) - plan = CustomerPlan.objects.first() - assert plan is not None + plan.refresh_from_db() self.assertEqual(plan.status, CustomerPlan.ACTIVE) @patch("stripe.Invoice.create") @@ -3225,28 +3128,26 @@ def test_downgrade_during_invoicing(self, *mocks: Mock) -> None: self.local_upgrade( self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) + customer = get_customer_by_realm(user.realm) + assert customer is not None + plan = get_current_plan_by_customer(customer) + assert plan is not None with self.assertLogs("corporate.stripe", "INFO") as m: - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None with time_machine.travel(self.now, tick=False): self.client_billing_patch( "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) - - plan = CustomerPlan.objects.first() - assert plan is not None + plan.refresh_from_db() self.assertIsNotNone(plan.next_invoice_date) self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE) # Fast forward the next_invoice_date to next year. plan.next_invoice_date = self.next_year plan.save(update_fields=["next_invoice_date"]) invoice_plans_as_needed(self.next_year) - plan = CustomerPlan.objects.first() - assert plan is not None + plan.refresh_from_db() self.assertIsNone(plan.next_invoice_date) self.assertEqual(plan.status, CustomerPlan.ENDED) @@ -3291,13 +3192,7 @@ def test_switch_now_free_trial_from_monthly_to_annual(self, *mocks: Mock) -> Non status=CustomerPlan.FREE_TRIAL, charge_automatically=True, ) - ledger_entry = LicenseLedger.objects.get( - plan=new_plan, - is_renewal=True, - event_time=self.now, - licenses=self.seat_count, - licenses_at_next_renewal=self.seat_count, - ) + ledger_entry = self.check_initial_ledger_entry(new_plan, self.seat_count) self.assertEqual(new_plan.invoiced_through, ledger_entry) realm_audit_log = RealmAuditLog.objects.filter( @@ -3345,13 +3240,7 @@ def test_switch_now_free_trial_from_annual_to_monthly(self, *mocks: Mock) -> Non status=CustomerPlan.FREE_TRIAL, charge_automatically=True, ) - ledger_entry = LicenseLedger.objects.get( - plan=new_plan, - is_renewal=True, - event_time=self.now, - licenses=self.seat_count, - licenses_at_next_renewal=self.seat_count, - ) + ledger_entry = self.check_initial_ledger_entry(new_plan, self.seat_count) self.assertEqual(new_plan.invoiced_through, ledger_entry) realm_audit_log = RealmAuditLog.objects.filter( @@ -3379,10 +3268,7 @@ def test_end_free_trial(self, *mocks: Mock) -> None: with patch("corporate.lib.stripe.get_latest_seat_count", return_value=21): billing_session.update_license_ledger_if_needed(self.now) - last_ledger_entry = LicenseLedger.objects.order_by("id").last() - assert last_ledger_entry is not None - self.assertEqual(last_ledger_entry.licenses, 21) - self.assertEqual(last_ledger_entry.licenses_at_next_renewal, 21) + last_ledger_entry = self.check_last_ledger_entry_license_counts(plan, 21, 21) self.login_user(user) @@ -3422,7 +3308,9 @@ def test_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None: with self.settings(CLOUD_FREE_TRIAL_DAYS=60): with time_machine.travel(self.now, tick=False): self.add_card_and_upgrade(user, schedule="annual") - plan = get_current_plan_by_realm(user.realm) + customer = get_customer_by_realm(user.realm) + assert customer is not None + plan = get_current_plan_by_customer(customer) assert plan is not None self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) @@ -3439,10 +3327,7 @@ def test_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None: "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}, ) - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}" + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() @@ -3472,12 +3357,7 @@ def test_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None: # part of the cycle with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (20, 20), - ) + self.check_last_ledger_entry_license_counts(plan, 20, 20) # Verify that we don't invoice them for the additional users during free trial. mocked = self.setup_mocked_stripe(invoice_plans_as_needed, self.next_month) @@ -3488,27 +3368,16 @@ def test_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None: # Check that we downgrade properly if the cycle is over with patch("corporate.lib.stripe.get_latest_seat_count", return_value=30): billing_session.update_license_ledger_if_needed(free_trial_end_date) - plan = CustomerPlan.objects.first() - assert plan is not None + plan.refresh_from_db() self.assertIsNone(plan.next_invoice_date) self.assertEqual(plan.status, CustomerPlan.ENDED) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (20, 20), - ) + self.check_last_ledger_entry_license_counts(plan, 20, 20) # Verify that we don't write LicenseLedger rows once we've downgraded with patch("corporate.lib.stripe.get_latest_seat_count", return_value=40): billing_session.update_license_ledger_if_needed(self.next_year) - self.assertEqual( - LicenseLedger.objects.order_by("-id") - .values_list("licenses", "licenses_at_next_renewal") - .first(), - (20, 20), - ) + self.check_last_ledger_entry_license_counts(plan, 20, 20) self.login_user(user) response = self.client_get("/billing/") @@ -3534,7 +3403,9 @@ def test_cancel_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None: with self.settings(CLOUD_FREE_TRIAL_DAYS=60): with time_machine.travel(self.now, tick=False): self.add_card_and_upgrade(user, schedule="annual") - plan = get_current_plan_by_realm(user.realm) + customer = get_customer_by_realm(user.realm) + assert customer is not None + plan = get_current_plan_by_customer(customer) assert plan is not None self.assertEqual(plan.next_invoice_date, free_trial_end_date) self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD) @@ -3551,10 +3422,7 @@ def test_cancel_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None: "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}, ) - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}" + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() @@ -3573,10 +3441,7 @@ def test_cancel_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None: "/billing/plan", {"status": CustomerPlan.FREE_TRIAL}, ) - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.FREE_TRIAL}" + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.FREE_TRIAL}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) plan.refresh_from_db() @@ -3601,10 +3466,11 @@ def test_reupgrade_by_billing_admin_after_downgrade(self) -> None: "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) - stripe_customer_id = Customer.objects.get(realm=user.realm).id - new_plan = get_current_plan_by_realm(user.realm) - assert new_plan is not None - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" + customer = get_customer_by_realm(user.realm) + assert customer is not None + plan = get_current_plan_by_customer(customer) + assert plan is not None + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) with ( @@ -3624,8 +3490,8 @@ def test_reupgrade_by_billing_admin_after_downgrade(self) -> None: ) # Fast forward the next_invoice_date to next year. - new_plan.next_invoice_date = self.next_year - new_plan.save(update_fields=["next_invoice_date"]) + plan.next_invoice_date = self.next_year + plan.save(update_fields=["next_invoice_date"]) invoice_plans_as_needed(self.next_year) with time_machine.travel(self.next_year, tick=False): @@ -3787,24 +3653,26 @@ def test_update_licenses_of_manual_plan_from_billing_page_exempt_from_license_nu self.login_user(user) customer = Customer.objects.get_or_create(realm=user.realm)[0] + reduced_seat_count = get_latest_seat_count(user.realm) - 2 customer.exempt_from_license_number_check = True customer.save() + paid_license_count = 100 with time_machine.travel(self.now, tick=False): - self.local_upgrade(100, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False) + self.local_upgrade( + paid_license_count, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False + ) with time_machine.travel(self.now, tick=False): result = self.client_billing_patch( "/billing/plan", - {"licenses_at_next_renewal": get_latest_seat_count(user.realm) - 2}, + {"licenses_at_next_renewal": reduced_seat_count}, ) self.assert_json_success(result) - latest_license_ledger = LicenseLedger.objects.last() - assert latest_license_ledger is not None - self.assertEqual( - latest_license_ledger.licenses_at_next_renewal, get_latest_seat_count(user.realm) - 2 - ) + plan = get_current_plan_by_customer(customer) + assert plan is not None + self.check_last_ledger_entry_license_counts(plan, paid_license_count, reduced_seat_count) def test_upgrade_exempt_from_license_number_check_realm_less_licenses_than_seat_count( self, @@ -3829,10 +3697,9 @@ def test_upgrade_exempt_from_license_number_check_realm_less_licenses_than_seat_ reduced_seat_count, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False ) - latest_license_ledger = LicenseLedger.objects.last() - assert latest_license_ledger is not None - self.assertEqual(latest_license_ledger.licenses_at_next_renewal, reduced_seat_count) - self.assertEqual(latest_license_ledger.licenses, reduced_seat_count) + plan = get_current_plan_by_customer(customer) + assert plan is not None + self.check_last_ledger_entry_license_counts(plan, reduced_seat_count, reduced_seat_count) def test_update_licenses_of_automatic_plan_from_billing_page(self) -> None: user = self.example_user("hamlet") @@ -3954,10 +3821,7 @@ def test_deactivate_realm(self, mock_: Mock) -> None: with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20): billing_session.update_license_ledger_if_needed(self.now) - last_ledger_entry = LicenseLedger.objects.order_by("id").last() - assert last_ledger_entry is not None - self.assertEqual(last_ledger_entry.licenses, 20) - self.assertEqual(last_ledger_entry.licenses_at_next_renewal, 20) + last_ledger_entry = self.check_last_ledger_entry_license_counts(plan, 20, 20) do_deactivate_realm( get_realm("zulip"), @@ -7088,23 +6952,21 @@ def test_upgrade_user_to_business_plan(self, *mocks: Mock) -> None: RemoteRealmAuditLog.objects.count(), min_licenses + 10 - realm_user_count + audit_log_count, ) - latest_ledger = LicenseLedger.objects.last() - assert latest_ledger is not None - self.assertEqual(latest_ledger.licenses, min_licenses + 10) + paid_license_count = min_licenses + 10 + self.check_last_ledger_entry_license_counts(plan, paid_license_count, paid_license_count) with time_machine.travel(self.now + timedelta(days=3), tick=False): response = self.client_get( f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting" ) - self.assertEqual(latest_ledger.licenses, 35) for substring in [ "Zulip Business", "Number of licenses", - f"{latest_ledger.licenses}", + f"{paid_license_count}", "January 2, 2013", "Your plan will automatically renew on", - f"${80 * latest_ledger.licenses:,.2f}", + f"${80 * paid_license_count:,.2f}", "Visa ending in 4242", "Update card", ]: @@ -7316,23 +7178,23 @@ def test_upgrade_user_to_basic_plan_free_trial(self, *mocks: Mock) -> None: RemoteRealmAuditLog.objects.count(), min_licenses + 10 - realm_user_count + audit_log_count, ) - latest_ledger = LicenseLedger.objects.last() - assert latest_ledger is not None - self.assertEqual(latest_ledger.licenses, min_licenses + 10) + paid_license_count = min_licenses + 10 + self.check_last_ledger_entry_license_counts( + plan, paid_license_count, paid_license_count + ) with time_machine.travel(self.now + timedelta(days=3), tick=False): response = self.client_get( f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting" ) - self.assertEqual(latest_ledger.licenses, min_licenses + 10) for substring in [ "Zulip Basic", "Number of licenses", - f"{latest_ledger.licenses}", + f"{paid_license_count}", "February 1, 2012", "Your plan will automatically renew on", - f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}", + f"${3.5 * paid_license_count - flat_discount // 100 * 1:,.2f}", "Visa ending in 4242", "Update card", ]: @@ -7524,23 +7386,21 @@ def test_upgrade_remote_realm_user_to_monthly_basic_plan(self, *mocks: Mock) -> RemoteRealmAuditLog.objects.count(), min_licenses + 10 - realm_user_count + audit_log_count, ) - latest_ledger = LicenseLedger.objects.last() - assert latest_ledger is not None - self.assertEqual(latest_ledger.licenses, min_licenses + 10) + paid_license_count = min_licenses + 10 + self.check_last_ledger_entry_license_counts(plan, paid_license_count, paid_license_count) with time_machine.travel(self.now + timedelta(days=3), tick=False): response = self.client_get( f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting" ) - self.assertEqual(latest_ledger.licenses, min_licenses + 10) for substring in [ "Zulip Basic", "Number of licenses", - f"{latest_ledger.licenses}", + f"{paid_license_count}", "February 2, 2012", "Your plan will automatically renew on", - f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}", + f"${3.5 * paid_license_count - flat_discount // 100 * 1:,.2f}", "Visa ending in 4242", "Update card", ]: @@ -9068,9 +8928,8 @@ def test_non_sponsorship_billing(self, *mocks: Mock) -> None: RemoteRealmAuditLog.objects.count(), audit_log_count + 8, ) - latest_ledger = LicenseLedger.objects.last() - assert latest_ledger is not None - self.assertEqual(latest_ledger.licenses, server_user_count + 8) + paid_license_count = server_user_count + 8 + self.check_last_ledger_entry_license_counts(plan, paid_license_count, paid_license_count) # Login again result = self.execute_remote_billing_authentication_flow( @@ -9088,13 +8947,11 @@ def test_non_sponsorship_billing(self, *mocks: Mock) -> None: "/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}, ) - customer = Customer.objects.get(remote_server=self.remote_server) - new_plan = get_current_plan_by_customer(customer) - assert new_plan is not None - expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" + expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}" self.assertEqual(m.output[0], expected_log) self.assert_json_success(response) - self.assertEqual(new_plan.licenses_at_next_renewal(), None) + plan.refresh_from_db() + self.assertEqual(plan.licenses_at_next_renewal(), None) @responses.activate def test_request_sponsorship(self) -> None: @@ -9504,23 +9361,23 @@ def test_upgrade_user_to_basic_plan_free_trial_remote_server(self, *mocks: Mock) RemoteRealmAuditLog.objects.count(), audit_log_count + 10, ) - latest_ledger = LicenseLedger.objects.last() - assert latest_ledger is not None - self.assertEqual(latest_ledger.licenses, 28) + paid_license_count = realm_user_count + 10 + self.check_last_ledger_entry_license_counts( + plan, paid_license_count, paid_license_count + ) with time_machine.travel(self.now + timedelta(days=3), tick=False): response = self.client_get( f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting" ) - self.assertEqual(latest_ledger.licenses, 28) for substring in [ "Zulip Basic", "Number of licenses", - f"{latest_ledger.licenses}", + f"{paid_license_count}", "February 1, 2012", "Your plan will automatically renew on", - f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}", + f"${3.5 * paid_license_count - flat_discount // 100 * 1:,.2f}", "Visa ending in 4242", "Update card", ]: @@ -9714,23 +9571,21 @@ def test_upgrade_server_user_to_monthly_basic_plan(self, *mocks: Mock) -> None: RemoteRealmAuditLog.objects.count(), audit_log_count + 10, ) - latest_ledger = LicenseLedger.objects.last() - assert latest_ledger is not None - self.assertEqual(latest_ledger.licenses, 28) + paid_license_count = server_user_count + 10 + self.check_last_ledger_entry_license_counts(plan, paid_license_count, paid_license_count) with time_machine.travel(self.now + timedelta(days=3), tick=False): response = self.client_get( f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting" ) - self.assertEqual(latest_ledger.licenses, 28) for substring in [ "Zulip Basic", "Number of licenses", - f"{latest_ledger.licenses}", + f"{paid_license_count}", "February 2, 2012", "Your plan will automatically renew on", - f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}", + f"${3.5 * paid_license_count - flat_discount // 100 * 1:,.2f}", "Visa ending in 4242", "Update card", ]: diff --git a/corporate/views/upgrade.py b/corporate/views/upgrade.py index a135275536b06..9de89bc57dfba 100644 --- a/corporate/views/upgrade.py +++ b/corporate/views/upgrade.py @@ -68,7 +68,7 @@ def upgrade( license_management, licenses, ) - raise e + raise except Exception: billing_logger.exception("Uncaught exception in billing:", stack_info=True) error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR) @@ -118,7 +118,7 @@ def remote_realm_upgrade( license_management, licenses, ) - raise e + raise except Exception: # nocoverage billing_logger.exception("Uncaught exception in billing:", stack_info=True) error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR) @@ -168,7 +168,7 @@ def remote_server_upgrade( license_management, licenses, ) - raise e + raise except Exception: # nocoverage billing_logger.exception("Uncaught exception in billing:", stack_info=True) error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR) diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/package.json b/package.json index 5bf45a9ac01cd..ee438272692d0 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@types/convert-source-map": "^2.0.3", "@types/css-tree": "^2.3.11", "@types/eslint-config-prettier": "^6.11.3", + "@types/estree": "^1.0.9", "@types/gtag.js": "^0.0.20", "@types/is-url": "^1.2.32", "@types/jquery": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6db1dfeca5512..bdf017bcc8e29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,6 +355,9 @@ importers: '@types/eslint-config-prettier': specifier: ^6.11.3 version: 6.11.3 + '@types/estree': + specifier: ^1.0.9 + version: 1.0.9 '@types/gtag.js': specifier: ^0.0.20 version: 0.0.20 diff --git a/puppet/kandra/files/statuspage-pusher b/puppet/kandra/files/statuspage-pusher index 2869a875f226a..3202fb063cc66 100755 --- a/puppet/kandra/files/statuspage-pusher +++ b/puppet/kandra/files/statuspage-pusher @@ -71,8 +71,8 @@ def main() -> None: for metric_id, query in metrics.items(): try: update_metric(metric_id, query, page_id, oauth_token) - except Exception as e: - logging.exception(e) + except Exception: + logging.exception("Error while updating metric") time.sleep(30) diff --git a/puppet/zulip/files/postgresql/wal-g-exporter b/puppet/zulip/files/postgresql/wal-g-exporter index 1aaaf5e5b783d..e394b121fce80 100755 --- a/puppet/zulip/files/postgresql/wal-g-exporter +++ b/puppet/zulip/files/postgresql/wal-g-exporter @@ -141,8 +141,8 @@ class WalGPrometheusServer(BaseHTTPRequestHandler): (t("finish_time") - t("start_time")) / timedelta(seconds=1), labels ) backup_ok(1) - except Exception as e: - logging.exception(e) + except Exception: + logging.exception("Error while getting backup information") finally: self.print_metrics() self.log_message( diff --git a/pyproject.toml b/pyproject.toml index 026a69fc99ea3..20f7ba56f4dda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -463,7 +463,7 @@ target-version = "py310" [tool.ruff.lint] # See https://github.com/astral-sh/ruff#rules for error code definitions. -select = [ +extend-select = [ "ANN", # annotations "B", # bugbear "C4", # comprehensions @@ -477,6 +477,7 @@ select = [ "FURB", # refurbishing "G", # logging format "I", # import sorting + "INP", # implicit namespace package "INT", # gettext "ISC", # string concatenation "LOG", # logging @@ -503,6 +504,7 @@ ignore = [ "ANN401", # Dynamically typed expressions (typing.Any) are disallowed "B007", # Loop control variable not used within the loop body "B904", # Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling + "BLE001", # Do not catch blind exception "C408", # Unnecessary `dict` call (rewrite as a literal) "COM812", # Trailing comma missing "DJ001", # Avoid using `null=True` on string-based fields @@ -550,6 +552,8 @@ ignore = [ "TC002", # Move third-party import into a type-checking block "TC003", # Move standard library import into a type-checking block "TC006", # Add quotes to type expression in `typing.cast()` + "TRY002", # Create your own exception + "TRY004", # Prefer `TypeError` exception for invalid type ] [tool.ruff.lint.flake8-bandit] diff --git a/templates/zerver/development/integrations_dev_panel.html b/templates/zerver/development/integrations_dev_panel.html index b948daf30bbaf..2f9e5f6e076d8 100644 --- a/templates/zerver/development/integrations_dev_panel.html +++ b/templates/zerver/development/integrations_dev_panel.html @@ -69,6 +69,10 @@ +
+ + +

diff --git a/tools/droplets/__init__.py b/tools/droplets/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tools/oneclickapps/__init__.py b/tools/oneclickapps/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tools/run-dev b/tools/run-dev index 3b120c39c0d92..f8978ad72f8d1 100755 --- a/tools/run-dev +++ b/tools/run-dev @@ -477,7 +477,7 @@ async def serve() -> None: setup_routes(options.help_center_static_build, options.help_center_dev_server) - children.extend(subprocess.Popen(cmd) for cmd in server_processes()) + children.extend(subprocess.Popen(cmd) for cmd in server_processes()) # noqa: ASYNC220 session = aiohttp.ClientSession() runner = web.AppRunner(app, auto_decompress=False, handler_cancellation=True) diff --git a/tools/setup/emoji/__init__.py b/tools/setup/emoji/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/web/src/bot_type_values.ts b/web/src/bot_type_values.ts index a93e47a8ad333..5b4e302ed6c72 100644 --- a/web/src/bot_type_values.ts +++ b/web/src/bot_type_values.ts @@ -4,5 +4,6 @@ export const INCOMING_WEBHOOK_BOT_TYPE_INT = 2; export const OUTGOING_WEBHOOK_BOT_TYPE_INT = 3; // String forms used as HTML form values. +export const INCOMING_WEBHOOK_BOT_TYPE = "2"; export const OUTGOING_WEBHOOK_BOT_TYPE = "3"; export const EMBEDDED_BOT_TYPE = "4"; diff --git a/web/src/compose_closed_ui.ts b/web/src/compose_closed_ui.ts index 5ad621268cf09..cf727fca89570 100644 --- a/web/src/compose_closed_ui.ts +++ b/web/src/compose_closed_ui.ts @@ -17,8 +17,12 @@ import * as stream_data from "./stream_data.ts"; import type {StreamSubscription} from "./sub_store.ts"; import * as util from "./util.ts"; +// `label_text` is a flat, pre-formatted string used by the call-creation +// paths in compose_call_ui.ts to build a meeting/room name. +// The reply-button template uses the structured `stream` / `topic_display_name` +// fields instead, so it can render the decorated channel icon. type RecipientLabel = { - label_text?: string; + label_text: string; has_empty_string_topic?: boolean; stream?: StreamSubscription; topic_display_name?: string; @@ -31,6 +35,7 @@ function get_stream_recipient_label(stream_id: number, topic: string): Recipient const topic_display_name = util.get_final_topic_display_name(topic); if (stream) { const recipient_label: RecipientLabel = { + label_text: `#${stream.name} > ${topic_display_name}`, has_empty_string_topic: topic === "", stream, topic_display_name, diff --git a/web/src/portico/integrations_dev_panel.ts b/web/src/portico/integrations_dev_panel.ts index 240c7a5e4cea1..56ec628158bb3 100644 --- a/web/src/portico/integrations_dev_panel.ts +++ b/web/src/portico/integrations_dev_panel.ts @@ -49,6 +49,8 @@ const integrations_api_response_schema = z.object({ type ServerResponse = z.infer; +let last_computed_header_key: string | null = null; // Tracks the current signature header for auto-clearing when switching integrations + const loaded_fixtures = new Map(); const url_base = "/api/v1/external/"; @@ -231,11 +233,83 @@ function update_url(): void { params.set("topic", topic_name); } } + const webhook_secret = $("input#webhook_secret").val()!; const url = `${url_base}${integration_name}?${params.toString()}`; url_field!.value = url; + + sync_signature_headers(integration_name, webhook_secret); } +} - return; +function sync_signature_headers(integration_name: string, webhook_secret: string): void { + const $custom_headers_field = $("textarea#custom_http_headers"); + const current_headers_raw = $custom_headers_field.val()?.toString().trim() ?? ""; + + let headers_object: Record = {}; + if (current_headers_raw !== "") { + try { + headers_object = z + .record(z.string(), z.string()) + .parse(JSON.parse(current_headers_raw)); + } catch { + headers_object = {}; + } + } + + if (last_computed_header_key && Object.hasOwn(headers_object, last_computed_header_key)) { + Reflect.deleteProperty(headers_object, last_computed_header_key); + } + + if (webhook_secret.trim() === "") { + last_computed_header_key = null; + if (Object.keys(headers_object).length === 0) { + $custom_headers_field.val("{}"); + } else { + $custom_headers_field.val(JSON.stringify(headers_object, null, 4)); + } + return; + } + + const raw_payload = $("textarea#fixture_body").val() ?? ""; + let cleaned_payload: string; + + try { + cleaned_payload = JSON.stringify(JSON.parse(raw_payload)); + } catch { + cleaned_payload = raw_payload.trim(); + } + + channel.post({ + url: "/devtools/integrations/recalculate_signature", + data: JSON.stringify({ + secret: webhook_secret, + payload: cleaned_payload, + integration_name, + }), + success(raw_data: unknown) { + const data = z + .object({ + supported: z.optional(z.boolean()), + clear_signature: z.optional(z.boolean()), + header_key: z.string(), + signature: z.string(), + }) + .parse(raw_data); + + if (!data.supported || data.clear_signature) { + last_computed_header_key = null; + if (Object.keys(headers_object).length === 0) { + $custom_headers_field.val("{}"); + } else { + $custom_headers_field.val(JSON.stringify(headers_object, null, 4)); + } + } else { + headers_object[data.header_key] = data.signature; + last_computed_header_key = data.header_key; + $custom_headers_field.val(JSON.stringify(headers_object, null, 4)); + } + }, + }); } // API callers: These methods handle communicating with the Python backend API. @@ -440,4 +514,6 @@ $(() => { $("#stream_name").on("change", update_url); $("#topic_name").on("change", update_url); + + $("#webhook_secret").on("change", update_url); }); diff --git a/web/src/settings_bots.ts b/web/src/settings_bots.ts index 1a496fc991ea4..0acf9b405a9ae 100644 --- a/web/src/settings_bots.ts +++ b/web/src/settings_bots.ts @@ -13,6 +13,7 @@ import * as bot_helper from "./bot_helper.ts"; import { EMBEDDED_BOT_TYPE, GENERIC_BOT_TYPE, + INCOMING_WEBHOOK_BOT_TYPE, INCOMING_WEBHOOK_BOT_TYPE_INT, OUTGOING_WEBHOOK_BOT_TYPE, OUTGOING_WEBHOOK_BOT_TYPE_INT, @@ -298,6 +299,20 @@ export function add_a_new_bot(): void { formData.append("interface_type", interface_type); break; } + case INCOMING_WEBHOOK_BOT_TYPE: { + const config_data: Record = {}; + $("#webhook_secret_inputbox input").each(function () { + const key = $(this).attr("name")!; + const raw_val = $(this).val(); + if (typeof raw_val === "string" && raw_val.trim() !== "") { + config_data[key] = raw_val.trim(); + } + }); + if (Object.keys(config_data).length > 0) { + formData.append("config_data", JSON.stringify(config_data)); + } + break; + } case EMBEDDED_BOT_TYPE: { formData.append("service_name", service_name); const config_data: Record = {}; @@ -336,7 +351,7 @@ export function add_a_new_bot(): void { } function set_up_form_fields(): void { - $("#create_bot_type").val(INCOMING_WEBHOOK_BOT_TYPE_INT); + $("#create_bot_type").val(INCOMING_WEBHOOK_BOT_TYPE).trigger("change"); $("#payload_url_inputbox").hide(); $("#create_payload_url").val(""); $("#service_name_list").hide(); @@ -362,7 +377,13 @@ export function add_a_new_bot(): void { $("#payload_url_inputbox").hide(); $("#create_payload_url").removeClass("required"); + + $("#webhook_secret_inputbox").hide(); switch (bot_type) { + case INCOMING_WEBHOOK_BOT_TYPE: { + $("#webhook_secret_inputbox").show(); + break; + } case OUTGOING_WEBHOOK_BOT_TYPE: { $("#payload_url_inputbox").show(); $("#create_payload_url").addClass("required"); @@ -377,7 +398,7 @@ export function add_a_new_bot(): void { } } }); - + $("#create_bot_type").val(INCOMING_WEBHOOK_BOT_TYPE).trigger("change"); $("#select_service_name").on("change", () => { $("#config_inputbox").children().hide(); const selected_bot = $( @@ -742,11 +763,12 @@ function set_up_bot_handlers($container: JQuery): void { add_a_new_bot(); }); - $container.find(".download-botserverrc-file").on("click", function () { + $container.find(".download-botserverrc-file").on("click", (e) => { + const currentTarget = e.currentTarget; void (async () => { let content = ""; - buttons.show_button_loading_indicator($(this)); - $(this).prop("disabled", true); + buttons.show_button_loading_indicator($(currentTarget)); + $(currentTarget).prop("disabled", true); for (const bot of bot_data.get_all_bots_for_current_user()) { if (bot.is_active && bot.bot_type === OUTGOING_WEBHOOK_BOT_TYPE_INT) { const bot_token = bot_helper.get_outgoing_webhook_token(bot.user_id); @@ -755,15 +777,15 @@ function set_up_bot_handlers($container: JQuery): void { $("#admin-your-bots-list .bot-list-error"), ); if (!api_key) { - buttons.hide_button_loading_indicator($(this)); - $(this).prop("disabled", false); + buttons.hide_button_loading_indicator($(currentTarget)); + $(currentTarget).prop("disabled", false); return; } content += generate_botserverrc_content(bot.email, api_key, bot_token); } } - buttons.hide_button_loading_indicator($(this)); - $(this).prop("disabled", false); + buttons.hide_button_loading_indicator($(currentTarget)); + $(currentTarget).prop("disabled", false); $container .find(".hidden-botserverrc-download") diff --git a/web/src/user_profile.ts b/web/src/user_profile.ts index 2722d938aed79..2afb40df808ea 100644 --- a/web/src/user_profile.ts +++ b/web/src/user_profile.ts @@ -1,6 +1,5 @@ import ClipboardJS from "clipboard"; import {parseISO} from "date-fns"; -import {parseOneAddress} from "email-addresses"; import {$} from "jquery"; import _ from "lodash"; import assert from "minimalistic-assert"; @@ -24,6 +23,7 @@ import * as bot_data from "./bot_data.ts"; import * as bot_helper from "./bot_helper.ts"; import { EMBEDDED_BOT_TYPE, + INCOMING_WEBHOOK_BOT_TYPE, INCOMING_WEBHOOK_BOT_TYPE_INT, OUTGOING_WEBHOOK_BOT_TYPE, } from "./bot_type_values.ts"; @@ -851,11 +851,7 @@ export function show_edit_bot_info_modal(user_id: number, $container: JQuery): v assert(bot.is_bot); // Extract short_name from email (format: {short_name}-bot@domain) - const parsed_address = parseOneAddress(bot.email); - assert(parsed_address?.type === "mailbox"); - const short_name = parsed_address.local.endsWith("-bot") - ? parsed_address.local.slice(0, -"-bot".length) - : parsed_address.local; + const short_name = bot.email.split("@", 1)[0]!.slice(0, -4); const modal_content_html = render_edit_bot_form({ user_id, is_active, @@ -879,7 +875,32 @@ export function show_edit_bot_info_modal(user_id: number, $container: JQuery): v const bot_type = bot.bot_type.toString(); const services = bot_data.get_services(bot.user_id); const service = services?.[0]; + let is_delete_requested = false; edit_bot_post_render(); + + $("#bot-edit-form").on("click", "#clear_webhook_secret_button", (e) => { + e.preventDefault(); + is_delete_requested = true; + + // Clear the input value and set visual feedback + const $secret_input = $("#edit_webhook_secret"); + $secret_input.val(""); + $secret_input.attr( + "placeholder", + $t({defaultMessage: "Secret will be deleted when saved."}), + ); + + // Notify form handler that a change was made so the save button is enabled + $("#user-profile-modal .dialog_submit_button").prop("disabled", false); + }); + + // If the user types anything manually into the input, reset the clear flag + $("#bot-edit-form").on("input", "#edit_webhook_secret", function () { + if ($(this).val() !== "") { + $(this).data("clear-secret", false); + } + }); + original_values = get_current_values($("#bot-edit-form")); $("#bot-edit-form").on("input", "input, select, button", (e) => { e.preventDefault(); @@ -935,6 +956,14 @@ export function show_edit_bot_info_modal(user_id: number, $container: JQuery): v config_data[$(this).attr("name")!] = $(this).val()!; }); formData.append("config_data", JSON.stringify(config_data)); + } else if (bot_type === INCOMING_WEBHOOK_BOT_TYPE) { + const webhook_secret = $("#edit_webhook_secret").val()?.trim(); + if (is_delete_requested) { + formData.append("config_data", JSON.stringify({webhook_secret: ""})); + is_delete_requested = false; + } else if (webhook_secret) { + formData.append("config_data", JSON.stringify({webhook_secret})); + } } const files = util.the( @@ -957,6 +986,7 @@ export function show_edit_bot_info_modal(user_id: number, $container: JQuery): v contentType: false, success() { $("#bot-edit-form-error").hide(); + $("#edit-webhook-secret").val(""); avatar_widget.clear(); hide_button_spinner($submit_button); original_values = get_current_values($("#bot-edit-form")); diff --git a/web/styles/portico/integrations_dev_panel.css b/web/styles/portico/integrations_dev_panel.css index fdf156382f823..31394093e93a6 100644 --- a/web/styles/portico/integrations_dev_panel.css +++ b/web/styles/portico/integrations_dev_panel.css @@ -105,7 +105,8 @@ } #stream_name, -#topic_name { +#topic_name, +#webhook_secret { width: 206px; } diff --git a/web/templates/settings/add_new_bot_form.hbs b/web/templates/settings/add_new_bot_form.hbs index 14c89e4fd2b15..b5dadbff1346a 100644 --- a/web/templates/settings/add_new_bot_form.hbs +++ b/web/templates/settings/add_new_bot_form.hbs @@ -53,6 +53,13 @@
+
+
+ + +
+
+
{{#each realm_embedded_bots}} {{#each (object_entries config) as |entry|}} diff --git a/web/templates/settings/edit_bot_form.hbs b/web/templates/settings/edit_bot_form.hbs index 4d71e501168b8..9b460fc21eb4c 100644 --- a/web/templates/settings/edit_bot_form.hbs +++ b/web/templates/settings/edit_bot_form.hbs @@ -39,8 +39,15 @@ widget_name="edit_bot_owner" label=(t 'Owner')}} -
+
+ + {{#if is_incoming_webhook_bot}} +
+ +
+ {{/if}} +
{{!-- Shows the current avatar --}} @@ -109,6 +116,18 @@ }}
{{/if}} + + {{#if is_incoming_webhook_bot}} +
+ {{> ../components/action_button + label=(t "Delete secret") + variant="subtle" + intent="danger" + id="clear_webhook_secret_button" + }} +
+ {{/if}} +
{{#if is_active}} {{> ../components/action_button diff --git a/web/tests/compose_closed_ui.test.cjs b/web/tests/compose_closed_ui.test.cjs index 974fe6769ff2d..938d85aca39ba 100644 --- a/web/tests/compose_closed_ui.test.cjs +++ b/web/tests/compose_closed_ui.test.cjs @@ -251,6 +251,34 @@ run_test("test_non_message_list_input", ({mock_template}) => { assert.equal(label, "translated: Compose message"); }); +run_test("recipient_label_text_for_call_creation", () => { + recent_view_util.is_visible = () => true; + message_lists.current = undefined; + const stream = make_stream({ + subscribed: true, + name: "stream test", + stream_id: 11, + }); + stream_data.add_sub_for_tests(stream); + + assert.equal( + compose_closed_ui.get_recipient_label({ + stream_id: stream.stream_id, + topic: "topic test", + })?.label_text, + "#stream test > topic test", + ); + + // Empty topic falls back to the realm's empty-topic display name. + assert.equal( + compose_closed_ui.get_recipient_label({ + stream_id: stream.stream_id, + topic: "", + })?.label_text, + `#stream test > translated: ${REALM_EMPTY_TOPIC_DISPLAY_NAME}`, + ); +}); + run_test("update_reply_button_state", ({override, override_rewire}) => { const $compose_reply_wrapper = $("#legacy-closed-compose-box .compose-reply-button-wrapper"); const $reply_button = $(".compose_reply_button"); diff --git a/zerver/actions/data_import.py b/zerver/actions/data_import.py index 75a4ec93e41a2..9093db6b3e973 100644 --- a/zerver/actions/data_import.py +++ b/zerver/actions/data_import.py @@ -112,6 +112,7 @@ def import_slack_data(event: dict[str, Any]) -> None: fh.name, output_dir, event["slack_access_token"], + convert_slack_threads=True, ) attachment_size = sum( os.path.getsize(os.path.join(dirpath, filename)) @@ -189,7 +190,7 @@ def record_created_realm(new_realm: Realm) -> None: preregistration_realm.save() logger.info("(%s) All done!", string_id) except Exception as e: - logger.exception(e) + logger.exception("Error during Slack import") try: preregistration_realm.created_realm = None preregistration_realm.data_import_metadata["is_import_work_queued"] = False diff --git a/zerver/decorator.py b/zerver/decorator.py index dd385b412131c..039e89d0199f3 100644 --- a/zerver/decorator.py +++ b/zerver/decorator.py @@ -412,7 +412,7 @@ def _wrapped_func_arguments( # the sender's fault, so tell the owner notify_bot_owner_about_invalid_json(user_profile, webhook_client_name) - raise err + raise # Store the event types registered for this webhook as an attribute, which can be access # later conveniently in zerver.lib.test_classes.WebhookTestCase. @@ -828,7 +828,7 @@ def _wrapped_func_arguments( return view_func(request, user_profile, *args, **kwargs) except Exception as err: if not webhook_client_name: - raise err + raise if not isinstance(err, JsonableError): # An unexpected exception of some form -- log it @@ -840,7 +840,7 @@ def _wrapped_func_arguments( err.webhook_name = webhook_client_name log_exception_to_webhook_logger(request, err) - raise err + raise return _wrapped_func_arguments diff --git a/zerver/forms.py b/zerver/forms.py index 30b8c43823600..d2ef200ab3b52 100644 --- a/zerver/forms.py +++ b/zerver/forms.py @@ -405,8 +405,8 @@ def validate_captcha_payload(request: HttpRequest, captcha_payload: str) -> None raise forms.ValidationError(_("Validation failed, please try again.")) except forms.ValidationError: raise - except Exception as e: - logging.exception(e) + except Exception: + logging.exception("Error while validating altcha solution") raise forms.ValidationError(_("Validation failed, please try again.")) captcha_data = orjson.loads(base64.b64decode(captcha_payload)) diff --git a/zerver/lib/avatar.py b/zerver/lib/avatar.py index d518cd837fcd7..3e5bcd83b12dc 100644 --- a/zerver/lib/avatar.py +++ b/zerver/lib/avatar.py @@ -206,9 +206,9 @@ def generate_avatar_jdenticon(input: str, medium: bool) -> bytes: try: stdout = subprocess.check_output(command) return stdout - except subprocess.CalledProcessError as error: # nocoverage + except subprocess.CalledProcessError: # nocoverage logger.exception("Jdenticon generation failed for user_id: %s", input) - raise error + raise def generate_and_upload_jdenticon_avatar( diff --git a/zerver/lib/cache.py b/zerver/lib/cache.py index 6ab1b604d20a5..6d2ad85f207de 100644 --- a/zerver/lib/cache.py +++ b/zerver/lib/cache.py @@ -251,8 +251,8 @@ def cache_set( val = (val,) try: cache_backend.set(final_key, val, timeout=timeout) - except MemcachedException as e: - logger.exception(e) + except MemcachedException: + logger.exception("Error while storing to cache") remote_cache_stats_finish() @@ -306,8 +306,8 @@ def cache_set_many( remote_cache_stats_start() try: get_cache_backend(cache_name).set_many(items, timeout=timeout) - except MemcachedException as e: - logger.exception(e) + except MemcachedException: + logger.exception("Error while storing to cache") remote_cache_stats_finish() diff --git a/zerver/lib/email_mirror_server.py b/zerver/lib/email_mirror_server.py index 512d3465b45dc..763293f019423 100644 --- a/zerver/lib/email_mirror_server.py +++ b/zerver/lib/email_mirror_server.py @@ -64,8 +64,8 @@ def send_to_postmaster(msg: email.message.Message) -> None: e.smtp_error, stack_info=True, ) - except smtplib.SMTPException as e: - logger.exception("Error sending bounce email to %s: %s", mail.to, str(e), stack_info=True) + except smtplib.SMTPException: + logger.exception("Error sending bounce email to %s", mail.to, stack_info=True) class ZulipMessageHandler(MessageHandler): diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index 9619dc2789f3b..8a1a62b8f5ece 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -149,7 +149,7 @@ def f(label: str, event: dict[str, object]) -> None: del event["id"] try: validate_with_model(event, base_model) - except Exception as e: # nocoverage + except Exception: # nocoverage print(f""" FAILURE: @@ -170,7 +170,7 @@ def f(label: str, event: dict[str, object]) -> None: """) PrettyPrinter(indent=4).pprint(event) - raise e + raise return f diff --git a/zerver/lib/import_realm.py b/zerver/lib/import_realm.py index cf6ee8a8f7605..42792f5bb57c2 100644 --- a/zerver/lib/import_realm.py +++ b/zerver/lib/import_realm.py @@ -1071,7 +1071,7 @@ def fix_subscriptions_is_user_active_column( def process_avatars(sanitized_record: SanitizedRecord) -> None: if not sanitized_record.safe_resolved_source_path.endswith(".original"): - return None + return user_profile = get_user_profile_by_id(sanitized_record.raw_record["user_profile_id"]) if settings.LOCAL_AVATARS_DIR is not None: avatar_path = user_avatar_base_path_from_ids( diff --git a/zerver/lib/push_notifications.py b/zerver/lib/push_notifications.py index 51d39c93c823b..4bbc32d1f998d 100644 --- a/zerver/lib/push_notifications.py +++ b/zerver/lib/push_notifications.py @@ -185,6 +185,11 @@ def get_apns_context() -> APNsContext | None: if not has_apns_credentials(): # nocoverage return None + key: str | None = None + if settings.APNS_TOKEN_KEY_FILE: # nocoverage + with open(settings.APNS_TOKEN_KEY_FILE) as f: + key = f.read() + # NB if called concurrently, this will make excess connections. # That's a little sloppy, but harmless unless a server gets # hammered with a ton of these all at once after startup. @@ -200,10 +205,6 @@ async def err_func( pass # nocoverage async def make_apns() -> aioapns.APNs: - key: str | None = None - if settings.APNS_TOKEN_KEY_FILE: # nocoverage - with open(settings.APNS_TOKEN_KEY_FILE) as f: - key = f.read() return aioapns.APNs( client_cert=settings.APNS_CERT_FILE, key=key, @@ -1566,7 +1567,7 @@ def send_push_notifications( if is_test_notification: # Propagate the exception to the caller to notify the client # about the error while attempting to send test push notification. - raise e + raise return diff --git a/zerver/lib/send_email.py b/zerver/lib/send_email.py index 98aa223706c04..ee85b5a514d2d 100644 --- a/zerver/lib/send_email.py +++ b/zerver/lib/send_email.py @@ -352,7 +352,7 @@ def send_immediate_email( connection.close() raise EmailNotDeliveredError from e except (smtplib.SMTPException, OSError) as e: - logger.exception("Error sending %s email to %s: %s", template, mail.to, e, stack_info=True) + logger.exception("Error sending %s email to %s", template, mail.to, stack_info=True) connection.close() raise EmailNotDeliveredError from e @@ -474,9 +474,9 @@ def send_future_email( assert len(to_emails) == 1 email.address = parseaddr(to_emails[0])[1] email.save() - except Exception as e: + except Exception: email.delete() - raise e + raise def send_email_to_admins( diff --git a/zerver/lib/test_classes.py b/zerver/lib/test_classes.py index 6f3c061f8644e..2f57f637caa28 100644 --- a/zerver/lib/test_classes.py +++ b/zerver/lib/test_classes.py @@ -1,5 +1,7 @@ import asyncio import base64 +import hashlib +import hmac import os import re import shutil @@ -35,6 +37,7 @@ from django.test.testcases import SerializeMixin from django.urls import resolve from django.utils import translation +from django.utils.encoding import force_bytes from django.utils.module_loading import import_string from django.utils.timezone import now as timezone_now from fakeldap import MockLDAP @@ -54,6 +57,7 @@ from zerver.actions.user_settings import do_change_full_name, do_change_user_setting from zerver.actions.users import do_change_user_role from zerver.decorator import do_two_factor_login +from zerver.lib.bot_config import set_bot_config from zerver.lib.cache import bounce_key_prefix_for_testing from zerver.lib.email_notifications import MissedMessageData, handle_missedmessage_emails from zerver.lib.initial_password import initial_password @@ -2544,6 +2548,8 @@ class WebhookTestCase(ZulipTestCase): DEFAULT_URL_TEMPLATE: str = ( "/api/v1/external/{webhook_dir_name}?stream={stream}&api_key={api_key}" ) + WEBHOOK_SIGNATURE_HEADER: str | None = None + WEBHOOK_TEST_SECRET: str | None = None def get_webhook_dir_name(self) -> str: module_parts = self.__module__.split(".") @@ -2619,6 +2625,18 @@ def api_channel_message( **extra, ) + def get_webhook_signature(self, raw_payload: bytes) -> str | None: + """ + Generate the signature header value for a given payload. + Override this method in child classes if the integration uses different signature format. + """ + secret = getattr(self, "WEBHOOK_TEST_SECRET", None) + if secret is None: + return None # nocoverage + + # Default implementation matches the current GitHub standard format + return "sha256=" + hmac.new(force_bytes(secret), raw_payload, hashlib.sha256).hexdigest() + def check_webhook( self, fixture_name: str, @@ -2650,16 +2668,37 @@ def check_webhook( """ self.subscribe(self.test_user, self.channel_name) + url = getattr(self, "url", None) + if url is None: + url = self.build_webhook_url() # nocoverage + + webhook_secret = getattr(self, "WEBHOOK_TEST_SECRET", None) + if webhook_secret is not None: + set_bot_config(self.test_user, "webhook_secret", webhook_secret) + payload = self.get_payload(fixture_name) if content_type is not None: extra["content_type"] = content_type + + signature_header_name = getattr(self, "WEBHOOK_SIGNATURE_HEADER", None) + if signature_header_name is not None: + try: + raw_payload = self.get_body(fixture_name) + except FileNotFoundError: # nocoverage + raw_payload = "" + + signature_value = self.get_webhook_signature(force_bytes(raw_payload)) + if signature_value is not None: + django_header = "HTTP_" + signature_header_name.upper().replace("-", "_") + extra[django_header] = signature_value + headers = call_fixture_to_headers(self.webhook_dir_name, fixture_name) headers = standardize_headers(headers) extra.update(headers) try: msg = self.send_webhook_payload( self.test_user, - self.url, + url, payload, **extra, ) @@ -2715,6 +2754,11 @@ def send_and_test_private_message( Most webhooks send to streams, and you will want to look at check_webhook. """ + + webhook_secret = getattr(self, "WEBHOOK_TEST_SECRET", None) + if webhook_secret is not None: + set_bot_config(self.test_user, "webhook_secret", webhook_secret) # nocoverage + payload = self.get_payload(fixture_name) extra["content_type"] = content_type diff --git a/zerver/lib/thumbnail.py b/zerver/lib/thumbnail.py index 67e651364204d..ff2867c7ac03a 100644 --- a/zerver/lib/thumbnail.py +++ b/zerver/lib/thumbnail.py @@ -194,8 +194,8 @@ def libvips_check_image( try: yield source_image - except pyvips.Error as e: # nocoverage - logging.exception(e) + except pyvips.Error: # nocoverage + logging.exception("Error while processing image") raise BadImageError(_("Image is corrupted or truncated")) diff --git a/zerver/lib/webhooks/common.py b/zerver/lib/webhooks/common.py index ac2d8ba7e7a16..649575dc85535 100644 --- a/zerver/lib/webhooks/common.py +++ b/zerver/lib/webhooks/common.py @@ -25,6 +25,7 @@ check_send_stream_message_by_id, send_rate_limited_pm_notification_to_bot_owner, ) +from zerver.lib.bot_config import ConfigError, get_bot_config from zerver.lib.exceptions import ( AnomalousWebhookPayloadError, ErrorCode, @@ -320,8 +321,45 @@ def parse_multipart_string(body: str) -> dict[str, str]: return data +def validate_webhook_delivery( + request: HttpRequest, signature_header_name: str, algorithm: str = "sha256" +) -> None: + assert request.user.is_authenticated + user_profile = request.user + assert isinstance(user_profile, UserProfile) + + try: + config = get_bot_config(user_profile) + webhook_secret = config.get("webhook_secret", "") + except ConfigError: + webhook_secret = "" + + if not webhook_secret: + return + + signature_header = request.headers.get(signature_header_name, "") + signature = signature_header.split("=")[-1] if "=" in signature_header else signature_header + + payload = request.body.decode("utf-8") + + try: + validate_webhook_signature( + payload=payload, + signature=signature, + secret=webhook_secret, + algorithm=algorithm, + ) + except JsonableError: + raise + except Exception as err: # nocoverage + raise JsonableError(str(err)) + + def validate_webhook_signature( - request: HttpRequest, payload: str, signature: str, algorithm: str = "sha256" + payload: str, + signature: str, + secret: str, + algorithm: str = "sha256", ) -> None: if not settings.VERIFY_WEBHOOK_SIGNATURES: # nocoverage return @@ -331,14 +369,10 @@ def validate_webhook_signature( _("The algorithm '{algorithm}' is not supported.").format(algorithm=algorithm) ) - webhook_secret: str | None = request.GET.get("webhook_secret") - if webhook_secret is None: - raise JsonableError( - _( - "The webhook secret is missing. Please set the webhook_secret while generating the URL." - ) - ) - webhook_secret_bytes = force_bytes(webhook_secret) + if not secret: + raise JsonableError(_("Webhook secret is not configured for this bot.")) + + webhook_secret_bytes = force_bytes(secret) payload_bytes = force_bytes(payload) signed_payload = hmac.new( diff --git a/zerver/lib/zulip_update_announcements.py b/zerver/lib/zulip_update_announcements.py index 97a90a78d73c2..4bb3c12282321 100644 --- a/zerver/lib/zulip_update_announcements.py +++ b/zerver/lib/zulip_update_announcements.py @@ -809,8 +809,8 @@ def send_zulip_update_announcements(skip_delay: bool, progress: bool = False) -> for i, realm in enumerate(realms, start=1): try: send_zulip_update_announcements_to_realm(realm, skip_delay) - except Exception as e: # nocoverage - logging.exception(e) + except Exception: # nocoverage + logging.exception("Error while sending update announcements") finally: if progress and i % 50 == 0: # nocoverage print(f"Processed {i}/{len(realms)} realms...") diff --git a/zerver/management/commands/send_password_reset_email.py b/zerver/management/commands/send_password_reset_email.py index 1c2bcd19073f3..1ccd497fd0d15 100644 --- a/zerver/management/commands/send_password_reset_email.py +++ b/zerver/management/commands/send_password_reset_email.py @@ -45,7 +45,7 @@ def handle(self, *args: Any, **options: str) -> None: raise CommandError( "You have to pass -u/--users or -a/--all-users or --entire-server." ) - raise error + raise if options["only_never_logged_in"]: users = users.filter(tos_version=-1) diff --git a/zerver/middleware.py b/zerver/middleware.py index 217108eed4d40..c23cac63cb7ce 100644 --- a/zerver/middleware.py +++ b/zerver/middleware.py @@ -299,8 +299,8 @@ def process_request(self, request: HttpRequest) -> None: try: request_notes.client_name, request_notes.client_version = parse_client(request) - except JsonableError as e: - logging.exception(e) + except JsonableError: + logging.exception("Error while parsing client from request") request_notes.client_name = "Unparsable" request_notes.client_version = None diff --git a/zerver/tests/test_bots.py b/zerver/tests/test_bots.py index 63769fa63f3a6..a2fda496db7e8 100644 --- a/zerver/tests/test_bots.py +++ b/zerver/tests/test_bots.py @@ -2307,6 +2307,85 @@ def test_create_incoming_webhook_bot_with_incorrect_service_name(self) -> None: with self.assertRaises(UserProfile.DoesNotExist): UserProfile.objects.get(full_name="My Stripe Bot") + def test_create_incoming_webhook_bot_with_secret(self) -> None: + self.login("hamlet") + self.assert_num_bots_equal(0) + self.create_bot( + bot_type=UserProfile.INCOMING_WEBHOOK_BOT, + config_data=orjson.dumps({"webhook_secret": "test-secret-key-123"}).decode(), + ) + self.assert_num_bots_equal(1) + + new_bot = UserProfile.objects.get(full_name="The Bot of Hamlet") + config_data = get_bot_config(new_bot) + self.assertEqual(config_data["webhook_secret"], "test-secret-key-123") + + def test_create_incoming_webhook_bot_without_secret(self) -> None: + self.login("hamlet") + self.assert_num_bots_equal(0) + self.create_bot(bot_type=UserProfile.INCOMING_WEBHOOK_BOT) + self.assert_num_bots_equal(1) + + new_bot = UserProfile.objects.get(full_name="The Bot of Hamlet") + + with self.assertRaisesMessage(ConfigError, "No config data available."): + get_bot_config(new_bot) + + def test_patch_incoming_webhook_bot_add_secret(self) -> None: + self.login("hamlet") + self.create_bot(bot_type=UserProfile.INCOMING_WEBHOOK_BOT) + + bot_email = "hambot-bot@zulip.testserver" + bot_id = self.get_bot_user(bot_email).id + + bot_update = { + "config_data": orjson.dumps({"webhook_secret": "test-secret-key-123"}).decode() + } + result = self.client_patch(f"/json/bots/{bot_id}", bot_update) + self.assert_json_success(result) + + bot = self.get_bot_user(bot_email) + config_data = get_bot_config(bot) + self.assertEqual(config_data["webhook_secret"], "test-secret-key-123") + + def test_patch_incoming_webhook_bot_update_secret(self) -> None: + self.login("hamlet") + self.create_bot( + bot_type=UserProfile.INCOMING_WEBHOOK_BOT, + config_data=orjson.dumps({"webhook_secret": "old-test-secret-123"}).decode(), + ) + + bot_email = "hambot-bot@zulip.testserver" + bot_id = self.get_bot_user(bot_email).id + + bot_update = { + "config_data": orjson.dumps({"webhook_secret": "new-test-secret-123"}).decode() + } + result = self.client_patch(f"/json/bots/{bot_id}", bot_update) + self.assert_json_success(result) + + bot = self.get_bot_user(bot_email) + config_data = get_bot_config(bot) + self.assertEqual(config_data["webhook_secret"], "new-test-secret-123") + + def test_patch_incoming_webhook_bot_clear_secret(self) -> None: + self.login("hamlet") + self.create_bot( + bot_type=UserProfile.INCOMING_WEBHOOK_BOT, + config_data=orjson.dumps({"webhook_secret": "test-secret-key-123"}).decode(), + ) + + bot_email = "hambot-bot@zulip.testserver" + bot_id = self.get_bot_user(bot_email).id + + bot_update = {"config_data": orjson.dumps({"webhook_secret": ""}).decode()} + result = self.client_patch(f"/json/bots/{bot_id}", bot_update) + self.assert_json_success(result) + + bot = self.get_bot_user(bot_email) + config_data = get_bot_config(bot) + self.assertEqual(config_data["webhook_secret"], "") + def test_get_bot_api_key(self) -> None: self.login("hamlet") self.create_bot() diff --git a/zerver/tests/test_email_mirror.py b/zerver/tests/test_email_mirror.py index cd2555b46fde9..efc0f3f35aade 100644 --- a/zerver/tests/test_email_mirror.py +++ b/zerver/tests/test_email_mirror.py @@ -2107,11 +2107,14 @@ def test_send_postmaster_failure(self) -> None: self.assertLogs("zerver.lib.email_mirror", "ERROR") as error_log, ): send_to_postmaster(email) - self.assert_length(error_log.output, 1) + self.assert_length(error_log.records, 1) + self.assertEqual(error_log.records[0].levelname, "ERROR") self.assertEqual( - error_log.output[0].splitlines()[0], - "ERROR:zerver.lib.email_mirror:Error sending bounce email to ['desdemona+admin@zulip.com']: moose", + error_log.records[0].getMessage(), + "Error sending bounce email to ['desdemona+admin@zulip.com']", ) + assert error_log.records[0].exc_info is not None + self.assertEqual(str(error_log.records[0].exc_info[1]), "moose") with ( mock.patch.object( diff --git a/zerver/tests/test_integrations_dev_panel.py b/zerver/tests/test_integrations_dev_panel.py index ad46910f00f3a..7188affc10bb2 100644 --- a/zerver/tests/test_integrations_dev_panel.py +++ b/zerver/tests/test_integrations_dev_panel.py @@ -1,3 +1,5 @@ +import hashlib +import hmac from unittest.mock import MagicMock, patch import orjson @@ -339,3 +341,155 @@ def test_send_all_webhook_fixture_messages_for_missing_fixtures( } self.assertEqual(response.status_code, 404) self.assertEqual(orjson.loads(response.content), expected_response) + + def test_recalculate_signature_method_not_allowed(self) -> None: + target_url = "/devtools/integrations/recalculate_signature" + # The endpoint expects a POST request. GET should fail with 405. + response = self.client_get(target_url) + self.assertEqual(response.status_code, 405) + self.assertEqual(orjson.loads(response.content), {"error": "Method not allowed"}) + + def test_recalculate_signature_unsupported_integration(self) -> None: + target_url = "/devtools/integrations/recalculate_signature" + data = { + "secret": "my_secret", + "payload": '{"event": "ping"}', + "integration_name": "unsupported_platform", + } + response = self.client_post(target_url, data, content_type="application/json") + self.assertEqual(response.status_code, 200) + + expected_response = { + "supported": False, + "msg": "No signature rules configured for this platform.", + } + self.assertEqual(orjson.loads(response.content), expected_response) + + def test_recalculate_signature_empty_secret_triggers_clear(self) -> None: + target_url = "/devtools/integrations/recalculate_signature" + data = { + "secret": "", + "payload": '{"event": "ping"}', + "integration_name": "github", + } + response = self.client_post(target_url, data, content_type="application/json") + self.assertEqual(response.status_code, 200) + + expected_response = {"supported": True, "clear_signature": True} + self.assertEqual(orjson.loads(response.content), expected_response) + + def test_recalculate_signature_success_with_json_payload(self) -> None: + target_url = "/devtools/integrations/recalculate_signature" + secret = "github_webhook_secret" + + payload = '{\n "zen": "Non-blocking is better than blocking."\n}' + + data = { + "secret": secret, + "payload": payload, + "integration_name": "github ", # Tests trimming behavior + } + + # Manually compute the expected HMAC hash of minified JSON + minified_payload_bytes = orjson.dumps(orjson.loads(payload)) + expected_hash = hmac.new( + secret.encode(), minified_payload_bytes, hashlib.sha256 + ).hexdigest() + + response = self.client_post(target_url, data, content_type="application/json") + self.assertEqual(response.status_code, 200) + + expected_response = { + "supported": True, + "clear_signature": False, + "header_key": "X_HUB_SIGNATURE_256", + "signature": f"sha256={expected_hash}", + } + self.assertEqual(orjson.loads(response.content), expected_response) + + def test_recalculate_signature_success_with_non_json_payload(self) -> None: + target_url = "/devtools/integrations/recalculate_signature" + secret = "github_webhook_secret" + payload = "plain-text-payload-string" + + data = { + "secret": secret, + "payload": payload, + "integration_name": "GITHUB", + } + + # Falls back to plain text bytes computation upon JSON extraction failure + expected_hash = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest() + + response = self.client_post(target_url, data, content_type="application/json") + self.assertEqual(response.status_code, 200) + + expected_response = { + "supported": True, + "clear_signature": False, + "header_key": "X_HUB_SIGNATURE_256", + "signature": f"sha256={expected_hash}", + } + self.assertEqual(orjson.loads(response.content), expected_response) + + def test_recalculate_signature_exception_handling(self) -> None: + target_url = "/devtools/integrations/recalculate_signature" + + # Sending a malformed request context (e.g. string payload instead of valid json object) + # to force the parsing logic down the general exception handling path. + response = self.client_post( + target_url, "invalid_json_body", content_type="application/json" + ) + self.assertEqual(response.status_code, 400) + + response_data = orjson.loads(response.content) + self.assertIn("error", response_data) + + def test_sync_signature_headers_endpoint_success(self) -> None: + """Tests the backend counterpart of sync_signature_headers for a valid integration.""" + target_url = "/devtools/integrations/recalculate_signature" + data = { + "secret": "my_webhook_secret", + "payload": '{"event": "ping"}', + "integration_name": "github", + } + + response = self.client_post(target_url, data, content_type="application/json") + self.assertEqual(response.status_code, 200) + + response_data = orjson.loads(response.content) + self.assertTrue(response_data["supported"]) + self.assertFalse(response_data["clear_signature"]) + self.assertEqual(response_data["header_key"], "X_HUB_SIGNATURE_256") + self.assertTrue(response_data["signature"].startswith("sha256=")) + + def test_sync_signature_headers_endpoint_empty_secret(self) -> None: + """Tests that passing an empty secret returns clear_signature=True to clear the UI instantly.""" + target_url = "/devtools/integrations/recalculate_signature" + data = { + "secret": "", + "payload": '{"event": "ping"}', + "integration_name": "github", + } + + response = self.client_post(target_url, data, content_type="application/json") + self.assertEqual(response.status_code, 200) + + response_data = orjson.loads(response.content) + self.assertTrue(response_data["supported"]) + self.assertTrue(response_data["clear_signature"]) + + def test_sync_signature_headers_endpoint_unsupported(self) -> None: + """Tests that an unregistered integration name returns supported=False to drop headers.""" + target_url = "/devtools/integrations/recalculate_signature" + data = { + "secret": "secret", + "payload": "{}", + "integration_name": "some_random_platform", + } + + response = self.client_post(target_url, data, content_type="application/json") + self.assertEqual(response.status_code, 200) + + response_data = orjson.loads(response.content) + self.assertFalse(response_data["supported"]) diff --git a/zerver/tests/test_send_email.py b/zerver/tests/test_send_email.py index 6f1292d37b2a6..659ffbf90a644 100644 --- a/zerver/tests/test_send_email.py +++ b/zerver/tests/test_send_email.py @@ -411,18 +411,22 @@ def test_send_email_exceptions(self) -> None: self.assertEqual(mail.extra_headers["From"], f"{from_name} <{FromAddress.NOREPLY}>") # We test the cases that should raise an EmailNotDeliveredError - errors = { - f"Unknown error sending password_reset email to {mail.to}": [0], - f"Error sending password_reset email to {mail.to}": [SMTPException()], - f"Error sending password_reset email to {mail.to}: {{'{address}': (550, b'User unknown')}}": [ - SMTPRecipientsRefused(recipients={address: (550, b"User unknown")}) - ], - f"Error sending password_reset email to {mail.to} with error code 242: From field too long": [ - SMTPDataError(242, "From field too long.") - ], - } + errors = [ + (f"Unknown error sending password_reset email to {mail.to}", None, [0]), + (f"Error sending password_reset email to {mail.to}", "", [SMTPException()]), + ( + f"Error sending password_reset email to {mail.to}", + f"{{{address!r}: (550, b'User unknown')}}", + [SMTPRecipientsRefused(recipients={address: (550, b"User unknown")})], + ), + ( + f"Error sending password_reset email to {mail.to} with error code 242: From field too long.", + "(242, 'From field too long.')", + [SMTPDataError(242, "From field too long.")], + ), + ] - for message, side_effect in errors.items(): + for message, exc_message, side_effect in errors: with mock.patch.object(EmailBackend, "send_messages", side_effect=side_effect): with ( self.assertLogs(logger=logger) as info_log, @@ -436,11 +440,16 @@ def test_send_email_exceptions(self) -> None: language="en", ) self.assert_length(info_log.records, 2) + self.assertEqual(info_log.records[0].levelname, "INFO") + self.assertEqual( + info_log.records[0].getMessage(), f"Sending password_reset email to {mail.to}" + ) + self.assertEqual(info_log.records[1].levelname, "ERROR") + self.assertEqual(info_log.records[1].getMessage(), message) self.assertEqual( - info_log.output[0], - f"INFO:{logger.name}:Sending password_reset email to {mail.to}", + info_log.records[1].exc_info and str(info_log.records[1].exc_info[1]), + exc_message, ) - self.assertTrue(info_log.output[1].startswith(f"ERROR:zulip.send_email:{message}")) def test_send_email_config_error_logging(self) -> None: hamlet = self.example_user("hamlet") diff --git a/zerver/tests/test_slack_importer.py b/zerver/tests/test_slack_importer.py index 56a9317dcc4ec..07de67f3bb510 100644 --- a/zerver/tests/test_slack_importer.py +++ b/zerver/tests/test_slack_importer.py @@ -2899,7 +2899,7 @@ def fake_do_import_realm(*args: object, **kwargs: object) -> Realm: mock_save_attachment_contents.assert_called_once() mock_do_convert_zipfile.assert_called_once_with( - mock.ANY, mock.ANY, event["slack_access_token"] + mock.ANY, mock.ANY, event["slack_access_token"], convert_slack_threads=True ) mock_do_import_realm.assert_called_once_with( mock.ANY, prereg_realm.string_id, on_realm_created=mock.ANY @@ -3067,7 +3067,7 @@ def fake_do_import_realm(*args: object, **kwargs: object) -> Realm: mock_save_attachment_contents.assert_called_once() mock_do_convert_zipfile.assert_called_once_with( - mock.ANY, mock.ANY, event["slack_access_token"] + mock.ANY, mock.ANY, event["slack_access_token"], convert_slack_threads=True ) mock_do_import_realm.assert_called_once_with( mock.ANY, prereg_realm.string_id, on_realm_created=mock.ANY diff --git a/zerver/tests/test_webhooks_common.py b/zerver/tests/test_webhooks_common.py index 02db0bdd1ce09..48ea22a889aaf 100644 --- a/zerver/tests/test_webhooks_common.py +++ b/zerver/tests/test_webhooks_common.py @@ -14,6 +14,7 @@ from zerver.actions.custom_profile_fields import try_add_realm_custom_profile_field from zerver.actions.streams import do_rename_stream from zerver.decorator import webhook_view +from zerver.lib.bot_config import set_bot_config from zerver.lib.exceptions import InvalidJSONError, JsonableError from zerver.lib.request import RequestNotes from zerver.lib.send_email import FromAddress @@ -29,6 +30,7 @@ get_service_api_data, guess_zulip_user_from_external_account, standardize_headers, + validate_webhook_delivery, validate_webhook_signature, ) from zerver.models import Client, CustomProfileField, Message, UserProfile @@ -152,18 +154,14 @@ def test_standardize_headers(self) -> None: @override_settings(VERIFY_WEBHOOK_SIGNATURES=True) def test_validate_webhook_signature(self) -> None: - request = HostRequestMock() - request.GET = QueryDict("", mutable=True) - - # Valid signature webhook_secret = "test_secret" payload = '{"key": "value"}' signature = hmac.new( force_bytes(webhook_secret), force_bytes(payload), hashlib.sha256 ).hexdigest() - request.GET.update({"webhook_secret": webhook_secret}) - validate_webhook_signature(request, payload, signature) + # Valid signature + validate_webhook_signature(payload, signature, webhook_secret) # Invalid signature invalid_signature = "invalid_signature" @@ -171,15 +169,47 @@ def test_validate_webhook_signature(self) -> None: JsonableError, "Webhook signature verification failed.", ): - validate_webhook_signature(request, payload, invalid_signature) + validate_webhook_signature(payload, invalid_signature, webhook_secret) - # No webhook_secret parameter - request.GET.clear() + # Missing or empty secret with self.assertRaisesRegex( JsonableError, - "The webhook secret is missing. Please set the webhook_secret while generating the URL.", + "Webhook secret is not configured for this bot.", ): - validate_webhook_signature(request, payload, signature) + validate_webhook_signature(payload, signature, secret="") + + @override_settings(VERIFY_WEBHOOK_SIGNATURES=True) + def test_validate_webhook_delivery(self) -> None: + webhook_bot = get_user("webhook-bot@zulip.com", get_realm("zulip")) + webhook_secret = "test_secret" + payload = '{"key": "value"}' + signature = hmac.new( + force_bytes(webhook_secret), force_bytes(payload), hashlib.sha256 + ).hexdigest() + + set_bot_config(webhook_bot, "webhook_secret", webhook_secret) + request = HostRequestMock(meta_data={"HTTP_X_HUB_SIGNATURE_256": f"sha256={signature}"}) + request.user = webhook_bot + request.GET = QueryDict("", mutable=True) + request._body = force_bytes(payload) + + # Valid signature + validate_webhook_delivery(request, "X_HUB_Signature_256") + + # Invalid signature + request.META["HTTP_X_HUB_SIGNATURE_256"] = "sha256=invalid_signature" + del request.headers + with self.assertRaisesRegex( + JsonableError, + "Webhook signature verification failed.", + ): + validate_webhook_delivery(request, "X_HUB_Signature_256") + + # No webhook_secret configured for this bot skips validation + set_bot_config(webhook_bot, "webhook_secret", "") + request.META["HTTP_X_HUB_SIGNATURE_256"] = f"sha256={signature}" + del request.headers + validate_webhook_delivery(request, "X_HUB_Signature_256") def test_check_send_webhook_message_returns_id(self) -> None: webhook_bot = get_user("webhook-bot@zulip.com", get_realm("zulip")) diff --git a/zerver/views/antispam.py b/zerver/views/antispam.py index aafc41123dae2..89c4117f70397 100644 --- a/zerver/views/antispam.py +++ b/zerver/views/antispam.py @@ -48,6 +48,6 @@ def get_challenge( (challenge.challenge, expires.timestamp()), ] return json_success(request, data=challenge.__dict__) - except Exception as e: # nocoverage - logging.exception(e) + except Exception: # nocoverage + logging.exception("Error while generating challenge") raise JsonableError(_("Failed to generate challenge")) diff --git a/zerver/views/development/integrations.py b/zerver/views/development/integrations.py index a3ec1ce71221c..6c524999d13f7 100644 --- a/zerver/views/development/integrations.py +++ b/zerver/views/development/integrations.py @@ -1,12 +1,17 @@ +import hashlib +import hmac import os +from collections.abc import Callable from contextlib import suppress from typing import TYPE_CHECKING, Any import orjson -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, JsonResponse from django.http.response import HttpResponseBase from django.shortcuts import render from django.test import Client +from django.utils.encoding import force_bytes +from django.views.decorators.csrf import csrf_exempt from pydantic import Json from zerver.lib.exceptions import JsonableError, ResourceNotFoundError @@ -156,3 +161,63 @@ def send_all_webhook_fixture_messages( } ) return json_success(request, data={"responses": responses}) + + +def format_github_signature(secret_bytes: bytes, payload_bytes: bytes) -> tuple[str, str]: + """Formats signature header following X-Hub-Signature-256 standard.""" + signed_payload = hmac.new(secret_bytes, payload_bytes, hashlib.sha256).hexdigest() + return "X_HUB_SIGNATURE_256", f"sha256={signed_payload}" + + +SIGNATURE_REGISTRY: dict[str, Callable[[bytes, bytes], tuple[str, str]]] = { + "github": format_github_signature +} + + +@csrf_exempt +def recalculate_signature(request: HttpRequest) -> JsonResponse: + """ + Unified endpoint invoked by the frontend UI dev panel to dynamically compute + and format signature header blocks based on the integration. + """ + if request.method != "POST": + return JsonResponse({"error": "Method not allowed"}, status=405) + + try: + data = orjson.loads(request.body) + secret = data.get("secret", "") + payload_string = data.get("payload", "") + integration_name = data.get("integration_name", "").lower().strip() + + # Check if the integration has signature management registered + if integration_name not in SIGNATURE_REGISTRY: + return JsonResponse( + {"supported": False, "msg": "No signature rules configured for this platform."} + ) + + if not secret: + return JsonResponse({"supported": True, "clear_signature": True}) + + # Normalize and minify JSON formats for crypto verification stability + try: + payload_bytes = orjson.dumps(orjson.loads(payload_string)) + except Exception: + payload_bytes = force_bytes(payload_string) + + webhook_secret_bytes = force_bytes(secret) + + # Execute the registered structural format strategy + formatter = SIGNATURE_REGISTRY[integration_name] + header_key, header_value = formatter(webhook_secret_bytes, payload_bytes) + + return JsonResponse( + { + "supported": True, + "clear_signature": False, + "header_key": header_key, + "signature": header_value, + } + ) + + except Exception: + return JsonResponse({"error": "Invalid request payload."}, status=400) diff --git a/zerver/views/documentation.py b/zerver/views/documentation.py index fe64df3046c28..52ba609167e86 100644 --- a/zerver/views/documentation.py +++ b/zerver/views/documentation.py @@ -427,7 +427,7 @@ def get_categories_for_integration(integration: Integration) -> list[tuple[str, for display_name in integration.categories: slug = display_to_slug.get(display_name) assert slug is not None - result.append((slug, display_name)) + result.append((slug, str(display_name))) return result diff --git a/zerver/views/sentry.py b/zerver/views/sentry.py index 2310ee28ce508..f711a44823044 100644 --- a/zerver/views/sentry.py +++ b/zerver/views/sentry.py @@ -87,9 +87,9 @@ def sentry_tunnel( sentry_request(url, updated_body) except CircuitBreakerError: logger.warning("Dropped a client exception due to circuit-breaking") - except RequestException as e: + except RequestException: # This logger has been configured, above, to not report to Sentry - logger.exception(e) + logger.exception("Error while reporting to Sentry") return HttpResponse(status=200) diff --git a/zerver/webhooks/github/tests.py b/zerver/webhooks/github/tests.py index 90c563f0e2db2..5effc6bda0f07 100644 --- a/zerver/webhooks/github/tests.py +++ b/zerver/webhooks/github/tests.py @@ -1,7 +1,9 @@ from unittest.mock import patch import orjson +from django.test import override_settings +from zerver.lib.bot_config import set_bot_config from zerver.lib.message import truncate_topic from zerver.lib.test_classes import WebhookTestCase from zerver.lib.webhooks.git import COMMITS_LIMIT @@ -22,6 +24,9 @@ class GitHubWebhookTest(WebhookTestCase): + WEBHOOK_SIGNATURE_HEADER = "X_HUB_Signature_256" + WEBHOOK_TEST_SECRET = "testingthis" + def test_ping_event(self) -> None: expected_message = "GitHub webhook has been successfully configured by TomaszKolek." self.check_webhook("ping", TOPIC_REPO, expected_message) @@ -851,6 +856,49 @@ def test_issue_comment_silent_mention_with_multiple_matches(self) -> None: expected_message = "baxterthehacker [commented](https://github.com/baxterthehacker/public-repo/issues/2#issuecomment-99262140) on [issue #2](https://github.com/baxterthehacker/public-repo/issues/2):\n\n``` quote\nYou are totally right! I'll get this fixed right away.\n```" self.check_webhook("issue_comment", TOPIC_ISSUE, expected_message) + def test_github_webhook_bad_signature(self) -> None: + with override_settings(VERIFY_WEBHOOK_SIGNATURES=True): + url = self.build_webhook_url() + set_bot_config(self.test_user, "webhook_secret", self.WEBHOOK_TEST_SECRET) + + result = self.client_post( + url, + self.get_payload("ping"), + content_type="application/json", + HTTP_X_HUB_SIGNATURE_256="sha256=completely_invalid_hash_value", + ) + self.assert_json_error(result, "Webhook signature verification failed.") + + def test_github_webhook_signature_disabled_skips_validation(self) -> None: + """Verifies that when VERIFY_WEBHOOK_SIGNATURES is explicitly disabled, + requests pass through even if the signature value is completely bogus. + """ + with override_settings(VERIFY_WEBHOOK_SIGNATURES=False): + expected_message = "GitHub webhook has been successfully configured by TomaszKolek." + self.check_webhook( + "ping", + TOPIC_REPO, + expected_message, + HTTP_X_HUB_SIGNATURE_256="sha256=invalid_hash", + ) + + def test_github_webhook_valid_signature_success(self) -> None: + """Verifies that a mathematically correct HMAC signature passes + cleanly when verification enforcement is active.""" + expected_message = "GitHub webhook has been successfully configured by TomaszKolek." + + with override_settings(VERIFY_WEBHOOK_SIGNATURES=True): + self.check_webhook("ping", TOPIC_REPO, expected_message) + + def test_github_webhook_missing_secret(self) -> None: + """Verifies that if no webhook secret is configured for the bot, + the request is processed normally without requiring signature verification.""" + + with override_settings(VERIFY_WEBHOOK_SIGNATURES=True): + set_bot_config(self.test_user, "webhook_secret", "") + expected_message = "GitHub webhook has been successfully configured by TomaszKolek." + self.check_webhook("ping", TOPIC_REPO, expected_message) + class GitHubSponsorsHookTests(WebhookTestCase): URL_TEMPLATE = "/api/v1/external/githubsponsors?stream={stream}&api_key={api_key}" diff --git a/zerver/webhooks/github/view.py b/zerver/webhooks/github/view.py index c1a13b5ad0daf..b276df37fd713 100644 --- a/zerver/webhooks/github/view.py +++ b/zerver/webhooks/github/view.py @@ -22,6 +22,7 @@ get_event_header, get_setup_webhook_message, guess_zulip_user_from_external_account, + validate_webhook_delivery, ) from zerver.lib.webhooks.git import ( CONTENT_MESSAGE_TEMPLATE, @@ -1215,6 +1216,8 @@ def api_github_webhook( directly to the X-GitHub-Event header's event, but we sometimes refine it based on the payload. """ + validate_webhook_delivery(request, "X_HUB_Signature_256", "sha256") + header_event = get_event_header(request, "X-GitHub-Event", "GitHub") # Ignore events from private repositories if the URL option is set diff --git a/zilencer/auth.py b/zilencer/auth.py index a0ccad76ead17..ab0cf5b69923c 100644 --- a/zilencer/auth.py +++ b/zilencer/auth.py @@ -95,9 +95,9 @@ def rate_limit_remote_server( try: RateLimitedRemoteZulipServer(remote_server, domain=domain).rate_limit_request(request) - except RateLimitedError as e: + except RateLimitedError: logger.warning("Remote server %s exceeded rate limits on domain %s", remote_server, domain) - raise e + raise def validate_remote_server( diff --git a/zproject/dev_urls.py b/zproject/dev_urls.py index c57717f9fe2a1..d637ca8e497c5 100644 --- a/zproject/dev_urls.py +++ b/zproject/dev_urls.py @@ -24,6 +24,7 @@ check_send_webhook_fixture_message, dev_panel, get_fixtures, + recalculate_signature, send_all_webhook_fixture_messages, ) from zerver.views.development.registration import ( @@ -98,6 +99,10 @@ "devtools/integrations/send_all_webhook_fixture_messages", send_all_webhook_fixture_messages ), path("devtools/integrations//fixtures", get_fixtures), + path( + "devtools/integrations/recalculate_signature", + recalculate_signature, + ), path("config-error/", config_error, name="config_error"), # Special endpoint to remove all the server-side caches. path("flush_caches", remove_caches),