@@ -602,6 +602,47 @@ public function test_expired_subscriptions_deleted($notification_type, $post_dat
602602 }
603603 }
604604
605+ /**
606+ * @dataProvider data_notification_webpush
607+ */
608+ public function test_permanently_removed_subscriptions_deleted ($ notification_type , $ post_data , $ expected_users ): void
609+ {
610+ // Skip test if no expected users
611+ if (empty ($ expected_users ))
612+ {
613+ $ this ->assertTrue (true );
614+ return ;
615+ }
616+
617+ // Insert a permanently-removed.invalid subscription for the first user.
618+ // This simulates a dead subscription whose endpoint can never resolve (RFC 6761).
619+ $ first_user_id = array_key_first ($ expected_users );
620+ $ dead_endpoint = 'https://permanently-removed.invalid/fcm/send/test_dead_subscription ' ;
621+ $ this ->insert_subscription_for_user ($ first_user_id , $ dead_endpoint );
622+
623+ $ this ->assertEquals (1 , $ this ->get_subscription_count (), 'Expected 1 subscription before notification ' );
624+
625+ $ post_data = array_merge ([
626+ 'post_time ' => 1349413322 ,
627+ 'poster_id ' => 1 ,
628+ 'topic_title ' => '' ,
629+ 'post_subject ' => '' ,
630+ 'post_username ' => '' ,
631+ 'forum_name ' => '' ,
632+ ], $ post_data );
633+
634+ // Send notifications — should trigger cleanup of the permanently-removed subscription
635+ $ this ->notifications ->add_notifications ($ notification_type , $ post_data );
636+
637+ // The dead subscription should have been silently deleted
638+ $ this ->assertEquals (0 , $ this ->get_subscription_count (), 'Expected permanently-removed subscription to be deleted ' );
639+
640+ // Verify no admin log was written — unlike real delivery failures (which log errors),
641+ // permanently-removed endpoints should be silently cleaned up without noise.
642+ $ admin_logs = $ this ->log ->get_logs ('admin ' );
643+ $ this ->assertEmpty ($ admin_logs , 'Expected no admin log entry for a permanently-removed subscription ' );
644+ }
645+
605646 public function test_get_type (): void
606647 {
607648 $ this ->assertEquals ('notification.method.phpbb.wpn.webpush ' , $ this ->notification_method_webpush ->get_type ());
@@ -688,6 +729,51 @@ protected function createMockRequest(): \Psr\Http\Message\RequestInterface
688729 return $ request ;
689730 }
690731
732+ /**
733+ * Test is_endpoint_permanently_removed method
734+ */
735+ public function test_is_endpoint_permanently_removed (): void
736+ {
737+ $ reflection = new \ReflectionMethod ($ this ->notification_method_webpush , 'is_endpoint_permanently_removed ' );
738+ $ reflection ->setAccessible (true );
739+
740+ // .invalid TLD sentinel — should return true
741+ $ this ->assertTrue (
742+ $ reflection ->invoke ($ this ->notification_method_webpush , 'https://permanently-removed.invalid/fcm/send/abc123 ' ),
743+ 'Expected permanently-removed.invalid to be treated as permanently removed '
744+ );
745+
746+ // Any .invalid host — should return true
747+ $ this ->assertTrue (
748+ $ reflection ->invoke ($ this ->notification_method_webpush , 'https://some-other.invalid/push/endpoint ' ),
749+ 'Expected any .invalid host to be treated as permanently removed '
750+ );
751+
752+ // Valid FCM endpoint — should return false
753+ $ this ->assertFalse (
754+ $ reflection ->invoke ($ this ->notification_method_webpush , 'https://fcm.googleapis.com/fcm/send/abc123 ' ),
755+ 'Expected valid FCM endpoint to not be treated as permanently removed '
756+ );
757+
758+ // Valid Mozilla endpoint — should return false
759+ $ this ->assertFalse (
760+ $ reflection ->invoke ($ this ->notification_method_webpush , 'https://updates.push.services.mozilla.com/push/v1/abc123 ' ),
761+ 'Expected valid Mozilla endpoint to not be treated as permanently removed '
762+ );
763+
764+ // Subdomain spoofing attempt (host ends in .invalid.attacker.com, not .invalid) — should return false
765+ $ this ->assertFalse (
766+ $ reflection ->invoke ($ this ->notification_method_webpush , 'https://permanently-removed.invalid.attacker.com/push ' ),
767+ 'Expected .invalid.attacker.com to not be treated as permanently removed '
768+ );
769+
770+ // Empty/invalid URL — should return false
771+ $ this ->assertFalse (
772+ $ reflection ->invoke ($ this ->notification_method_webpush , 'not_a_url ' ),
773+ 'Expected unparseable URL to not be treated as permanently removed '
774+ );
775+ }
776+
691777 /**
692778 * @dataProvider data_notification_webpush
693779 */
@@ -905,6 +991,27 @@ protected function get_all_subscriptions(): array
905991 return $ sql_ary ;
906992 }
907993
994+ /**
995+ * Create a real subscription via the push testing service for the given user, then overwrite
996+ * its endpoint with the specified value. This gives a subscription with valid encryption keys
997+ * (required for payload encryption) but an endpoint that will never resolve — used for testing
998+ * dead/sentinel endpoints such as permanently-removed.invalid.
999+ */
1000+ protected function insert_subscription_for_user (int $ user_id , string $ endpoint ): void
1001+ {
1002+ // Get a real subscription from the push testing service so the p256dh/auth keys are
1003+ // valid base64url-encoded EC keys that the library can actually encrypt against.
1004+ $ subscription_data = $ this ->create_subscription_for_user ($ user_id );
1005+
1006+ // Overwrite the endpoint to the dead one we want to test with.
1007+ $ push_subscriptions_table = $ this ->container ->getParameter ('tables.phpbb.wpn.push_subscriptions ' );
1008+ $ sql = 'UPDATE ' . $ push_subscriptions_table . "
1009+ SET endpoint = ' " . $ this ->db ->sql_escape ($ endpoint ) . "'
1010+ WHERE user_id = " . (int ) $ user_id . "
1011+ AND endpoint = ' " . $ this ->db ->sql_escape ($ subscription_data ['endpoint ' ]) . "' " ;
1012+ $ this ->db ->sql_query ($ sql );
1013+ }
1014+
9081015 /**
9091016 * @depends test_get_subscription
9101017 */
0 commit comments