diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index 436d6f1924..ac24245af3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -612,6 +612,21 @@ private WireVoteResult handleWireVoteDelayRejected(JsonEnvelope msg) { if (rejected.uuid.isEmpty() || rejected.service.isEmpty()) { return new WireVoteResult(rejected.voteId, true); } + VoteSite legacySite = null; + if (!ServiceSiteValidator.isValid(rejected.service) + && ServiceSiteValidator.isValidConfiguredLegacyIdentifier(rejected.service)) { + for (VoteSite site : plugin.getVoteSiteManager().getVoteSites().toArray(new VoteSite[0])) { + if (site.isEnabled() && rejected.service.equalsIgnoreCase(site.getServiceSite())) { + legacySite = site; + break; + } + } + } + boolean configuredLegacy = legacySite != null; + if (!ServiceSiteValidator.isValid(rejected.service) && !configuredLegacy) { + plugin.getLogger().warning("Rejected VoteDelayRejected with invalid service site"); + return new WireVoteResult(rejected.voteId, true); + } UUID javaUuid; try { javaUuid = UUID.fromString(rejected.uuid); @@ -620,8 +635,10 @@ private WireVoteResult handleWireVoteDelayRejected(JsonEnvelope msg) { + ServiceSiteValidator.sanitizeForLog(rejected.uuid)); return new WireVoteResult(rejected.voteId, true); } - VoteSite voteSite = plugin.getVoteSiteManager() - .getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, rejected.service), true); + VoteSite voteSite = configuredLegacy + ? legacySite + : plugin.getVoteSiteManager() + .getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, rejected.service), true); if (voteSite == null) { plugin.getLogger().warning("No voting site with the service site: '" + ServiceSiteValidator.sanitizeForLog(rejected.service) + "'"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServiceSiteValidator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServiceSiteValidator.java index 44110fcdd4..5515de38d4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServiceSiteValidator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServiceSiteValidator.java @@ -7,7 +7,6 @@ public final class ServiceSiteValidator { /** Maximum accepted UTF-16 length for a service-site identifier. */ public static final int MAX_LENGTH = 2048; private static final int MAX_LOG_LENGTH = 128; - private static final String FORMATTING_BOUNDARY = "\u2060"; private ServiceSiteValidator() { } @@ -17,9 +16,18 @@ private ServiceSiteValidator() { * * @param serviceSite service-site name supplied by a vote source * @return {@code true} for a bounded, visible service-site name that does not - * contain structural formatting delimiters or control characters + * contain unsupported delimiters */ public static boolean isValid(String serviceSite) { + return isValid(serviceSite, false); + } + + /** Bounded legacy identifier validation; only use for an already-configured site. */ + public static boolean isValidConfiguredLegacyIdentifier(String serviceSite) { + return isValid(serviceSite, true); + } + + private static boolean isValid(String serviceSite, boolean allowLegacyDelimiters) { if (serviceSite == null || serviceSite.length() > MAX_LENGTH) { return false; } @@ -27,7 +35,7 @@ public static boolean isValid(String serviceSite) { boolean hasVisibleCharacter = false; for (int offset = 0; offset < serviceSite.length();) { int codePoint = serviceSite.codePointAt(offset); - if (isDisallowed(codePoint)) { + if (isDisallowed(codePoint) && !(allowLegacyDelimiters && isLegacyDelimiter(codePoint))) { return false; } if (isVisibleBaseCharacter(codePoint)) { @@ -66,27 +74,26 @@ public static String sanitizeForLog(String value) { return sanitized.toString(); } - /** - * Keeps a validated external identifier visually unchanged while preventing a - * surrounding trusted template from completing a formatting token across a - * substitution boundary. - */ + /** Display-only representation; raw identifiers remain authoritative for matching/actions. + * Uses printable ASCII labels and space boundaries rather than font-dependent guards. + * Boundaries prevent surrounding template text from completing interpreter tokens. */ public static String inertForFormatting(String value) { - if (value == null || value.isEmpty()) return value == null ? "" : value; - StringBuilder inert = new StringBuilder(value.length() + 2).append(FORMATTING_BOUNDARY); - for (int offset = 0; offset < value.length();) { - int codePoint = value.codePointAt(offset); - inert.appendCodePoint(codePoint); - if (codePoint == '%' || codePoint == '&') inert.append(FORMATTING_BOUNDARY); - offset += Character.charCount(codePoint); - } - return inert.append(FORMATTING_BOUNDARY).toString(); + if (value == null || value.isEmpty()) return ""; + return " " + value.replace("&", "(ampersand)").replace("%", "(percent)") + .replace("§", "(section)").replace("{", "(open brace)").replace("}", "(close brace)") + .replace("[", "(open bracket)").replace("]", "(close bracket)") + .replace("\"", "(double quote)").replace("'", "(quote)") + .replace("`", "(backtick)").replace("\\", "(backslash)") + " "; + } + + private static boolean isLegacyDelimiter(int codePoint) { + return codePoint == '[' || codePoint == ']' || codePoint == '\'' || codePoint == '"' + || codePoint == '`' || codePoint == '\\' || codePoint == '\u00A7'; } private static boolean isDisallowed(int codePoint) { if (codePoint == '[' || codePoint == ']' || codePoint == '\'' || codePoint == '"' || codePoint == '`' - || codePoint == '\u00A7' - || codePoint == '\\' || codePoint == '{' || codePoint == '}') { + || codePoint == '\\' || codePoint == '\u00A7') { return true; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votesites/VoteSite.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votesites/VoteSite.java index ac30171a5e..654cee7db2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votesites/VoteSite.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votesites/VoteSite.java @@ -189,7 +189,7 @@ public void setDisplayName(String displayName) { public String getDisplayNameForFormatting() { return displayNameFallback ? ServiceSiteValidator.inertForFormatting(getDisplayName()) : getDisplayName(); } - + /** * Inits the. */ diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java index 07ccdb127e..a022fd41c1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java @@ -1066,6 +1066,97 @@ void receiptReleaseBypassesOrderingOnlyAfterItsReceiptIsDurable() { assertTrue(voteRouter.hasDurableReceiptForRelease(release)); } + @Test + void delayRejectionRejectsInterpreterDelimitersBeforeResolvingUsersOrSites() { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.complete(any(UUID.class))).thenReturn(true); + BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, + mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), + mock(BackendVotePartySync.class), cache); + for (String service : new String[] { "[Spoofed]", "bad\\name", "bad\nname", "bad\u2060name" }) { + for (boolean reliable : new boolean[] { false, true }) { + configureDelayRejection(true); + JsonEnvelope rejection = VotingPluginWire.voteDelayRejected("Player", PLAYER_UUID.toString(), + service, true, UUID.randomUUID()); + if (reliable) rejection = VotingPluginWire.requestVoteDeliveryAcknowledgement(rejection); + AtomicReference outcome = new AtomicReference<>(); + voteRouter.handleOrderedVote(rejection, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + verify(voteSiteManager, never()).getVoteSiteName(true, service); + } + } + verify(plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(PLAYER_UUID, "Player"); + verify(user, never()).cache(); + } + + @Test + void configuredLegacyDelayRejectionNamesKeepRewardsWithoutCreatingSites() { + for (String service : new String[] { "Old's Site", "[Old]", "Old`Site", "Old\\Site", "Old\"Site", "§kOldSite" }) { + for (boolean reliable : new boolean[] { false, true }) { + VoteSite site = configureDelayRejection(true); + when(site.getServiceSite()).thenReturn(service); + when(voteSiteManager.getVoteSites()).thenReturn(java.util.List.of(site)); + UUID id = UUID.randomUUID(); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveWithOutcome(id)).thenReturn(Reservation.RESERVED); + when(cache.complete(id)).thenReturn(true); + BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, + mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), + mock(BackendVotePartySync.class), cache); + JsonEnvelope message = VotingPluginWire.voteDelayRejected("Player", PLAYER_UUID.toString(), + service, true, id); + if (reliable) message = VotingPluginWire.requestVoteDeliveryAcknowledgement(message); + AtomicReference outcome = new AtomicReference<>(); + voteRouter.handleOrderedVote(message, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + verify(site).giveWaitUntilVoteDelayRewards(user, false, true); + verify(voteSiteManager, never()).getVoteSiteName(true, service); + verify(voteSiteManager, never()).getVoteSite(service, true); + } + } + } + + @Test + void legacyDelayRejectionIgnoresDisplayAliasesAndFindsTheActualService() { + for (boolean reliable : new boolean[] { false, true }) { + String service = "[Old]"; + VoteSite alias = configureDelayRejection(true); + when(alias.getDisplayName()).thenReturn(service); + when(alias.getServiceSite()).thenReturn("different.example"); + when(voteSiteManager.hasConfiguredVoteSite(service)).thenReturn(true); + when(voteSiteManager.resolveVoteSite(service, true)).thenReturn(alias); + when(voteSiteManager.getVoteSites()).thenReturn(java.util.List.of(alias)); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.complete(any(UUID.class))).thenReturn(true); + when(cache.reserveWithOutcome(any(UUID.class))).thenReturn(Reservation.RESERVED); + BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, + mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), + mock(BackendVotePartySync.class), cache); + JsonEnvelope message = VotingPluginWire.voteDelayRejected("Player", PLAYER_UUID.toString(), + service, true, UUID.randomUUID()); + if (reliable) message = VotingPluginWire.requestVoteDeliveryAcknowledgement(message); + AtomicReference outcome = new AtomicReference<>(); + voteRouter.handleOrderedVote(message, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + verify(alias, never()).giveWaitUntilVoteDelayRewards(user, false, true); + verify(user, never()).cache(); + + VoteSite actual = mock(VoteSite.class); + when(actual.isEnabled()).thenReturn(true); + when(actual.isWaitUntilVoteDelay()).thenReturn(true); + when(actual.getServiceSite()).thenReturn("[oLD]"); + when(voteSiteManager.getVoteSites()).thenReturn(java.util.List.of(alias, actual)); + JsonEnvelope actualMessage = VotingPluginWire.voteDelayRejected("Player", PLAYER_UUID.toString(), + service, true, UUID.randomUUID()); + if (reliable) actualMessage = VotingPluginWire.requestVoteDeliveryAcknowledgement(actualMessage); + voteRouter.handleOrderedVote(actualMessage, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + verify(actual).giveWaitUntilVoteDelayRewards(user, false, true); + verify(alias, never()).giveWaitUntilVoteDelayRewards(user, false, true); + org.mockito.Mockito.clearInvocations(user); + } + } + @Test void reliableDelayRejectionExecutesOnceAndPersistsItsVoteId() { UUID voteId = UUID.randomUUID(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/neoforge/NeoForgeRewardReplayServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/neoforge/NeoForgeRewardReplayServiceTest.java index aeeed44eb4..5b8c0a91d6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/neoforge/NeoForgeRewardReplayServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/neoforge/NeoForgeRewardReplayServiceTest.java @@ -834,7 +834,7 @@ void playerMessageOnlyRewardIsSupported() throws Exception { runtime.scheduler().onServerTick(); assertEquals(NeoForgeRewardReplayService.Status.COMPLETED, completion.get(5, TimeUnit.SECONDS).get(0).status()); - assertEquals(List.of("Thanks Alex from \u2060Service\u2060 at Supported Display"), actions.rendered); + assertEquals(List.of("Thanks Alex from Service at Supported Display"), actions.rendered); } } @@ -878,7 +878,7 @@ void playerMessageKeepsConfiguredServiceFormattingLiteral() throws Exception { assertEquals(NeoForgeRewardReplayService.Status.COMPLETED, runOne(runtime, replay, actions).status()); - assertEquals(List.of("Thanks Alex from \u2060&\u2060aService\u2060"), actions.rendered); + assertEquals(List.of("Thanks Alex from (ampersand)aService "), actions.rendered); assertTrue(runtime.deferredVotes().pending(playerId).isEmpty()); } } @@ -900,7 +900,7 @@ void playerMessageKeepsServicePlaceholderTextInert() throws Exception { assertEquals(NeoForgeRewardReplayService.Status.COMPLETED, runOne(runtime, replay, actions).status()); - assertEquals(List.of("Thanks Alex from \u2060%\u2060player%\u2060\u2060"), actions.rendered); + assertEquals(List.of("Thanks Alex from (percent)player(percent) "), actions.rendered); assertTrue(runtime.deferredVotes().pending(playerId).isEmpty()); } } @@ -923,7 +923,7 @@ void playerMessageKeepsFallbackSiteNamePlaceholderTextInert() throws Exception { assertEquals(NeoForgeRewardReplayService.Status.COMPLETED, runOne(runtime, replay, actions).status()); - assertEquals(List.of("Thanks Alex from \u2060%\u2060player%\u2060\u2060"), actions.rendered); + assertEquals(List.of("Thanks Alex from (percent)player(percent) "), actions.rendered); assertTrue(runtime.deferredVotes().pending(playerId).isEmpty()); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/PendingIncomingVoteJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/PendingIncomingVoteJournalTest.java index c083e19b12..20fde41631 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/PendingIncomingVoteJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/PendingIncomingVoteJournalTest.java @@ -19,6 +19,20 @@ class PendingIncomingVoteJournalTest { @TempDir Path temporaryDirectory; + @Test + void legacyDelimiterBearingVotesRemainReplayableWithOtherQueuedVotes() throws Exception { + var journal = new PendingIncomingVoteJournal(temporaryDirectory); + var votes = List.of(new VoteTimeQueue(UUID.randomUUID(), "Player", "R&D", 1L), + new VoteTimeQueue(UUID.randomUUID(), "Player", "https://example.com/site%20name", 2L), + new VoteTimeQueue(UUID.randomUUID(), "Player", "NormalSite", 3L)); + journal.merge(votes); + var recovered = journal.load(); + assertEquals(votes.stream().map(VoteTimeQueue::getService).toList(), + recovered.stream().map(VoteTimeQueue::getService).toList()); + assertEquals(votes.stream().map(VoteTimeQueue::getVoteId).toList(), + recovered.stream().map(VoteTimeQueue::getVoteId).toList()); + } + @Test void atomicJournalPreservesStableIdAndPartialEffectFences() throws Exception { PendingIncomingVoteJournal journal = new PendingIncomingVoteJournal(temporaryDirectory); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServiceSiteValidatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServiceSiteValidatorTest.java index 0b5aa35ba7..30e67a9699 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServiceSiteValidatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServiceSiteValidatorTest.java @@ -4,8 +4,11 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.concurrent.atomic.AtomicInteger; + import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate; import com.bencodez.votingplugin.util.ServiceSiteValidator; class ServiceSiteValidatorTest { @@ -13,39 +16,69 @@ class ServiceSiteValidatorTest { @Test void acceptsCommonServiceSiteNames() { for (String serviceSite : new String[] { "PlanetMinecraft.com", "Minecraft Server List", "Crafty.gg", - "https://example.com/vote", "https://list.example/vote?id=1&source=proxy#top", - "Research & Development", "R&D", "site_name-2", "Site, Other; Network!", - "site%20name", "site%2Bname", "site%20%2Bname", "site%20name%2Bnetwork", - "site%20name_with%20spaces", "Top 100% Servers", "%player_name%", "%javascript_vote%", - "%be_secret%20", "%25player_name%25", "site%", "site%2", "site%GG", - "&kSpoofed", "&aGreen", "&rReset", - "&xHex", "&#ff0000Spoofed", "&#ABCDEFText", - "Serviço de votação", - "Site\u00A0Name", "Site\uFE0F", "Cafe\u0301", "Site\u3164Name" }) { + "https://example.com/vote", "https://list.example/vote?id=1#top", + "https://example.com/search?q=site+name+network", "site_name-2", "Site, Other; Network!", + "Serviço de votação", "Site\u00A0Name", "Site\uFE0F", "Cafe\u0301", "Site\u3164Name" }) { assertTrue(ServiceSiteValidator.isValid(serviceSite), serviceSite); } } @Test - void rejectsUnsupportedCharacters() { - for (String serviceSite : new String[] { "[Javascript=1]", "Site's", "\"Site\"", "Site`Name", - "Site\\Name", "Site\nName", "Site\tName", "Site\u0000Name", "Site\u200BName", - "{player}", "\u00A7kSpoofed", "\u00A7aGreen" }) { - assertFalse(ServiceSiteValidator.isValid(serviceSite), serviceSite); + void acceptsLegacyFormattingIdentifiersWithoutChangingMatching() { + for (String value : new String[] { "R&D", "&kSpoofed", "%player_name%", "{RED}", + "https://list.example/vote?id=1&source=proxy", "https://example.com/site%20name" }) { + assertTrue(ServiceSiteValidator.isValid(value), value); + String display = ServiceSiteValidator.inertForFormatting(value); + assertFalse(display.contains("&") || display.contains("%") || display.contains("§") + || display.contains("{") || display.contains("}")); + assertFalse(display.contains("\u2060")); } } @Test void formattingGuardPreventsTokensAcrossTemplateBoundaries() { - String value = "%player_name% &aGreen &#ff0000Red kSpoofed&"; - String guarded = ServiceSiteValidator.inertForFormatting(value); + AtomicInteger executions = new AtomicInteger(); + for (String[] layout : new String[][] { + { "[%SiteName%]", "Javascript=1" }, + { "[Java%SiteName%]", "script=1" }, + { "[%SiteName%script=1]", "Java" } }) { + assertTrue(ServiceSiteValidator.isValid(layout[1])); + String rendered = layout[0].replace("%SiteName%", + ServiceSiteValidator.inertForFormatting(layout[1])); + assertEquals(rendered, JavascriptTextTemplate.parse(rendered).evaluate(text -> text, script -> { + executions.incrementAndGet(); + return "executed"; + })); + } + assertEquals(0, executions.get()); + assertEquals("& kSpoofed ", "&%SiteName%".replace("%SiteName%", + ServiceSiteValidator.inertForFormatting("kSpoofed"))); + assertEquals("% player_name %", "%" + ServiceSiteValidator.inertForFormatting("player_name") + "%"); + assertEquals("", ServiceSiteValidator.inertForFormatting(null)); + assertEquals("", ServiceSiteValidator.inertForFormatting("")); + } + + @Test + void legacyConfiguredValidationAllowsPunctuationButKeepsBoundsAndControlChecks() { + for (String service : new String[] { "Old's Site", "[Javascript=1]", "Old`Site", "Old\\Site", "§kOldSite" }) { + assertFalse(ServiceSiteValidator.isValid(service)); + assertTrue(ServiceSiteValidator.isValidConfiguredLegacyIdentifier(service)); + String display = ServiceSiteValidator.inertForFormatting(service); + assertFalse(display.contains("[") || display.contains("]") || display.contains("'") + || display.contains("`") || display.contains("\\") || display.contains("§")); + } + for (String service : new String[] { "bad\nsite", "bad\u2060site", "x".repeat(2049), " " }) { + assertFalse(ServiceSiteValidator.isValidConfiguredLegacyIdentifier(service)); + } + assertFalse(ServiceSiteValidator.isValidConfiguredLegacyIdentifier(null)); + } - assertFalse(("&" + guarded + "a").contains("&k")); - assertFalse(("%" + guarded + "player_name%").contains("%kSpoofed&player_name%")); - assertFalse(guarded.contains("%player_name%")); - assertFalse(guarded.contains("&a")); - assertFalse(guarded.contains("&#ff0000")); - assertEquals(value, guarded.replace("\u2060", "")); + @Test + void rejectsUnsupportedCharacters() { + for (String value : new String[] { "[Javascript=1]", "Site's", "\"Site\"", "Site`Name", + "§kSpoofed", "Site\\Name", "Site\nName", "Site\tName", "Site\u0000Name", "Site\u200BName" }) { + assertFalse(ServiceSiteValidator.isValid(value), value); + } } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index b667f52987..c43d21301e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -103,6 +103,18 @@ private com.vexsoftware.votifier.model.VotifierEvent createVoteEvent(String serv return event; } + @Test + void rejectsStructurallyInvalidServiceNamesBeforeSchedulingOrAutomaticCreation() { + when(plugin.getConfigFile().isAutoCreateVoteSites()).thenReturn(true); + for (String service : new String[] { "[Javascript=1]", "§kSpoofed", "bad\\name", "bad\nname" }) { + listener.onVotiferEvent(createVoteEvent(service)); + } + verify(voteTimer, never()).submit(any(Runnable.class)); + verify(overflowQueue, never()).enqueue(anyString(), anyString()); + verify(configVoteSites, never()).tryAutoGenerateVoteSite(anyString()); + verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); + } + @Test public void testDisabledConfiguredSiteIsNotGeneratedByVotifierPath() { when(plugin.getConfigFile().isAutoCreateVoteSites()).thenReturn(true); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/reminders/VoteRemindersListenerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/reminders/VoteRemindersListenerTest.java index cbd73c802a..25962f2e3d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/reminders/VoteRemindersListenerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/reminders/VoteRemindersListenerTest.java @@ -35,11 +35,11 @@ void postVoteKeepsRawActionValueAndSeparateDisplayValue() { when(event.getUser()).thenReturn(user); when(event.getVoteSite()).thenReturn(site); when(site.getDisplayName()).thenReturn("site_key"); - when(site.getDisplayNameForFormatting()).thenReturn("\u2060site_key\u2060"); + when(site.getDisplayNameForFormatting()).thenReturn("site_key"); new VoteRemindersListener(plugin).onPostVote(event); - verify(manager).onVoteCast(user, "site_key", "\u2060site_key\u2060"); + verify(manager).onVoteCast(user, "site_key", "site_key"); } @Test @@ -53,7 +53,7 @@ void cooldownKeepsRawActionValueAndSeparateDisplayValue() { when(event.getPlayer()).thenReturn(user); when(event.getSite()).thenReturn(site); when(site.getDisplayName()).thenReturn("site_key"); - when(site.getDisplayNameForFormatting()).thenReturn("\u2060site_key\u2060"); + when(site.getDisplayNameForFormatting()).thenReturn("site_key"); when(site.getKey()).thenReturn("site_key"); new VoteRemindersListener(plugin).onCoolDownEnd(event); @@ -63,6 +63,6 @@ void cooldownKeepsRawActionValueAndSeparateDisplayValue() { verify(manager).onCooldownTrigger(eq(user), eq(VoteReminderType.COOLDOWN_END_ANY_SITE), placeholders.capture()); HashMap values = new HashMap<>(placeholders.getValue()); assertEquals("site_key", values.get("votesite")); - assertEquals("\u2060site_key\u2060", RewardDisplayPlaceholders.forDisplay(values).get("votesite")); + assertEquals("site_key", RewardDisplayPlaceholders.forDisplay(values).get("votesite")); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteTest.java index 7700bf23c2..f0d92aabf2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.tests.votesite; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -53,6 +54,20 @@ public void setUp() { when(plugin.getServerData()).thenReturn(dummyServerData); } + @Test + void legacyAutoCreatedFallbackIsVisibleAndInertWhileIdentityStaysExact() { + for (String name : new String[] { "&kSpoofed", "%player_name%", "R&D" }) { + when(plugin.getConfigVoteSites().getDisplayName(name)).thenReturn(""); + VoteSite site = new VoteSite(plugin, name); + site.setServiceSite(name); + assertEquals(name, site.getServiceSite()); + assertEquals(name, site.getDisplayName()); + assertFalse(site.getDisplayNameForFormatting().contains("&")); + assertFalse(site.getDisplayNameForFormatting().contains("%")); + assertFalse(site.getDisplayNameForFormatting().contains("\u2060")); + } + } + @Test public void testInitSetsValuesCorrectly() { VoteSite voteSite = new VoteSite(plugin, "site.test"); @@ -71,8 +86,8 @@ public void testInitSetsValuesCorrectly() { public void serviceSiteFormattingCannotJoinSurroundingColorCodes() { VoteSite voteSite = new VoteSite(plugin, "site.test"); - assertEquals("ServiceSite", voteSite.getServiceSiteForFormatting().replace("\u2060", "")); - assertTrue(("&" + voteSite.getServiceSiteForFormatting()).indexOf("&S") < 0); + assertEquals(" ServiceSite ", voteSite.getServiceSiteForFormatting()); + assertFalse(voteSite.getServiceSiteForFormatting().contains("\u2060")); } @Test @@ -85,8 +100,8 @@ public void rewardActionsReceiveTheExactValidatedServiceSite() throws Exception RewardBuilder builder = (RewardBuilder) factory.invoke(voteSite, "Rewards", true, false); assertEquals("ServiceSite", builder.getRewardOptions().getPlaceholders().get("ServiceSite")); - assertEquals("ServiceSite", RewardDisplayPlaceholders - .forDisplay(builder.getRewardOptions().getPlaceholders()).get("ServiceSite").replace("\u2060", "")); + assertEquals(" ServiceSite ", RewardDisplayPlaceholders + .forDisplay(builder.getRewardOptions().getPlaceholders()).get("ServiceSite")); } @Test @@ -100,8 +115,8 @@ public void fallbackDisplayNameStaysRawForActionsAndGuardedForFormatting() throw assertEquals("site_test", voteSite.getDisplayName()); assertEquals("site_test", builder.getRewardOptions().getPlaceholders().get("SiteName")); - assertEquals("site_test", voteSite.getDisplayNameForFormatting().replace("\u2060", "")); - assertTrue(voteSite.getDisplayNameForFormatting().startsWith("\u2060")); + assertEquals(" site_test ", voteSite.getDisplayNameForFormatting()); + assertFalse(voteSite.getDisplayNameForFormatting().contains("\u2060")); assertEquals(voteSite.getDisplayNameForFormatting(), RewardDisplayPlaceholders .forDisplay(builder.getRewardOptions().getPlaceholders()).get("SiteName")); diff --git a/docs/service-site-rendering.md b/docs/service-site-rendering.md new file mode 100644 index 0000000000..730a45e3f8 --- /dev/null +++ b/docs/service-site-rendering.md @@ -0,0 +1,33 @@ +# Voting-service identifiers and displayed names + +Ordinary voting-site names retain their text, without WORD JOINER or other +font-dependent guards. External display values have an ASCII space on each side +to prevent adjacent template text from completing a formatting token. For example, +`[%SiteName%]` renders `[ Javascript=1 ]` for the literal service `Javascript=1`, +rather than creating an executable rich-format token. Administrator-authored display-name colors and placeholders +remain supported. The exact service identifier is preserved for matching, +action placeholders, and queued-vote recovery. + +Display-only values originating from external service identifiers or fallback +site keys use printable ASCII labels for interpreter delimiters: `&` becomes +`(ampersand)`, `%` becomes `(percent)`, `§` becomes `(section)`, and braces become +`(open brace)` / `(close brace)`. This avoids executing color/placeholder syntax +without relying on font support for hidden Unicode guards. For example, a legacy +`R&D` identifier matches exactly as before but its fallback display is +`R(ampersand)D`. An explicit trusted `DisplayName` can provide a preferred label. + +This also protects already-persisted auto-created sites. It does not rename site +keys or change reward action identifiers. Existing service-name acceptance and +persisted ingress journals remain compatible: names containing `&` or `%`, +including legacy URL-shaped identifiers, remain replayable. Structural delimiters +such as quotes, brackets, backticks and backslashes, section-sign formatting codes, control characters and +oversized identifiers retain their existing rejection rules. Delayed-vote rejection messages additionally accept bounded legacy punctuation +for an enabled, loaded site whose configured `ServiceSite` matches (case-insensitively), +resolved without auto-creation. Display-name and YAML-key aliases alone cannot +authorize these legacy rejection rewards; unknown malformed +names, control characters and oversized values remain rejected before user lookup. +Legacy brackets, quotes, backticks and backslashes use printable display labels too. `VoteURL` is unchanged. + +No configuration or data migration is required. Minecraft client rendering of +the new printable labels has not been visually verified; automated tests cover +literal display values, trusted labels, queued-vote recovery and reward routing.