diff --git a/README.md b/README.md index ae2cb74..c98d447 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The same bridge connects selected moderation and roleplay events to the wider co - **Discord account linking** — players can request a linking code or unlink their Minecraft account. - **Website feature codes** — issues scoped codes for skins, drinks, and player profiles, subject to access rules and cooldowns. - **Account status updates** — processes website notices about linking and eligibility, including RPCharacters survival access checks. -- **Moderation connections** — sends player warnings and supported Essentials ban or unban events to the linked services. +- **Moderation connections** — sends player warnings, and bans, unbans and expired timed bans from the server ban list, to the linked services so the Discord Banned role follows the in-game ban. - **Player benefit synchronization** — shares resolved player metadata and entitlements with ProvinceSystem. - **Bird-mail notifications** — provides the connection for BirdMessenger arrival notifications to linked Discord accounts. diff --git a/src/main/java/net/tfminecraft/tfmcweb/Cache.java b/src/main/java/net/tfminecraft/tfmcweb/Cache.java index e82298a..9aa0927 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/Cache.java +++ b/src/main/java/net/tfminecraft/tfmcweb/Cache.java @@ -72,6 +72,10 @@ public final class Cache { /** Tier key → LuckPerms group. Only these groups are ever added or removed. */ public static Map patreonGroups = Map.copyOf(patreonGroupDefaults()); + /** Mirror this server's ban list (bans, unbans, expiries) to the Discord bot. */ + public static boolean banMirrorEnabled = true; + public static int banMirrorPollSeconds = 30; + private Cache() {} /** Mutable noble/gilded/ascended → same-name group map. */ diff --git a/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java index a44964c..102eb96 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java +++ b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java @@ -8,10 +8,10 @@ import net.tfminecraft.tfmcweb.cache.LinkCache; import net.tfminecraft.tfmcweb.gate.DiscordGateService; -import net.tfminecraft.tfmcweb.listeners.EssentialsBanListener; import net.tfminecraft.tfmcweb.listeners.PlayerJoinListener; import net.tfminecraft.tfmcweb.loaders.ConfigLoader; import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService; +import net.tfminecraft.tfmcweb.managers.BanMirrorPoller; import net.tfminecraft.tfmcweb.managers.LinkDiscordCommand; import net.tfminecraft.tfmcweb.managers.PatreonCommand; import net.tfminecraft.tfmcweb.managers.PluginNoticePoller; @@ -30,6 +30,7 @@ public class TFMCWeb extends JavaPlugin { private DiscordGateService gateService; private PluginNoticePoller noticePoller; private PatreonRankWriter rankWriter; + private BanMirrorPoller banMirror; @Override public void onEnable() { @@ -95,7 +96,8 @@ public void onEnable() { new PlayerJoinListener(this, gateService), this ); - new EssentialsBanListener(this, linkCache).register(); + banMirror = new BanMirrorPoller(this, linkCache); + banMirror.start(); getLogger().info( "TFMCWeb enabled (api=" @@ -114,6 +116,9 @@ public void onDisable() { if (rankWriter != null) { rankWriter.stop(); } + if (banMirror != null) { + banMirror.stop(); + } } public LinkCache getLinkCache() { diff --git a/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java b/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java deleted file mode 100644 index 2b4faf4..0000000 --- a/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java +++ /dev/null @@ -1,266 +0,0 @@ -package net.tfminecraft.tfmcweb.listeners; - -import java.lang.reflect.Method; -import java.time.Duration; -import java.time.Instant; -import java.util.UUID; -import java.util.logging.Level; - -import org.bukkit.Bukkit; -import org.bukkit.event.Event; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.plugin.EventExecutor; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.java.JavaPlugin; - -import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; -import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.IdentityStatus; -import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.MirrorResult; -import net.tfminecraft.tfmcweb.cache.LinkCache; - -/** - * Mirrors EssentialsX BanStatusChangeEvent → ProvinceSystem moderation outbox. - */ -public final class EssentialsBanListener { - - private static final String EVENT_CLASS = "net.ess3.api.events.BanStatusChangeEvent"; - - private final JavaPlugin plugin; - private final LinkCache linkCache; - private boolean registered; - - public EssentialsBanListener(JavaPlugin plugin, LinkCache linkCache) { - this.plugin = plugin; - this.linkCache = linkCache; - } - - public boolean register() { - Plugin ess = Bukkit.getPluginManager().getPlugin("Essentials"); - if (ess == null || !ess.isEnabled()) { - plugin.getLogger().info("Essentials not found — ban mirror disabled."); - return false; - } - try { - @SuppressWarnings("unchecked") - Class eventClass = - (Class) Class.forName(EVENT_CLASS); - Listener marker = new Listener() {}; - EventExecutor executor = (listener, event) -> handle(event); - Bukkit.getPluginManager().registerEvent( - eventClass, - marker, - EventPriority.MONITOR, - executor, - plugin, - true - ); - registered = true; - plugin.getLogger().info("Essentials ban mirror listener registered."); - return true; - } catch (ClassNotFoundException e) { - plugin.getLogger().warning( - "Essentials present but BanStatusChangeEvent missing — ban mirror disabled." - ); - return false; - } catch (Exception e) { - plugin.getLogger().log(Level.WARNING, "Failed to register Essentials ban listener", e); - return false; - } - } - - public boolean isRegistered() { - return registered; - } - - private void handle(Event event) { - try { - boolean nowBanned = readBanned(event); - UUID uuid = readUuid(event); - String name = readName(event); - String reason = readReason(event); - String duration = nowBanned ? readDuration(event) : null; - String staffName = readControllerName(event); - - if (uuid == null && (name == null || name.isBlank())) { - plugin.getLogger().fine("[ban-mirror] event missing player identity"); - return; - } - - final boolean banned = nowBanned; - final UUID playerUuid = uuid; - final String mcName = name; - final String banReason = reason; - final String dur = duration; - final String staff = staffName; - - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - UUID id = playerUuid; - if (id == null && mcName != null) { - @SuppressWarnings("deprecation") - org.bukkit.OfflinePlayer offline = Bukkit.getOfflinePlayer(mcName); - id = offline.getUniqueId(); - } - if (id == null) { - plugin.getLogger().warning("[ban-mirror] could not resolve UUID for " + mcName); - return; - } - String discordId = resolveDiscordId(id); - String eventType = banned ? "ban" : "unban"; - MirrorResult result = ProvinceSystemClient.postBanEvent( - eventType, - id.toString(), - discordId, - mcName, - banReason, - banned ? (dur != null ? dur : "Permanent") : null, - staff - ); - if (!result.ok) { - plugin.getLogger().warning( - "[ban-mirror] API failed for " + eventType + " " + mcName + ": " + result.error - ); - return; - } - if (!result.mirrored) { - plugin.getLogger().info( - "[ban-mirror] " + eventType + " for " + mcName - + " — no Discord link, skip Discord side-effects" - ); - } - }); - } catch (Exception e) { - plugin.getLogger().log(Level.WARNING, "[ban-mirror] handler failed", e); - } - } - - private String resolveDiscordId(UUID uuid) { - LinkCache.Entry cached = linkCache.get(uuid); - if (cached != null && cached.discordUserId != null && !cached.discordUserId.isBlank()) { - return cached.discordUserId; - } - IdentityStatus status = ProvinceSystemClient.getIdentityStatus(uuid.toString()); - if (status.ok && status.discordUserId != null && !status.discordUserId.isBlank()) { - linkCache.putFromStatus(uuid, status); - return status.discordUserId; - } - return null; - } - - private static boolean readBanned(Event event) throws Exception { - Object value = tryInvoke(event, "getValue"); - if (value instanceof Boolean) { - return ((Boolean) value).booleanValue(); - } - Object banned = tryInvoke(event, "isBanned"); - if (banned instanceof Boolean) { - return ((Boolean) banned).booleanValue(); - } - return true; - } - - private static UUID readUuid(Event event) throws Exception { - Object affected = tryInvoke(event, "getAffected"); - if (affected == null) { - return null; - } - Object uuid = tryInvoke(affected, "getUUID"); - if (uuid instanceof UUID) { - return (UUID) uuid; - } - uuid = tryInvoke(affected, "getBase"); - if (uuid != null) { - Object id = tryInvoke(uuid, "getUniqueId"); - if (id instanceof UUID) { - return (UUID) id; - } - } - return null; - } - - private static String readName(Event event) throws Exception { - Object affected = tryInvoke(event, "getAffected"); - if (affected != null) { - Object name = tryInvoke(affected, "getName"); - if (name != null) { - return String.valueOf(name); - } - } - Object name = tryInvoke(event, "getName"); - return name != null ? String.valueOf(name) : null; - } - - private static String readReason(Event event) throws Exception { - Object entry = tryInvoke(event, "getEntry"); - if (entry == null) { - entry = tryInvoke(event, "getBanEntry"); - } - if (entry != null) { - Object reason = tryInvoke(entry, "getReason"); - if (reason != null) { - return String.valueOf(reason); - } - } - return null; - } - - private static String readDuration(Event event) throws Exception { - Object entry = tryInvoke(event, "getEntry"); - if (entry == null) { - entry = tryInvoke(event, "getBanEntry"); - } - if (entry == null) { - return "Permanent"; - } - Object timeout = tryInvoke(entry, "getTimeout"); - if (timeout == null) { - timeout = tryInvoke(entry, "getExpiration"); - } - if (timeout == null) { - return "Permanent"; - } - long millis; - if (timeout instanceof Long) { - millis = ((Long) timeout).longValue(); - } else if (timeout instanceof Instant) { - millis = ((Instant) timeout).toEpochMilli(); - } else { - return "Permanent"; - } - if (millis <= 0L) { - return "Permanent"; - } - Duration d = Duration.between(Instant.now(), Instant.ofEpochMilli(millis)); - if (d.isNegative() || d.isZero()) { - return "Permanent"; - } - long days = d.toDays(); - if (days >= 1) { - return days + (days == 1 ? " day" : " days"); - } - long hours = d.toHours(); - if (hours >= 1) { - return hours + "h"; - } - long minutes = Math.max(1, d.toMinutes()); - return minutes + "m"; - } - - private static String readControllerName(Event event) throws Exception { - Object controller = tryInvoke(event, "getController"); - if (controller == null) { - return null; - } - Object name = tryInvoke(controller, "getName"); - return name != null ? String.valueOf(name) : null; - } - - private static Object tryInvoke(Object target, String method) throws Exception { - try { - Method m = target.getClass().getMethod(method); - return m.invoke(target); - } catch (NoSuchMethodException e) { - return null; - } - } -} diff --git a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java index ec000b5..a480505 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java @@ -39,6 +39,11 @@ public void load(File configFile) { loadTokenCooldowns(config); PlayerMetaConfigLoader.load(config); loadPatreon(config); + Cache.banMirrorEnabled = config.getBoolean("ban-mirror.enabled", true); + Cache.banMirrorPollSeconds = positiveOrDefault( + config.getInt("ban-mirror.poll-seconds", 30), + 30 + ); } private void loadPatreon(FileConfiguration config) { diff --git a/src/main/java/net/tfminecraft/tfmcweb/managers/BanMirrorPoller.java b/src/main/java/net/tfminecraft/tfmcweb/managers/BanMirrorPoller.java new file mode 100644 index 0000000..cb09afa --- /dev/null +++ b/src/main/java/net/tfminecraft/tfmcweb/managers/BanMirrorPoller.java @@ -0,0 +1,386 @@ +package net.tfminecraft.tfmcweb.managers; + +import java.io.File; +import java.io.IOException; +import java.text.Normalizer; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; + +import org.bukkit.BanEntry; +import org.bukkit.Bukkit; +import org.bukkit.ban.ProfileBanList; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +import com.destroystokyo.paper.profile.PlayerProfile; + +import io.papermc.paper.ban.BanListType; +import net.tfminecraft.tfmcweb.Cache; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.IdentityStatus; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.MirrorResult; +import net.tfminecraft.tfmcweb.cache.LinkCache; + +/** + * Mirrors the server's player ban list to the ProvinceSystem moderation outbox. + * EssentialsX has no ban event and timed bans expire silently, so the list is + * diffed on a timer: new entries post a ban, removed or expired entries post an + * unban (which clears the Discord Banned role). + */ +public final class BanMirrorPoller { + + static final String STATE_FILE = "ban-mirror.yml"; + static final String EXPIRED_STAFF = "Ban expired"; + + private static final int REASON_MAX = 500; + private static final int STAFF_NAME_MAX = 32; + private static final int MC_NAME_MAX = 16; + private static final Pattern COLOUR_CODE = + Pattern.compile("(?i)[§&][0-9a-fk-or]|#[0-9a-f]{6}"); + + private final JavaPlugin plugin; + private final LinkCache linkCache; + private final File stateFile; + private final AtomicBoolean busy = new AtomicBoolean(); + private final Set warned = ConcurrentHashMap.newKeySet(); + /** Bans already mirrored; null until the first list has been adopted. */ + private Map known; + private BukkitTask task; + + public BanMirrorPoller(JavaPlugin plugin, LinkCache linkCache) { + this.plugin = plugin; + this.linkCache = linkCache; + this.stateFile = new File(plugin.getDataFolder(), STATE_FILE); + } + + public void start() { + if (task != null) { + return; + } + if (!Cache.banMirrorEnabled) { + plugin.getLogger().info("Ban mirror disabled in config."); + return; + } + known = loadState(); + long period = Cache.banMirrorPollSeconds * 20L; + task = Bukkit.getScheduler().runTaskTimer(plugin, this::tick, period, period); + plugin.getLogger().info( + "Ban mirror polling the ban list every " + Cache.banMirrorPollSeconds + "s." + ); + } + + public void stop() { + if (task != null) { + task.cancel(); + task = null; + } + } + + /** Main thread: snapshot the ban list, then post differences off-thread. */ + void tick() { + if (!busy.compareAndSet(false, true)) { + return; + } + long now = System.currentTimeMillis(); + Map current; + try { + current = readBanList(now); + } catch (RuntimeException e) { + plugin.getLogger().warning("[ban-mirror] could not read the ban list: " + e); + busy.set(false); + return; + } + if (known == null) { + // First run: adopt existing bans without notifying anyone. + known = current; + Bukkit.getScheduler().runTaskAsynchronously(plugin, this::saveAndRelease); + return; + } + List changes = diff(known, current, now); + if (changes.isEmpty()) { + busy.set(false); + return; + } + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + for (Change change : changes) { + post(change); + } + } finally { + saveAndRelease(); + } + }); + } + + private void saveAndRelease() { + try { + saveState(known); + } finally { + busy.set(false); + } + } + + static Map readBanList(long now) { + Map bans = new HashMap<>(); + ProfileBanList list = Bukkit.getBanList(BanListType.PROFILE); + for (BanEntry entry : list.>getEntries()) { + PlayerProfile profile = entry.getBanTarget(); + UUID id = profile == null ? null : profile.getId(); + if (id == null) { + continue; + } + long expires = millis(entry.getExpiration()); + if (expires > 0L && expires <= now) { + continue; + } + bans.put(id, new BanRecord( + profile.getName(), + millis(entry.getCreated()), + expires, + entry.getReason(), + entry.getSource() + )); + } + return bans; + } + + static List diff(Map known, Map current, long now) { + List changes = new ArrayList<>(); + for (Map.Entry entry : known.entrySet()) { + BanRecord was = entry.getValue(); + BanRecord is = current.get(entry.getKey()); + if (is == null) { + String staff = was.expires > 0L && was.expires <= now ? EXPIRED_STAFF : null; + changes.add(new Change(entry.getKey(), false, was, staff)); + } else if (is.created != was.created || is.expires != was.expires) { + changes.add(new Change(entry.getKey(), true, is, is.source)); + } + } + for (Map.Entry entry : current.entrySet()) { + if (!known.containsKey(entry.getKey())) { + BanRecord ban = entry.getValue(); + changes.add(new Change(entry.getKey(), true, ban, ban.source)); + } + } + return changes; + } + + private void post(Change change) { + BanRecord ban = change.record; + String eventType = change.banned ? "ban" : "unban"; + String name = cleanDisplay(ban.name, MC_NAME_MAX); + MirrorResult result = ProvinceSystemClient.postBanEvent( + eventType, + change.id.toString(), + resolveDiscordId(change.id), + name, + change.banned ? cleanReason(ban.reason) : null, + change.banned ? formatDuration(ban.expires, banStart(ban)) : null, + cleanDisplay(change.staff, STAFF_NAME_MAX) + ); + if (!result.ok) { + if (warned.add(change.id)) { + plugin.getLogger().warning( + "[ban-mirror] API failed for " + eventType + " " + name + ": " + result.error + + " (retrying quietly)" + ); + } + return; + } + warned.remove(change.id); + if (change.banned) { + known.put(change.id, ban); + } else { + known.remove(change.id); + } + plugin.getLogger().info( + "[ban-mirror] " + eventType + " for " + name + + (result.mirrored ? " sent to Discord" : " (no Discord link)") + ); + } + + private String resolveDiscordId(UUID uuid) { + LinkCache.Entry cached = linkCache.get(uuid); + if (cached != null && cached.discordUserId != null && !cached.discordUserId.isBlank()) { + return cached.discordUserId; + } + IdentityStatus status = ProvinceSystemClient.getIdentityStatus(uuid.toString()); + if (status.ok && status.discordUserId != null && !status.discordUserId.isBlank()) { + linkCache.putFromStatus(uuid, status); + return status.discordUserId; + } + return null; + } + + private Map loadState() { + if (!stateFile.isFile()) { + return null; + } + Map state = new HashMap<>(); + ConfigurationSection bans = + YamlConfiguration.loadConfiguration(stateFile).getConfigurationSection("bans"); + if (bans == null) { + return state; + } + for (String key : bans.getKeys(false)) { + UUID id; + try { + id = UUID.fromString(key); + } catch (IllegalArgumentException e) { + continue; + } + state.put(id, new BanRecord( + bans.getString(key + ".name"), + bans.getLong(key + ".created"), + bans.getLong(key + ".expires"), + null, + null + )); + } + return state; + } + + private void saveState(Map state) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.createSection("bans"); + for (Map.Entry entry : state.entrySet()) { + String key = "bans." + entry.getKey(); + yaml.set(key + ".name", entry.getValue().name); + yaml.set(key + ".created", entry.getValue().created); + yaml.set(key + ".expires", entry.getValue().expires); + } + try { + yaml.save(stateFile); + } catch (IOException e) { + plugin.getLogger().warning("[ban-mirror] could not save " + STATE_FILE + ": " + e); + } + } + + private static long millis(Date date) { + return date == null ? 0L : date.getTime(); + } + + /** Full ban length, not time left: a 7 day ban seen a minute late is still "7 days". */ + private static long banStart(BanRecord ban) { + return ban.created > 0L ? ban.created : System.currentTimeMillis(); + } + + static String formatDuration(long expires, long since) { + if (expires <= 0L) { + return "Permanent"; + } + Duration d = Duration.ofMillis(Math.max(0L, expires - since)); + long days = d.toDays(); + if (days >= 1) { + return days + (days == 1 ? " day" : " days"); + } + long hours = d.toHours(); + if (hours >= 1) { + return hours + "h"; + } + return Math.max(1, d.toMinutes()) + "m"; + } + + /** Ban reasons may hold symbols the API's prose check rejects (e.g. "|"). */ + static String cleanReason(String reason) { + if (reason == null) { + return null; + } + String text = COLOUR_CODE.matcher(Normalizer.normalize(reason, Normalizer.Form.NFKC)) + .replaceAll(""); + StringBuilder sb = new StringBuilder(text.length()); + text.codePoints().forEach(cp -> { + if (allowedInProse(cp)) { + sb.appendCodePoint(cp); + } else { + sb.append(' '); + } + }); + String cleaned = sb.toString().replaceAll("\\s+", " ").trim(); + if (cleaned.length() > REASON_MAX) { + cleaned = cleaned.substring(0, REASON_MAX).trim(); + } + return cleaned.isEmpty() ? null : cleaned; + } + + private static boolean allowedInProse(int cp) { + switch (Character.getType(cp)) { + case Character.CONTROL: + case Character.LINE_SEPARATOR: + case Character.PARAGRAPH_SEPARATOR: + case Character.MATH_SYMBOL: + case Character.CURRENCY_SYMBOL: + case Character.MODIFIER_SYMBOL: + case Character.OTHER_SYMBOL: + case Character.FORMAT: + case Character.SURROGATE: + case Character.PRIVATE_USE: + case Character.UNASSIGNED: + case Character.ENCLOSING_MARK: + return false; + default: + return true; + } + } + + /** Keeps the characters the API allows in names: letters, digits and " .-'_". */ + static String cleanDisplay(String value, int max) { + if (value == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + Normalizer.normalize(value, Normalizer.Form.NFKC).codePoints().forEach(cp -> { + if (Character.isLetter(cp) || Character.getType(cp) == Character.DECIMAL_DIGIT_NUMBER + || " .-'_".indexOf(cp) >= 0) { + sb.appendCodePoint(cp); + } + }); + String cleaned = sb.toString().replaceAll("\\s+", " ").trim(); + if (cleaned.length() > max) { + cleaned = cleaned.substring(0, max).trim(); + } + return cleaned.isEmpty() ? null : cleaned; + } + + /** One ban as last seen; expires is 0 for permanent bans. */ + static final class BanRecord { + final String name; + final long created; + final long expires; + final String reason; + final String source; + + BanRecord(String name, long created, long expires, String reason, String source) { + this.name = name; + this.created = created; + this.expires = expires; + this.reason = reason; + this.source = source; + } + } + + static final class Change { + final UUID id; + final boolean banned; + final BanRecord record; + final String staff; + + Change(UUID id, boolean banned, BanRecord record, String staff) { + this.id = id; + this.banned = banned; + this.record = record; + this.staff = staff; + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index bcc0d72..b828922 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -155,3 +155,10 @@ patreon: noble: noble gilded: gilded ascended: ascended + +# Mirror this server's ban list to Discord (DM + Banned role on ban, role +# cleared on unban or when a timed ban expires). Linked players only. The list +# is checked every poll-seconds; changes apply on restart. +ban-mirror: + enabled: true + poll-seconds: 30 diff --git a/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java b/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java deleted file mode 100644 index 5917ef3..0000000 --- a/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package net.ess3.api.events; - -import org.bukkit.event.Event; -import org.bukkit.event.HandlerList; - -/** Test-only shape for the optional Essentials reflective integration. */ -public class BanStatusChangeEvent extends Event { - private static final HandlerList HANDLERS = new HandlerList(); - public static HandlerList getHandlerList(){return HANDLERS;} - public Object value, banned, affected, entry, banEntry, controller, name; - public Object getValue(){return value;} - public Object isBanned(){return banned;} - public Object getAffected(){return affected;} - public Object getEntry(){return entry;} - public Object getBanEntry(){return banEntry;} - public Object getController(){return controller;} - public Object getName(){return name;} - @Override public HandlerList getHandlers(){return HANDLERS;} -} diff --git a/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java index 4349b98..a80b56e 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java @@ -7,6 +7,7 @@ import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService; import net.tfminecraft.tfmcweb.gate.DiscordGateService; +import net.tfminecraft.tfmcweb.managers.BanMirrorPoller; import net.tfminecraft.tfmcweb.managers.PluginNoticePoller; import org.bukkit.Bukkit; import org.bukkit.plugin.Plugin; @@ -20,13 +21,13 @@ class TFMCWebLifecycleTest { @BeforeEach void setup() throws Exception {state=new TestState();server=MockBukkit.mock();} @AfterEach void cleanup() throws Exception {MockBukkit.unmock();state.close();} @Test void enableRegistersCommandsAndReloadsConfigurationAndDisableStopsPoller() throws Exception { - try(var pollers=mockConstruction(PluginNoticePoller.class);var sync=mockStatic(PlayerMetaSyncService.class)){ - TFMCWeb plugin=MockBukkit.load(TFMCWeb.class);assertSame(plugin,TFMCWeb.plugin);assertNotNull(plugin.getLinkCache());assertNotNull(plugin.getGateService()); + try(var pollers=mockConstruction(PluginNoticePoller.class);var bans=mockConstruction(BanMirrorPoller.class);var sync=mockStatic(PlayerMetaSyncService.class)){ + TFMCWeb plugin=MockBukkit.load(TFMCWeb.class);verify(bans.constructed().getFirst()).start();assertSame(plugin,TFMCWeb.plugin);assertNotNull(plugin.getLinkCache());assertNotNull(plugin.getGateService()); for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning","patreon"})assertNotNull(plugin.getCommand(name).getExecutor()); assertNotNull(plugin.getCommand("web").getTabCompleter());assertNotNull(plugin.getCommand("token").getTabCompleter());assertNotNull(plugin.getCommand("patreon").getTabCompleter()); verify(pollers.constructed().getFirst()).start();assertTrue(TFMCWeb.isPresent()); Files.writeString(plugin.getDataFolder().toPath().resolve("config.yml"),"realm:\n id: dev\n");plugin.reloadLocalConfig();assertEquals("dev",TFMCWeb.getRealmId()); - plugin.onDisable();verify(pollers.constructed().getFirst()).stop(); + plugin.onDisable();verify(pollers.constructed().getFirst()).stop();verify(bans.constructed().getFirst()).stop(); } } @Test void enableCreatesMissingDataDirectoryAndLogsMissingCommands() throws Exception { diff --git a/src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java b/src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java deleted file mode 100644 index 08b5927..0000000 --- a/src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package net.tfminecraft.tfmcweb.listeners; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; -import java.time.Instant; -import java.util.*; -import java.util.logging.Logger; -import net.ess3.api.events.BanStatusChangeEvent; -import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; -import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*; -import net.tfminecraft.tfmcweb.cache.LinkCache; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import org.bukkit.event.*; -import org.bukkit.plugin.*; -import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.scheduler.BukkitScheduler; -import org.junit.jupiter.api.*; -import org.mockito.*; - -class EssentialsBanListenerTest { - public static class Affected { - public UUID uuid;public Object base;public String name; - public UUID getUUID(){return uuid;}public Object getBase(){return base;}public String getName(){return name;} - } - public static class Base {public UUID getUniqueId(){return ID;}} - public static class Entry { - public Object timeout,expiration;public String reason; - public Object getTimeout(){return timeout;}public Object getExpiration(){return expiration;}public String getReason(){return reason;} - } - public static class Controller {public String getName(){return "Staff";}} - static final UUID ID=UUID.randomUUID(); - JavaPlugin plugin;Plugin essentials;PluginManager manager;BukkitScheduler scheduler;Logger logger;LinkCache cache; - MockedStatic bukkit;MockedStatic api;EssentialsBanListener listener; - @BeforeEach void setup(){ - plugin=mock(JavaPlugin.class);logger=mock(Logger.class);when(plugin.getLogger()).thenReturn(logger);essentials=mock(Plugin.class);when(essentials.isEnabled()).thenReturn(true); - manager=mock(PluginManager.class);when(manager.getPlugin("Essentials")).thenReturn(essentials);scheduler=mock(BukkitScheduler.class); - bukkit=mockStatic(Bukkit.class);bukkit.when(Bukkit::getPluginManager).thenReturn(manager);bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); - doAnswer(c->{c.getArgument(1,Runnable.class).run();return null;}).when(scheduler).runTaskAsynchronously(eq(plugin),any(Runnable.class)); - api=mockStatic(ProvinceSystemClient.class, call -> call.getMethod().getName().equals("jsonString") ? call.callRealMethod() : org.mockito.Answers.RETURNS_DEFAULTS.answer(call));api.when(()->ProvinceSystemClient.getIdentityStatus(anyString())).thenReturn(IdentityStatus.fail("none")); - api.when(()->ProvinceSystemClient.postBanEvent(anyString(),anyString(),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class))).thenReturn(MirrorResult.success(true)); - cache=new LinkCache();listener=new EssentialsBanListener(plugin,cache); - } - @AfterEach void cleanup(){api.close();bukkit.close();} - EventExecutor register(){assertTrue(listener.register());var executor=ArgumentCaptor.forClass(EventExecutor.class);verify(manager).registerEvent(eq(BanStatusChangeEvent.class),any(Listener.class),eq(EventPriority.MONITOR),executor.capture(),eq(plugin),eq(true));assertTrue(listener.isRegistered());return executor.getValue();} - BanStatusChangeEvent event(){var e=new BanStatusChangeEvent();var a=new Affected();a.uuid=ID;a.name="Ada";e.affected=a;e.value=true;return e;} - @Test void registrationHandlesMissingDisabledAndBrokenPluginManager() { - assertFalse(listener.isRegistered());when(manager.getPlugin("Essentials")).thenReturn(null);assertFalse(listener.register()); - when(manager.getPlugin("Essentials")).thenReturn(essentials);when(essentials.isEnabled()).thenReturn(false);assertFalse(listener.register()); - when(essentials.isEnabled()).thenReturn(true);doThrow(new IllegalStateException("bad")).when(manager).registerEvent(any(),any(),any(),any(),any(),anyBoolean());assertFalse(listener.register()); - } - @Test void registrationHandlesMissingOptionalEventClass() throws Exception { - String name=EssentialsBanListener.class.getName();ClassLoader parent=getClass().getClassLoader(); - ClassLoader isolated=new ClassLoader(parent){@Override protected Class loadClass(String candidate,boolean resolve)throws ClassNotFoundException{ - if(candidate.equals("net.ess3.api.events.BanStatusChangeEvent"))throw new ClassNotFoundException(candidate); - if(candidate.startsWith(name)){Class found=findLoadedClass(candidate);if(found!=null)return found; - try(var bytes=parent.getResourceAsStream(candidate.replace('.','/')+".class")){byte[] data=bytes.readAllBytes();Class cls=defineClass(candidate,data,0,data.length,EssentialsBanListener.class.getProtectionDomain());if(resolve)resolveClass(cls);return cls;}catch(java.io.IOException ex){throw new ClassNotFoundException(candidate,ex);}} - return super.loadClass(candidate,resolve); - }}; - Class type=isolated.loadClass(name);Object copy=type.getConstructor(JavaPlugin.class,LinkCache.class).newInstance(plugin,cache); - assertEquals(false,type.getMethod("register").invoke(copy));verify(logger).warning(contains("BanStatusChangeEvent missing")); - } - @Test void mirrorsBansUnbansAndIdentityFallbacks() throws Exception { - EventExecutor executor=register();var e=event();cache.putLinked(ID,"cached","user");e.controller=new Controller();executor.execute(null,e); - api.verify(()->ProvinceSystemClient.postBanEvent("ban",ID.toString(),"cached","Ada",null,"Permanent","Staff")); - e.value=false;executor.execute(null,e);api.verify(()->ProvinceSystemClient.postBanEvent("unban",ID.toString(),"cached","Ada",null,null,"Staff")); - e.value=null;e.banned=false;e.controller=new Object();((Affected)e.affected).uuid=null;((Affected)e.affected).base=new Base();cache.clear(ID); - var expectedIdentity0=IdentityStatus.fromJson("{\"discord_user_id\":\"fetched\"}"); - api.when(()->ProvinceSystemClient.getIdentityStatus(ID.toString())).thenReturn(expectedIdentity0);executor.execute(null,e);assertEquals("fetched",cache.get(ID).discordUserId); - e.banned=null;e.affected=null;e.name="Ada";OfflinePlayer offline=mock(OfflinePlayer.class);when(offline.getUniqueId()).thenReturn(ID);bukkit.when(()->Bukkit.getOfflinePlayer("Ada")).thenReturn(offline); - api.when(()->ProvinceSystemClient.postBanEvent(anyString(),anyString(),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class))).thenReturn(MirrorResult.success(false));executor.execute(null,e);verify(logger).info(contains("no Discord link")); - api.when(()->ProvinceSystemClient.postBanEvent(anyString(),anyString(),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class))).thenReturn(MirrorResult.fail("down"));executor.execute(null,e);verify(logger).warning(contains("API failed")); - when(offline.getUniqueId()).thenReturn(null);executor.execute(null,e);verify(logger).warning(contains("could not resolve UUID")); - e.name=" ";executor.execute(null,e);verify(logger).fine(contains("missing player identity")); - e.name=null;e.affected=new Affected();executor.execute(null,e); - } - @Test void durationsHandlePermanentPastAndDifferentTimeRepresentations() throws Exception { - EventExecutor executor=register();var e=event();var entry=new Entry();e.banEntry=entry;entry.reason="reason"; - for(Object time:Arrays.asList(null,"unsupported",0L,Instant.now().minusSeconds(5),Instant.now().plusSeconds(60),Instant.now().plusSeconds(3700),Instant.now().plusSeconds(87000),Instant.now().plusSeconds(173000))){ - entry.expiration=time;executor.execute(null,e); - } - entry.timeout=Instant.now().plusSeconds(60).toEpochMilli();executor.execute(null,e); - api.verify(()->ProvinceSystemClient.postBanEvent(eq("ban"),eq(ID.toString()),isNull(),eq("Ada"),eq("reason"),eq("1m"),isNull()),atLeastOnce()); - api.verify(()->ProvinceSystemClient.postBanEvent(eq("ban"),eq(ID.toString()),isNull(),eq("Ada"),eq("reason"),eq("1h"),isNull())); - api.verify(()->ProvinceSystemClient.postBanEvent(eq("ban"),eq(ID.toString()),isNull(),eq("Ada"),eq("reason"),eq("1 day"),isNull())); - api.verify(()->ProvinceSystemClient.postBanEvent(eq("ban"),eq(ID.toString()),isNull(),eq("Ada"),eq("reason"),eq("2 days"),isNull())); - e.entry=new Object();executor.execute(null,e); - } - @Test void handlerLogsUnexpectedReflectionFailures() throws Exception { - EventExecutor executor=register();var e=mock(BanStatusChangeEvent.class);when(e.getValue()).thenThrow(new IllegalStateException("bad"));executor.execute(null,e); - verify(logger).log(eq(java.util.logging.Level.WARNING),contains("handler failed"),any(Exception.class)); - } -} diff --git a/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java b/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java index fab7b15..682beb7 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java @@ -24,6 +24,9 @@ void load(String yaml) throws Exception { assertEquals(List.of("skin","drink","profile","skin_staff"),Cache.tokenEnabledScopes); assertEquals(List.of("skin","drink"),Cache.tokenCooldownSharedScopes);assertEquals(-1,Cache.tokenCooldownDefaultDays); assertEquals(3,Cache.rpcMetaDefaults.get("max-alive-characters"));assertTrue(Cache.rpcMetaGroups.isEmpty()); + assertTrue(Cache.banMirrorEnabled);assertEquals(30,Cache.banMirrorPollSeconds); + load("ban-mirror:\n enabled: false\n poll-seconds: 0\n");assertFalse(Cache.banMirrorEnabled);assertEquals(30,Cache.banMirrorPollSeconds); + load("ban-mirror:\n poll-seconds: 5\n");assertTrue(Cache.banMirrorEnabled);assertEquals(5,Cache.banMirrorPollSeconds); Cache.apiBaseUrl="preserved";Cache.patreonEnabled=true;new ConfigLoader().load(temp.resolve("missing.yml").toFile()); assertEquals("preserved",Cache.apiBaseUrl);assertTrue(Cache.patreonEnabled); load("bad: [");assertEquals("preserved",Cache.apiBaseUrl);assertTrue(Cache.patreonEnabled); diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/BanMirrorPollerTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/BanMirrorPollerTest.java new file mode 100644 index 0000000..9c1caec --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/managers/BanMirrorPollerTest.java @@ -0,0 +1,205 @@ +package net.tfminecraft.tfmcweb.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.destroystokyo.paper.profile.PlayerProfile; +import io.papermc.paper.ban.BanListType; +import java.nio.file.*; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.tfmcweb.Cache; +import net.tfminecraft.tfmcweb.TestState; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*; +import net.tfminecraft.tfmcweb.cache.LinkCache; +import org.bukkit.BanEntry; +import org.bukkit.Bukkit; +import org.bukkit.ban.ProfileBanList; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.*; + +class BanMirrorPollerTest { + static final UUID ADA = UUID.randomUUID(), BOB = UUID.randomUUID(); + static final long HOUR = 3_600_000L; + @TempDir Path temp; + TestState state; JavaPlugin plugin; Logger logger; BukkitScheduler scheduler; BukkitTask timer; ProfileBanList banList; + MockedStatic bukkit; MockedStatic api; LinkCache cache; + Set> entries = new HashSet<>(); + List deferred; Runnable tick; + + @BeforeEach void setup() throws Exception { + state = new TestState(); + plugin = mock(JavaPlugin.class); logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); when(plugin.getDataFolder()).thenReturn(temp.toFile()); + scheduler = mock(BukkitScheduler.class); timer = mock(BukkitTask.class); banList = mock(ProfileBanList.class); + when(banList.getEntries()).thenAnswer(c -> new HashSet<>(entries)); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.getBanList(BanListType.PROFILE)).thenReturn(banList); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(600L), eq(600L))).thenAnswer(c -> { tick = c.getArgument(1); return timer; }); + doAnswer(c -> { Runnable r = c.getArgument(1); if (deferred != null) deferred.add(r); else r.run(); return null; }) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + api = mockStatic(ProvinceSystemClient.class, call -> call.getMethod().getName().equals("jsonString") ? call.callRealMethod() : Answers.RETURNS_DEFAULTS.answer(call)); + api.when(() -> ProvinceSystemClient.getIdentityStatus(anyString())).thenReturn(IdentityStatus.fail("none")); + postReturns(MirrorResult.success(true)); + cache = new LinkCache(); + } + @AfterEach void cleanup() throws Exception { api.close(); bukkit.close(); state.close(); } + + void postReturns(MirrorResult result) { + api.when(() -> ProvinceSystemClient.postBanEvent(anyString(), anyString(), nullable(String.class), nullable(String.class), + nullable(String.class), nullable(String.class), nullable(String.class))).thenReturn(result); + } + @SuppressWarnings("unchecked") + BanEntry ban(UUID id, String name, long created, Long expires, String reason, String source) { + PlayerProfile profile = mock(PlayerProfile.class); + when(profile.getId()).thenReturn(id); when(profile.getName()).thenReturn(name); + BanEntry entry = mock(BanEntry.class); + when(entry.getBanTarget()).thenReturn(profile); when(entry.getCreated()).thenReturn(new Date(created)); + when(entry.getExpiration()).thenReturn(expires == null ? null : new Date(expires)); + when(entry.getReason()).thenReturn(reason); when(entry.getSource()).thenReturn(source); + entries.add(entry); + return entry; + } + BanMirrorPoller started() { BanMirrorPoller poller = new BanMirrorPoller(plugin, cache); poller.start(); return poller; } + void verifyPost(String type, UUID id, String discord, String name, String reason, String duration, String staff) { + String uuid = id.toString(); + api.verify(() -> ProvinceSystemClient.postBanEvent(type, uuid, discord, name, reason, duration, staff)); + } + void verifyNoPosts() { + api.verify(() -> ProvinceSystemClient.postBanEvent(anyString(), anyString(), nullable(String.class), nullable(String.class), + nullable(String.class), nullable(String.class), nullable(String.class)), never()); + } + + @Test void startHonoursConfigAndStopsOnce() { + Cache.banMirrorEnabled = false; + BanMirrorPoller poller = started(); + verify(logger).info(contains("disabled")); verify(scheduler, never()).runTaskTimer(any(JavaPlugin.class), any(Runnable.class), anyLong(), anyLong()); + poller.stop(); + Cache.banMirrorEnabled = true; + poller.start(); poller.start(); + verify(scheduler, times(1)).runTaskTimer(eq(plugin), any(Runnable.class), eq(600L), eq(600L)); + poller.stop(); poller.stop(); + verify(timer, times(1)).cancel(); + } + + @Test void firstRunAdoptsBansSilentlyThenMirrorsChangesAcrossRestarts() throws Exception { + long now = System.currentTimeMillis(); + var ada = ban(ADA, "Ada", now - HOUR, null, "Xray", "Mod"); + started(); tick.run(); + verifyNoPosts(); + assertTrue(Files.readString(temp.resolve(BanMirrorPoller.STATE_FILE)).contains(ADA.toString())); + + // Restart: state comes back from disk, so Ada stays quiet. + started(); tick.run(); + verifyNoPosts(); + + ban(BOB, "Bob", now, System.currentTimeMillis() + 1000, "Griefing | (3.1)", "Staffer"); + cache.putLinked(BOB, "42", "bob"); + tick.run(); + verifyPost("ban", BOB, "42", "Bob", "Griefing (3.1)", "1m", "Staffer"); + verify(logger).info(contains("ban for Bob sent to Discord")); + + entries.remove(ada); postReturns(MirrorResult.success(false)); + tick.run(); + verifyPost("unban", ADA, null, "Ada", null, null, null); + verify(logger).info(contains("unban for Ada (no Discord link)")); + + // Bob's timed ban lapses; the entry lingers in the list but counts as expired. + postReturns(MirrorResult.success(true)); + Thread.sleep(1100); + tick.run(); + verifyPost("unban", BOB, "42", "Bob", null, null, "Ban expired"); + tick.run(); + api.verify(() -> ProvinceSystemClient.postBanEvent(anyString(), anyString(), nullable(String.class), nullable(String.class), + nullable(String.class), nullable(String.class), nullable(String.class)), times(3)); + } + + @Test void rebansFailuresAndIdentityLookups() { + long now = System.currentTimeMillis(); + started(); tick.run(); + var first = ban(ADA, "Ada", now, now + 2 * HOUR, "first", "Mod"); + ban(BOB, "Bob", now, now + 26 * HOUR, "long", "Mod"); + var identity = IdentityStatus.fromJson("{\"discord_user_id\":\"fetched\"}"); + api.when(() -> ProvinceSystemClient.getIdentityStatus(ADA.toString())).thenReturn(identity); + tick.run(); + verifyPost("ban", ADA, "fetched", "Ada", "first", "2h", "Mod"); + verifyPost("ban", BOB, null, "Bob", "long", "1 day", "Mod"); + assertEquals("fetched", cache.get(ADA).discordUserId); + + entries.remove(first); ban(ADA, "Ada", now + 1, now + 3 * HOUR, "second", "Mod"); + postReturns(MirrorResult.fail("down")); + tick.run(); tick.run(); + verify(logger, times(1)).warning(contains("API failed for ban Ada: down")); + postReturns(MirrorResult.success(true)); + tick.run(); + api.verify(() -> ProvinceSystemClient.postBanEvent("ban", ADA.toString(), "fetched", "Ada", "second", "2h", "Mod"), times(3)); + } + + @Test void skipsOverlappingTicksAndSurvivesBanListFailures() { + deferred = new ArrayList<>(); + started(); tick.run(); tick.run(); + assertEquals(1, deferred.size()); + deferred.getFirst().run(); deferred = null; + bukkit.when(() -> Bukkit.getBanList(BanListType.PROFILE)).thenThrow(new IllegalStateException("boom")); + tick.run(); + verify(logger).warning(contains("could not read the ban list")); + bukkit.when(() -> Bukkit.getBanList(BanListType.PROFILE)).thenReturn(banList); + ban(ADA, "Ada", 1L, null, null, null); + ban(BOB, "Bob", 0L, System.currentTimeMillis() + 2 * HOUR - 60_000L, null, null); + tick.run(); + verifyPost("ban", ADA, null, "Ada", null, "Permanent", null); + verifyPost("ban", BOB, null, "Bob", null, "1h", null); + } + + @Test void readBanListSkipsEntriesWithoutAnId() { + @SuppressWarnings("unchecked") BanEntry noProfile = mock(BanEntry.class); + entries.add(noProfile); + ban(null, "ghost", 1L, null, null, null); + assertTrue(BanMirrorPoller.readBanList(System.currentTimeMillis()).isEmpty()); + } + + @Test void stateFileWithoutBansOrWithBadKeysIsReadAndSaveErrorsAreLogged() throws Exception { + Files.writeString(temp.resolve(BanMirrorPoller.STATE_FILE), "other: 1\n"); + ban(ADA, "Ada", 1L, null, "r", "s"); + started(); tick.run(); + verifyPost("ban", ADA, null, "Ada", "r", "Permanent", "s"); + + Files.writeString(temp.resolve(BanMirrorPoller.STATE_FILE), "bans:\n not-a-uuid:\n name: x\n " + ADA + ":\n name: Ada\n created: 1\n expires: 0\n"); + started(); tick.run(); + api.verify(() -> ProvinceSystemClient.postBanEvent(eq("ban"), anyString(), any(), any(), any(), any(), any()), times(1)); + + Files.delete(temp.resolve(BanMirrorPoller.STATE_FILE)); + Files.createDirectory(temp.resolve(BanMirrorPoller.STATE_FILE)); + new BanMirrorPoller(plugin, cache).tick(); + verify(logger).warning(contains("could not save")); + } + + @Test void formatsDurations() { + assertEquals("Permanent", BanMirrorPoller.formatDuration(0L, 5L)); + assertEquals("2 days", BanMirrorPoller.formatDuration(49 * HOUR, 0L)); + assertEquals("1 day", BanMirrorPoller.formatDuration(24 * HOUR, 0L)); + assertEquals("3h", BanMirrorPoller.formatDuration(3 * HOUR + 5, 0L)); + assertEquals("5m", BanMirrorPoller.formatDuration(5 * 60_000L, 0L)); + assertEquals("1m", BanMirrorPoller.formatDuration(10L, 20L)); + } + + @Test void cleansTextForTheApi() { + assertNull(BanMirrorPoller.cleanReason(null)); + assertNull(BanMirrorPoller.cleanReason(" | $ ")); + assertEquals("Red text and more", BanMirrorPoller.cleanReason("§cRed <ext\nand #ff00ffmore 😀")); + assertEquals("(4.16) - Do not parkour, use magic/skills.", BanMirrorPoller.cleanReason("(4.16) - Do not parkour, use magic/skills.")); + assertEquals("José's ban", BanMirrorPoller.cleanReason("José's ban")); + assertEquals(500, BanMirrorPoller.cleanReason("a".repeat(600)).length()); + assertNull(BanMirrorPoller.cleanDisplay(null, 16)); + assertNull(BanMirrorPoller.cleanDisplay("!!!", 16)); + assertEquals("Ryan's console", BanMirrorPoller.cleanDisplay("Ryan's console!", 32)); + assertEquals("Some_Long_Name_1", BanMirrorPoller.cleanDisplay("Some_Long_Name_1234", 16)); + assertEquals("abc", BanMirrorPoller.cleanDisplay("abc ".repeat(9), 3)); + } +}