feat(stealth): mask identities and notify admins on login
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user