@@ -536,6 +536,227 @@ public function test_wp_kses_allowed_html() {
536536 $ this ->assertSame ( $ allowedtags , wp_kses_allowed_html ( 'data ' ) );
537537 }
538538
539+ /**
540+ * Tests that the comment content context allows only the mention span beyond the defaults.
541+ *
542+ * @ticket 65622
543+ *
544+ * @covers ::_wp_kses_allow_note_mention_span
545+ */
546+ public function test_wp_kses_allowed_html_pre_comment_content_allows_only_the_mention_span () {
547+ global $ allowedtags ;
548+
549+ $ allowed = wp_kses_allowed_html ( 'pre_comment_content ' );
550+
551+ $ this ->assertSame (
552+ array ( 'class ' => true ),
553+ $ allowed ['span ' ],
554+ 'The mention span should be allowed in comment content. '
555+ );
556+
557+ unset( $ allowed ['span ' ] );
558+ $ this ->assertSame (
559+ $ allowedtags ,
560+ $ allowed ,
561+ 'Nothing beyond the mention span should be allowed on top of the default comment tags. '
562+ );
563+ }
564+
565+ /**
566+ * Tests that a note mention survives content sanitization of a `note` comment.
567+ *
568+ * @ticket 65622
569+ *
570+ * @covers ::_wp_kses_allow_note_mention_span
571+ * @covers ::_wp_kses_sanitize_note_mention_classes
572+ */
573+ public function test_note_mention_markup_survives_note_content_sanitization () {
574+ add_filter ( 'pre_comment_content ' , 'wp_filter_kses ' );
575+
576+ $ content = 'Hello <span class="wp-note-mention user-2">@admin</span>! ' ;
577+ $ filtered = wp_filter_comment ( wp_slash ( $ this ->get_mention_commentdata ( 'note ' , $ content ) ) );
578+
579+ remove_filter ( 'pre_comment_content ' , 'wp_filter_kses ' );
580+
581+ $ this ->assertSame ( $ content , wp_unslash ( $ filtered ['comment_content ' ] ) );
582+ }
583+
584+ /**
585+ * Tests that the mention markup also survives in regular comment content.
586+ *
587+ * The allowance is always on rather than scoped per comment type: the
588+ * mention markup is inert, so uniform sanitization avoids stateful
589+ * arming and disarming of kses filters around each note write.
590+ *
591+ * @ticket 65622
592+ *
593+ * @covers ::_wp_kses_allow_note_mention_span
594+ * @covers ::_wp_kses_sanitize_note_mention_classes
595+ */
596+ public function test_note_mention_markup_survives_regular_comment_content_sanitization () {
597+ add_filter ( 'pre_comment_content ' , 'wp_filter_kses ' );
598+ $ content = 'Hello <span class="wp-note-mention user-2">@admin</span>! ' ;
599+ $ filtered = wp_filter_comment ( wp_slash ( $ this ->get_mention_commentdata ( 'comment ' , $ content ) ) );
600+
601+ $ this ->assertSame ( $ content , wp_unslash ( $ filtered ['comment_content ' ] ) );
602+ }
603+
604+ /**
605+ * Tests that span classes are reduced to the two mention tokens.
606+ *
607+ * @ticket 65622
608+ *
609+ * @covers ::_wp_kses_sanitize_note_mention_classes
610+ */
611+ public function test_note_mention_span_classes_are_reduced_to_the_mention_tokens () {
612+ add_filter ( 'pre_comment_content ' , 'wp_filter_kses ' );
613+ $ content = 'Hello <span class="wp-note-mention user-2 is-destructive components-button">@admin</span>! ' ;
614+ $ filtered = wp_filter_comment ( wp_slash ( $ this ->get_mention_commentdata ( 'note ' , $ content ) ) );
615+
616+ $ this ->assertSame (
617+ 'Hello <span class="wp-note-mention user-2">@admin</span>! ' ,
618+ wp_unslash ( $ filtered ['comment_content ' ] ),
619+ 'Class tokens beyond `wp-note-mention` and `user-N` should be stripped from spans. '
620+ );
621+ }
622+
623+ /**
624+ * Tests that class tokens are reduced on spans regardless of tag-name casing.
625+ *
626+ * kses preserves tag-name casing, so the class reduction must match `SPAN`
627+ * case-insensitively rather than bail on a `<span` substring check.
628+ *
629+ * @ticket 65622
630+ *
631+ * @covers ::_wp_kses_sanitize_note_mention_classes
632+ */
633+ public function test_note_mention_class_tokens_are_reduced_on_uppercase_span_tags () {
634+ add_filter ( 'pre_comment_content ' , 'wp_filter_kses ' );
635+ $ content = 'Hello <SPAN class="wp-note-mention user-2 is-destructive">@admin</SPAN>! ' ;
636+ $ filtered = wp_filter_comment ( wp_slash ( $ this ->get_mention_commentdata ( 'note ' , $ content ) ) );
637+
638+ $ this ->assertEqualHTML (
639+ 'Hello <span class="wp-note-mention user-2">@admin</span>! ' ,
640+ wp_unslash ( $ filtered ['comment_content ' ] ),
641+ '<body> ' ,
642+ 'Class tokens should be reduced on spans regardless of tag-name casing. '
643+ );
644+ }
645+
646+ /**
647+ * Tests that the class attribute is removed when no mention tokens remain.
648+ *
649+ * @ticket 65622
650+ *
651+ * @covers ::_wp_kses_sanitize_note_mention_classes
652+ */
653+ public function test_note_mention_class_attribute_removed_when_no_tokens_remain () {
654+ add_filter ( 'pre_comment_content ' , 'wp_filter_kses ' );
655+ $ content = 'Hello <span class="is-destructive user-0 user-x wp-note-mention-foo">there</span>! ' ;
656+ $ filtered = wp_filter_comment ( wp_slash ( $ this ->get_mention_commentdata ( 'comment ' , $ content ) ) );
657+
658+ // Markup-equivalence assertion: the HTML API's whitespace handling
659+ // when removing the final attribute is not part of its contract.
660+ $ this ->assertEqualHTML (
661+ 'Hello <span>there</span>! ' ,
662+ wp_unslash ( $ filtered ['comment_content ' ] ),
663+ '<body> ' ,
664+ 'A span with no valid mention tokens should lose its class attribute entirely. '
665+ );
666+ }
667+
668+ /**
669+ * Tests that only the `class` attribute is allowed on mention spans.
670+ *
671+ * @ticket 65622
672+ *
673+ * @covers ::_wp_kses_allow_note_mention_span
674+ */
675+ public function test_note_mention_allows_only_class_on_mention_spans () {
676+ add_filter ( 'pre_comment_content ' , 'wp_filter_kses ' );
677+ $ content = 'Hello <span class="wp-note-mention user-2" data-user-id="2" onclick="alert(1)" style="color:red" id="mention">@admin</span>! ' ;
678+ $ filtered = wp_filter_comment ( wp_slash ( $ this ->get_mention_commentdata ( 'note ' , $ content ) ) );
679+
680+ $ this ->assertSame (
681+ 'Hello <span class="wp-note-mention user-2">@admin</span>! ' ,
682+ wp_unslash ( $ filtered ['comment_content ' ] ),
683+ 'Attributes beyond `class` should be stripped from spans. '
684+ );
685+ }
686+
687+ /**
688+ * Tests that `class` is still stripped from links in comment content.
689+ *
690+ * @ticket 65622
691+ *
692+ * @covers ::_wp_kses_allow_note_mention_span
693+ */
694+ public function test_class_is_still_stripped_from_links_in_comment_content () {
695+ add_filter ( 'pre_comment_content ' , 'wp_filter_kses ' );
696+
697+ /*
698+ * The href is external to the test site so that wp_rel_ugc() - which
699+ * applies to notes like any other comment - deterministically appends
700+ * `rel="nofollow ugc"`.
701+ */
702+ $ content = 'Hello <a class="wp-note-mention user-2" href="https://example.com/author/admin/">@admin</a>! ' ;
703+ $ filtered = wp_filter_comment ( wp_slash ( $ this ->get_mention_commentdata ( 'note ' , $ content ) ) );
704+
705+ $ this ->assertSame (
706+ 'Hello <a href="https://example.com/author/admin/" rel="nofollow ugc">@admin</a>! ' ,
707+ wp_unslash ( $ filtered ['comment_content ' ] ),
708+ 'The class allowance is scoped to spans; links keep the default sanitization. '
709+ );
710+ }
711+
712+ /**
713+ * Tests that the class reduction is skipped while the restrictive comment kses is inactive.
714+ *
715+ * Users with `unfiltered_html` are filtered through `wp_filter_post_kses`
716+ * (or not at all), where arbitrary classes are permitted; the mention
717+ * class reduction must not narrow what they can post.
718+ *
719+ * @ticket 65622
720+ *
721+ * @covers ::_wp_kses_sanitize_note_mention_classes
722+ */
723+ public function test_note_mention_class_reduction_skipped_when_restrictive_kses_is_inactive () {
724+ // kses_init() hooks wp_filter_kses by default in the test
725+ // environment, so detach it to simulate the unfiltered_html setup.
726+ // The test framework restores filters after each test.
727+ remove_filter ( 'pre_comment_content ' , 'wp_filter_kses ' );
728+
729+ $ content = 'Hello <span class="components-button is-destructive">there</span>! ' ;
730+
731+ $ this ->assertSame (
732+ wp_slash ( $ content ),
733+ _wp_kses_sanitize_note_mention_classes ( wp_slash ( $ content ) ),
734+ 'Span classes should be left untouched when wp_filter_kses is not active. '
735+ );
736+ }
737+
738+ /**
739+ * Builds a complete commentdata array for wp_filter_comment().
740+ *
741+ * @param 'note'|'comment' $comment_type The comment type.
742+ * @param string $content The comment content.
743+ * @return array{
744+ * comment_content: string,
745+ * ...
746+ * }
747+ */
748+ private function get_mention_commentdata ( string $ comment_type , string $ content ): array {
749+ return array (
750+ 'comment_content ' => $ content ,
751+ 'comment_type ' => $ comment_type ,
752+ 'comment_author ' => 'admin ' ,
753+ 'comment_author_IP ' => '127.0.0.1 ' ,
754+ 'comment_author_url ' => 'http://example.org ' ,
755+ 'comment_author_email ' => 'admin@example.org ' ,
756+ 'comment_agent ' => '' ,
757+ );
758+ }
759+
539760 public function test_hyphenated_tag () {
540761 $ content = '<hyphenated-tag attribute="value" otherattribute="value2">Alot of hyphens.</hyphenated-tag> ' ;
541762 $ custom_tags = array (
0 commit comments