feat(stealth): mask identities and notify admins on login
Release / release (push) Successful in 2m8s
CI / build (push) Successful in 1m6s

This commit is contained in:
dmg
2026-09-06 21:19:59 -04:00
parent 831f6a2ce4
commit 1addc93062
17 changed files with 677 additions and 11 deletions
@@ -25,10 +25,14 @@ class BukkitIdentityPresentationTest {
BukkitIdentityPresentation presentation = new BukkitIdentityPresentation(
() -> List.of(target), scoreboard, mock(TabListController.class));
when(target.getDisplayName()).thenReturn("Original Alex");
presentation.conceal(target);
presentation.conceal(target);
presentation.reveal(target);
verify(target).setSleepingIgnored(true);
verify(target).setDisplayName("Original Alex");
org.junit.jupiter.api.Assertions.assertTrue(presentation.concealedNames().isEmpty());
verify(target, org.mockito.Mockito.times(2)).setSleepingIgnored(true);
verify(target).setSleepingIgnored(false);
}
@@ -73,7 +77,7 @@ class BukkitIdentityPresentationTest {
}
@Test
void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() {
void concealRemovesTabEntryAndScramblesNameTagWithoutHidingEntity() {
UUID targetId = UUID.randomUUID();
Player target = player(targetId, "Alex");
Player observer = player(UUID.randomUUID(), "Morgan");
@@ -87,7 +91,11 @@ class BukkitIdentityPresentationTest {
presentation.conceal(target);
verify(team).setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER);
verify(team).setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.ALWAYS);
verify(team).setPrefix("§k");
verify(team).setSuffix("§r");
verify(target).setDisplayName("§kAnonymous§r");
org.junit.jupiter.api.Assertions.assertEquals(java.util.Set.of("Alex"), presentation.concealedNames());
verify(team).addEntry("Alex");
verify(tabLists).remove(observer, targetId);
verify(observer, never()).hidePlayer(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
@@ -0,0 +1,32 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
class IdentityPacketFieldsTest {
private final StealthChatMasker masker = new StealthChatMasker(Set.of("Secret"));
record Entry(String text, Optional<String> tooltip) { }
record Bound(int chatType, String name, Optional<String> targetName) { }
@Test
void filtersModernSuggestionEntriesWithoutChangingOrdinaryEntries() throws ReflectiveOperationException {
Entry ordinary = new Entry("Someone", Optional.of("help"));
List<Entry> entries = List.of(new Entry("sEcReT", Optional.empty()), ordinary);
assertEquals(List.of(ordinary), IdentityPacketFields.filterEntries(entries, masker));
assertEquals(2, entries.size());
}
@Test
void rebuildsDisplayNameRecordWithoutMutatingOriginal() throws ReflectiveOperationException {
Bound original = new Bound(42, "Secret", Optional.of("Secret"));
Object copy = IdentityPacketFields.mapRecord(original,
value -> value.equals("Secret") ? "Anonymous" : value);
assertEquals(new Bound(42, "Anonymous", Optional.of("Anonymous")), copy);
assertEquals("Secret", original.name());
}
}
@@ -0,0 +1,63 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.utility.MinecraftReflection;
import com.comphenix.protocol.utility.MinecraftVersion;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
class ProtocolLibIdentityMaskingListenerTest {
private static MockedStatic<MinecraftReflection> reflection;
@BeforeAll
static void initializeProtocolTypesWithoutServer() {
MinecraftVersion.setCurrentVersion(new MinecraftVersion(1, 21, 8));
reflection = mockStatic(MinecraftReflection.class);
}
@AfterAll
static void resetProtocolEnvironment() {
reflection.close();
MinecraftVersion.setCurrentVersion(null);
}
@Test
void skipsPacketWorkWhenNobodyIsConcealed() {
AtomicReference<Set<String>> names = new AtomicReference<>(Set.of());
var listener = new ProtocolLibIdentityMaskingListener(mock(Plugin.class), names::get, ignored -> { });
PacketEvent event = mock(PacketEvent.class);
listener.onPacketSending(event);
verify(event, never()).getPacket();
assertTrue(listener.getSendingWhitelist().getTypes().contains(PacketType.Play.Server.CHAT));
assertTrue(listener.getSendingWhitelist().getTypes().contains(PacketType.Play.Server.SYSTEM_CHAT));
}
@Test
void readsFreshSnapshotAndWarnsOnceWithoutCancellingOrReplacingFailedPacket() {
List<String> warnings = new ArrayList<>();
AtomicReference<Set<String>> names = new AtomicReference<>(Set.of());
var listener = new ProtocolLibIdentityMaskingListener(mock(Plugin.class), names::get, warnings::add);
PacketEvent event = mock(PacketEvent.class);
when(event.getPacketType()).thenReturn(PacketType.Play.Server.CHAT);
listener.onPacketSending(event);
assertTrue(warnings.isEmpty());
names.set(Set.of("Secret"));
// No real NMS packet: the clone attempt fails and must leave event delivery untouched.
listener.onPacketSending(event);
listener.onPacketSending(event);
assertEquals(1, warnings.size());
verify(event, never()).setPacket(any());
verify(event, never()).setCancelled(anyBoolean());
}
}
@@ -0,0 +1,83 @@
package games.dmg.spigotstealth;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
class StealthAdminJoinListenerTest {
@Test
void joiningAdminReceivesCountButExistingAdminsDoNotReceiveOrdinaryJoinNotice() {
Player joining = player(true);
Player existing = player(true);
StealthSessionService sessions = sessions(Set.of(UUID.randomUUID(), UUID.randomUUID()));
new StealthAdminJoinListener(sessions, () -> List.of(joining, existing)).onJoin(event(joining));
verify(joining).sendMessage("[Stealth] 2 players are invisible. Use /stealthadmin list to see who.");
verify(existing, never()).sendMessage(anyString());
}
@Test
void concealedJoinNotifiesOnlyAdminsWithUpdatedCount() {
Player joining = player(false);
Player admin = player(true);
Player ordinary = player(false);
StealthSessionService sessions = sessions(Set.of(joining.getUniqueId()));
new StealthAdminJoinListener(sessions, () -> List.of(joining, admin, ordinary)).onJoin(event(joining));
verify(admin).sendMessage("[Stealth] An invisible player joined. 1 player is now invisible. Use /stealthadmin list to see who.");
verify(joining, never()).sendMessage(anyString());
verify(ordinary, never()).sendMessage(anyString());
}
@Test
void concealedAdminGetsExactlyOneNoticeEvenWhenIncludedInOnlinePlayers() {
Player joining = player(true);
Player admin = player(true);
StealthSessionService sessions = sessions(Set.of(joining.getUniqueId(), UUID.randomUUID()));
new StealthAdminJoinListener(sessions, () -> List.of(joining, admin)).onJoin(event(joining));
verify(joining, times(1)).sendMessage("[Stealth] 2 players are invisible. Use /stealthadmin list to see who.");
verify(admin).sendMessage("[Stealth] An invisible player joined. 2 players are now invisible. Use /stealthadmin list to see who.");
}
@Test
void zeroCountIsSilent() {
Player joining = player(true);
new StealthAdminJoinListener(sessions(Set.of()), () -> List.of(joining)).onJoin(event(joining));
verify(joining, never()).sendMessage(anyString());
}
@Test
void ordinaryJoinIsSilentAndSinglePlayerCountUsesSingular() {
Player ordinary = player(false);
Player admin = player(true);
StealthAdminJoinListener listener = new StealthAdminJoinListener(
sessions(Set.of(UUID.randomUUID())), () -> List.of(ordinary, admin));
listener.onJoin(event(ordinary));
verify(ordinary, never()).sendMessage(anyString());
verify(admin, never()).sendMessage(anyString());
listener.onJoin(event(admin));
verify(admin).sendMessage("[Stealth] 1 player is invisible. Use /stealthadmin list to see who.");
}
private static StealthSessionService sessions(Set<UUID> ids) {
StealthSessionService sessions = mock(StealthSessionService.class);
when(sessions.concealedPlayerIds()).thenReturn(ids);
return sessions;
}
private static Player player(boolean admin) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.hasPermission("spigotstealth.admin")).thenReturn(admin);
return player;
}
private static PlayerJoinEvent event(Player player) {
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player);
return event;
}
}
@@ -0,0 +1,70 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonParser;
import java.util.Set;
import org.junit.jupiter.api.Test;
class StealthChatMaskerTest {
private final StealthChatMasker masker = new StealthChatMasker(Set.of("Secret"));
@Test
void masksSenderAndRemovesIdentityMetadataWithoutChangingMessage() {
String input = """
{"translate":"chat.type.text","with":[
{"text":"Secret","hoverEvent":{"action":"show_entity","contents":{"name":"Secret"}},
"clickEvent":{"action":"suggest_command","value":"/msg Secret "}},
{"text":"Secret says hello","clickEvent":{"action":"copy_to_clipboard","value":"Secret"}}]}
""";
var result = JsonParser.parseString(masker.maskAnnouncement(input)).getAsJsonObject();
var args = result.getAsJsonArray("with");
assertEquals(JsonParser.parseString("{\"text\":\"Anonymous\",\"obfuscated\":true,\"hoverEvent\":null,\"clickEvent\":null,\"insertion\":\"\"}"), args.get(0));
assertEquals(JsonParser.parseString(input).getAsJsonObject().getAsJsonArray("with").get(1), args.get(1));
}
@Test
void handlesPrivateMessagesAndAdvancementNamesOnly() {
for (String key : Set.of("commands.message.display.incoming", "commands.message.display.outgoing",
"chat.type.advancement.task", "chat.type.advancement.goal", "chat.type.advancement.challenge")) {
String input = "{\"translate\":\"" + key + "\",\"with\":[\"secret\",{\"text\":\"Secret\"}]}";
var args = JsonParser.parseString(masker.maskAnnouncement(input)).getAsJsonObject().getAsJsonArray("with");
assertEquals("Anonymous", args.get(0).getAsJsonObject().get("text").getAsString());
assertEquals("Secret", args.get(1).getAsJsonObject().get("text").getAsString());
}
}
@Test
void doesNotTraverseMessageBodyEvenWhenItContainsChatTranslations() {
String input = """
{"translate":"chat.type.text","with":["Ordinary",
{"translate":"chat.type.text","with":["Secret","body"]}]}
""";
assertEquals(input, masker.maskAnnouncement(input));
}
@Test
void handlesNestedAnnouncementsButNotPlainTextOrUnknownTranslations() {
assertTrue(masker.maskAnnouncement("{\"text\":\"\",\"extra\":[{\"translate\":\"chat.type.text\",\"with\":[\"Secret\",\"hello\"]}]}").contains("Anonymous"));
for (String input : Set.of("{\"text\":\"Secret: hello\"}", "{\"translate\":\"custom\",\"with\":[\"Secret\"]}", "not json")) {
assertEquals(input, masker.maskAnnouncement(input));
}
}
@Test
void removesInheritedActionsFromArrayParent() {
String json = """
[{"text":"","hover_event":{"action":"show_text","value":"Secret"}},
{"translate":"chat.type.text","with":["Secret","hello"]}]
""";
assertFalse(masker.maskAnnouncement(json).contains("Secret"));
}
@Test
void recognizesSplitNamesAndMasksWholeNameComponent() {
String name = "{\"text\":\"Sec\",\"extra\":[{\"text\":\"ret\"}],\"insertion\":\"Secret\"}";
assertTrue(masker.maskDisplayName(name).contains("Anonymous"));
assertFalse(masker.maskDisplayName(name).contains("Secret"));
assertEquals("\"NotSecret\"", masker.maskDisplayName("\"NotSecret\""));
}
}
@@ -62,8 +62,9 @@ class StealthSessionListenerTest {
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
IdentityPresentation presentation = mock(IdentityPresentation.class);
StealthSessionService sessions = new StealthSessionService(manager, progression);
StealthSessionListener listener = new StealthSessionListener(
new StealthSessionService(manager, progression), presentation, "Stealth active");
sessions, presentation, "Stealth active");
Player player = player(playerId);
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player);
@@ -73,6 +74,10 @@ class StealthSessionListenerTest {
verify(event).setJoinMessage(null);
verify(presentation).conceal(player);
verify(player).sendMessage("Stealth active");
Player admin = player(UUID.randomUUID());
when(admin.hasPermission("spigotstealth.admin")).thenReturn(true);
new StealthAdminJoinListener(sessions, () -> java.util.List.of(player, admin)).onJoin(event);
verify(admin).sendMessage("[Stealth] An invisible player joined. 1 player is now invisible. Use /stealthadmin list to see who.");
}
}