diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..48d130d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,3 +74,11 @@ jobs: name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }} path: target/surefire-reports/ if-no-files-found: error + + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 87d147c..337486b 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -73,6 +73,14 @@ jobs: echo "jar=target/$final_name.jar" >> "$GITHUB_OUTPUT" mvn -B --no-transfer-progress clean verify -DskipTests=false -Dmaven.test.skip=false + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error + - name: Stage only the release JAR env: ARTIFACT_PATH: ${{ steps.maven.outputs.jar }} diff --git a/README.md b/README.md index c8c1d59..54c62f9 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,12 @@ Copyright (c) 2026 TF-Minecraft contributors. TF-Minecraft-authored material in this repository is licensed under the [Artistic License 2.0](LICENSE). Third-party dependencies and bundled material retain their own licenses. + +## Tests and coverage + +Run `mvn clean verify` with Java 21. The build runs the unit tests and enforces +100% executable runtime **line coverage** with JaCoCo, without production-class +exclusions. Instruction and branch coverage are reported separately. + +The HTML report is `target/site/jacoco/index.html`; the machine-readable report is +`target/site/jacoco/jacoco.xml`. CI uploads these reports alongside test results. diff --git a/pom.xml b/pom.xml index f1f7f3c..39227dd 100644 --- a/pom.xml +++ b/pom.xml @@ -9,6 +9,7 @@ main-SNAPSHOT + 21 UTF-8 full @@ -16,6 +17,18 @@ + + org.slf4j + slf4j-simple + 2.0.17 + test + + + com.google.code.gson + gson + 2.11.0 + provided + io.papermc.paper paper-api @@ -45,13 +58,19 @@ org.junit.jupiter junit-jupiter - 5.11.4 + 5.14.0 test org.mockito mockito-core - 5.14.2 + 5.23.0 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 test @@ -101,11 +120,48 @@ org.apache.maven.plugins maven-surefire-plugin - 3.5.2 + 3.5.4 - -javaagent:${org.mockito:mockito-core:jar} -Xshare:off + @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off + true + + + org.slf4j.simple.SimpleServiceProvider + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + prepare-agent + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + LINE + MISSEDCOUNT + 0 + + + + + + + + diff --git a/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java index ea147fb..6553b0f 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java +++ b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java @@ -139,6 +139,6 @@ public static String getRealmId() { if (realm == null || realm.isBlank()) { return "main"; } - return realm.trim().toLowerCase(); + return realm.trim().toLowerCase(java.util.Locale.ROOT); } } diff --git a/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClient.java b/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClient.java index da0a27b..b46223c 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClient.java +++ b/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClient.java @@ -11,6 +11,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Locale; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; import net.tfminecraft.tfmcweb.Cache; @@ -277,7 +281,7 @@ public static CodeResult startDiscordLink(String playerUuid, String minecraftNam public static FeatureCodeResult issueFeatureCode(String playerUuid, String scope) { String uuid = playerUuid == null ? "" : playerUuid.trim(); - String sc = scope == null ? "" : scope.trim().toLowerCase(); + String sc = scope == null ? "" : scope.trim().toLowerCase(Locale.ROOT); if (uuid.isEmpty()) { return FeatureCodeResult.fail("player_uuid is required"); } @@ -289,7 +293,7 @@ public static FeatureCodeResult issueFeatureCode(String playerUuid, String scope } String realm = Cache.realmId == null || Cache.realmId.isBlank() ? "main" - : Cache.realmId.trim().toLowerCase(); + : Cache.realmId.trim().toLowerCase(Locale.ROOT); String body = "{" + "\"player_uuid\":\"" + escapeJson(uuid) + "\"," + "\"scope\":\"" + escapeJson(sc) + "\"," @@ -410,7 +414,7 @@ public static MirrorResult postBanEvent( String duration, String staffName ) { - String etype = event == null ? "" : event.trim().toLowerCase(); + String etype = event == null ? "" : event.trim().toLowerCase(Locale.ROOT); if (!"ban".equals(etype) && !"unban".equals(etype)) { return MirrorResult.fail("event must be ban or unban"); } @@ -940,79 +944,21 @@ static String jsonString(String json, String key) { if (json == null || key == null) { return null; } - String needle = "\"" + key + "\""; - int keyIdx = json.indexOf(needle); - if (keyIdx < 0) { - return null; - } - int colon = json.indexOf(':', keyIdx + needle.length()); - if (colon < 0) { - return null; - } - int i = colon + 1; - while (i < json.length() && Character.isWhitespace(json.charAt(i))) { - i++; - } - if (i >= json.length()) { - return null; - } - char c = json.charAt(i); - if (c == '"') { - StringBuilder out = new StringBuilder(); - i++; - while (i < json.length()) { - char ch = json.charAt(i++); - if (ch == '\\' && i < json.length()) { - out.append(json.charAt(i++)); - continue; - } - if (ch == '"') { - break; - } - out.append(ch); + try { + JsonElement root = JsonParser.parseString(json); + if (!root.isJsonObject()) { + return null; } - return out.toString(); - } - if (c == 'n' && json.startsWith("null", i)) { + JsonElement value = root.getAsJsonObject().get(key); + return value != null && value.isJsonPrimitive() ? value.getAsString() : null; + } catch (com.google.gson.JsonParseException e) { return null; } - int start = i; - while (i < json.length()) { - char ch = json.charAt(i); - if (ch == ',' || ch == '}' || ch == ']') { - break; - } - i++; - } - return json.substring(start, i).trim(); } static String escapeJson(String raw) { - if (raw == null) { - return ""; - } - StringBuilder sb = new StringBuilder(raw.length() + 8); - for (int i = 0; i < raw.length(); i++) { - char ch = raw.charAt(i); - switch (ch) { - case '\\': - case '"': - sb.append('\\').append(ch); - break; - case '\n': - sb.append("\\n"); - break; - case '\r': - sb.append("\\r"); - break; - case '\t': - sb.append("\\t"); - break; - default: - sb.append(ch); - } - } - return sb.toString(); + String encoded = new Gson().toJson(raw == null ? "" : raw); + return encoded.substring(1, encoded.length() - 1); } private static String readBody(InputStream stream) throws Exception { diff --git a/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGateway.java b/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGateway.java index 1c66089..2ac1256 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGateway.java +++ b/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGateway.java @@ -10,8 +10,13 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; import net.tfminecraft.tfmcweb.Cache; import net.tfminecraft.tfmcweb.TFMCWeb; @@ -24,9 +29,6 @@ public final class ProvinceSystemGateway { private static final int TIMEOUT_MS = 8000; private static final int DOWNLOAD_TIMEOUT_MS = 30000; - private static final Pattern REALM_IN_BODY = Pattern.compile( - "\"realm_id\"\\s*:" - ); private ProvinceSystemGateway() {} @@ -169,15 +171,11 @@ static String injectRealmPath(String method, String path) { if (queryContainsRealm(query)) { return path; } - try { - String enc = URLEncoder.encode(realm, StandardCharsets.UTF_8); - if (q >= 0) { - return path + (query.isEmpty() ? "" : "&") + "realm_id=" + enc; - } - return path + "?realm_id=" + enc; - } catch (Exception e) { - return path + (q >= 0 ? "&" : "?") + "realm_id=" + realm; + String enc = URLEncoder.encode(realm, StandardCharsets.UTF_8); + if (q >= 0) { + return path + (query.isEmpty() ? "" : "&") + "realm_id=" + enc; } + return path + "?realm_id=" + enc; } static String injectRealmBody(String method, String path, String jsonBody) { @@ -192,19 +190,17 @@ static String injectRealmBody(String method, String path, String jsonBody) { if (!needsRealmBody(method, bare)) { return jsonBody; } - if (REALM_IN_BODY.matcher(jsonBody).find()) { - return jsonBody; - } - String realm = TFMCWeb.getRealmId(); - String trimmed = jsonBody.trim(); - if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + try { + JsonElement parsed = JsonParser.parseString(jsonBody); + if (!parsed.isJsonObject() || parsed.getAsJsonObject().has("realm_id")) { + return jsonBody; + } + // Only the top-level field scopes the request; nested metadata is independent. + parsed.getAsJsonObject().addProperty("realm_id", TFMCWeb.getRealmId()); + return parsed.toString(); + } catch (com.google.gson.JsonParseException e) { return jsonBody; } - String inner = trimmed.substring(1, trimmed.length() - 1).trim(); - if (inner.isEmpty()) { - return "{\"realm_id\":\"" + escapeJson(realm) + "\"}"; - } - return "{\"realm_id\":\"" + escapeJson(realm) + "\"," + inner + "}"; } private static boolean needsRealmQuery(String method, String barePath) { @@ -233,9 +229,6 @@ private static boolean needsRealmBody(String method, String barePath) { } private static boolean equalsPath(String bare, String expected) { - if (bare == null) { - return false; - } String p = bare.trim(); if (p.endsWith("/") && p.length() > 1) { p = p.substring(0, p.length() - 1); @@ -273,13 +266,17 @@ private static GatewayResult execute( return GatewayResult.fail(notConfiguredMessage()); } + if ("PATCH".equals(method)) { + return executePatch(base, key, path, body, contentType, readTimeoutMs); + } + HttpURLConnection connection = null; try { String root = trimSlash(base); @SuppressWarnings("deprecation") URL url = new URL(root + path); connection = (HttpURLConnection) url.openConnection(); - applyRequestMethod(connection, method); + connection.setRequestMethod(method.isBlank() ? "GET" : method); connection.setConnectTimeout(TIMEOUT_MS); connection.setReadTimeout(readTimeoutMs); connection.setRequestProperty("X-Plugin-Key", key); @@ -317,31 +314,33 @@ private static GatewayResult execute( } private static String trimSlash(String base) { - if (base == null) { - return ""; - } return base.endsWith("/") ? base.substring(0, base.length() - 1) : base; } - /** - * {@link HttpURLConnection} rejects PATCH on many JVMs ({@code Invalid HTTP method: PATCH}). - */ - private static void applyRequestMethod(HttpURLConnection connection, String method) - throws java.net.ProtocolException { - String m = method == null || method.isBlank() ? "GET" : method.trim().toUpperCase(Locale.ROOT); - try { - connection.setRequestMethod(m); - } catch (java.net.ProtocolException ex) { - if (!"PATCH".equals(m)) { - throw ex; - } - try { - var field = HttpURLConnection.class.getDeclaredField("method"); - field.setAccessible(true); - field.set(connection, "PATCH"); - } catch (ReflectiveOperationException roe) { - throw ex; + /** Use the supported JDK HTTP API for PATCH, which HttpURLConnection rejects. */ + private static GatewayResult executePatch(String base, String key, String path, + byte[] body, String contentType, int readTimeoutMs) { + try (HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(TIMEOUT_MS)).build()) { + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(trimSlash(base) + path)) + .timeout(Duration.ofMillis(readTimeoutMs)) + .header("X-Plugin-Key", key).header("Accept", "application/json"); + if (contentType != null) { + request.header("Content-Type", contentType); } + request.method("PATCH", body == null ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofByteArray(body)); + HttpResponse response = client.send(request.build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + int status = response.statusCode(); + return status >= 200 && status < 300 + ? GatewayResult.success(status, response.body()) + : GatewayResult.fail(status, detailOrHttp(response.body(), status)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return GatewayResult.fail("Could not reach API: request interrupted"); + } catch (Exception e) { + return GatewayResult.fail("Could not reach API: " + e.getMessage()); } } @@ -350,7 +349,7 @@ private static String notConfiguredMessage() { } private static String detailOrHttp(String response, int status) { - String detail = extractJsonString(response, "detail"); + String detail = ProvinceSystemClient.jsonString(response, "detail"); if (detail == null || detail.isEmpty()) { detail = response == null || response.isEmpty() ? ("HTTP " + status) @@ -362,52 +361,6 @@ private static String detailOrHttp(String response, int status) { return detail; } - private static String extractJsonString(String json, String key) { - if (json == null || key == null) { - return null; - } - Matcher m = Pattern.compile( - "\"" + Pattern.quote(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"" - ).matcher(json); - if (!m.find()) { - return null; - } - return unescape(m.group(1)); - } - - private static String unescape(String raw) { - if (raw == null) { - return null; - } - StringBuilder out = new StringBuilder(raw.length()); - for (int i = 0; i < raw.length(); i++) { - char c = raw.charAt(i); - if (c == '\\' && i + 1 < raw.length()) { - char n = raw.charAt(++i); - out.append(n == 'n' ? '\n' : n == 't' ? '\t' : n == 'r' ? '\r' : n); - } else { - out.append(c); - } - } - return out.toString(); - } - - private static String escapeJson(String raw) { - if (raw == null) { - return ""; - } - StringBuilder out = new StringBuilder(raw.length() + 8); - for (int i = 0; i < raw.length(); i++) { - char c = raw.charAt(i); - switch (c) { - case '\\' -> out.append("\\\\"); - case '"' -> out.append("\\\""); - default -> out.append(c); - } - } - return out.toString(); - } - private static String readBody(InputStream stream) throws Exception { if (stream == null) { return ""; @@ -428,9 +381,6 @@ private static String readBody(InputStream stream) throws Exception { } private static byte[] readBytes(InputStream stream) throws Exception { - if (stream == null) { - return new byte[0]; - } ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buf = new byte[8192]; int n; diff --git a/src/main/java/net/tfminecraft/tfmcweb/entitlements/PlayerMetaSyncService.java b/src/main/java/net/tfminecraft/tfmcweb/entitlements/PlayerMetaSyncService.java index aab143a..dff942e 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/entitlements/PlayerMetaSyncService.java +++ b/src/main/java/net/tfminecraft/tfmcweb/entitlements/PlayerMetaSyncService.java @@ -87,7 +87,7 @@ static String toJson(UUID playerUuid, ResolvedMeta meta) { sb.append(','); } first = false; - sb.append('"').append(escape(kind.trim().toLowerCase())).append('"'); + sb.append('"').append(escape(kind.trim().toLowerCase(java.util.Locale.ROOT))).append('"'); } sb.append(']'); sb.append(",\"allow_armor_3d_helmet\":").append(meta.allowArmor3dHelmet); @@ -110,18 +110,7 @@ static String toJson(UUID playerUuid, ResolvedMeta meta) { } private static String escape(String raw) { - if (raw == null) { - return ""; - } - StringBuilder out = new StringBuilder(raw.length() + 8); - for (int i = 0; i < raw.length(); i++) { - char c = raw.charAt(i); - switch (c) { - case '\\' -> out.append("\\\\"); - case '"' -> out.append("\\\""); - default -> out.append(c); - } - } - return out.toString(); + String encoded = new com.google.gson.Gson().toJson(raw); + return encoded.substring(1, encoded.length() - 1); } } diff --git a/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java b/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java index 38cc8c4..2b4faf4 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java +++ b/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java @@ -256,9 +256,6 @@ private static String readControllerName(Event event) throws Exception { } private static Object tryInvoke(Object target, String method) throws Exception { - if (target == null) { - return null; - } try { Method m = target.getClass().getMethod(method); return m.invoke(target); diff --git a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java index ebfbafb..8710186 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java @@ -25,9 +25,6 @@ public void load(File configFile) { } String base = config.getString("api.base-url", ""); - if (base == null) { - base = ""; - } base = base.trim(); while (base.endsWith("/")) { base = base.substring(0, base.length() - 1); diff --git a/src/main/java/net/tfminecraft/tfmcweb/loaders/PlayerMetaConfigLoader.java b/src/main/java/net/tfminecraft/tfmcweb/loaders/PlayerMetaConfigLoader.java index a24ae5f..c202de4 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/loaders/PlayerMetaConfigLoader.java +++ b/src/main/java/net/tfminecraft/tfmcweb/loaders/PlayerMetaConfigLoader.java @@ -117,9 +117,11 @@ private static void applyLadderSection(ConfigurationSection root) { skinsDefaults, Cache.skinsMetaDefaults ); - Cache.skinsMetaDefaultKinds = List.copyOf( - readStringList(skinsDefaults.getList("skin-kinds")) - ); + if (skinsDefaults.contains("skin-kinds")) { + Cache.skinsMetaDefaultKinds = List.copyOf( + readStringList(skinsDefaults.getList("skin-kinds")) + ); + } Cache.skinsMetaDefaultAllowArmor3dHelmet = skinsDefaults.getBoolean( "allow-armor-3d-helmet", Cache.skinsMetaDefaultAllowArmor3dHelmet diff --git a/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java b/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java new file mode 100644 index 0000000..5917ef3 --- /dev/null +++ b/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java @@ -0,0 +1,19 @@ +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/RuntimeUtilitiesTest.java b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java new file mode 100644 index 0000000..231808c --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java @@ -0,0 +1,73 @@ +package net.tfminecraft.tfmcweb; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.time.Instant; +import java.util.*; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; +import net.tfminecraft.tfmcweb.cache.LinkCache; +import net.tfminecraft.tfmcweb.managers.TokenCooldownService; +import net.tfminecraft.tfmcweb.utils.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; + +class RuntimeUtilitiesTest { + TestState state; + @BeforeEach void setup() throws Exception {state=new TestState();} + @AfterEach void cleanup() throws Exception {state.close();} + @Test void cacheTracksIdentityGraceAndEligibility() { + LinkCache cache=new LinkCache();UUID id=UUID.randomUUID(); + assertNull(cache.get(null));assertNull(cache.get(id));assertFalse(cache.isEligible(id)); + cache.put(null,LinkCache.Entry.unlinked());cache.put(id,null);assertEquals(0,cache.size()); + cache.putFromStatus(id,null);assertFalse(cache.get(id).linked); + cache.putFromStatus(id,ProvinceSystemClient.IdentityStatus.fail("bad"));assertFalse(cache.isEligible(id)); + cache.putFromStatus(id,ProvinceSystemClient.IdentityStatus.fromJson("{\"linked\":true,\"eligible\":true,\"in_grace\":true,\"discord_user_id\":\"d\",\"discord_username\":\"Ada\",\"grace_until\":\"soon\"}")); + assertTrue(cache.isEligible(id));assertEquals("soon",cache.get(id).graceUntil); + cache.putGrace(id,null,"later");assertEquals("d",cache.get(id).discordUserId);assertEquals("Ada",cache.get(id).discordUsername);assertTrue(cache.get(id).inGrace); + cache.clearGrace(id);assertFalse(cache.get(id).inGrace);assertEquals("Ada",cache.get(id).discordUsername); + cache.clear(null);cache.clear(id);cache.clearGrace(id);assertTrue(cache.get(id).linked);assertNull(cache.get(id).discordUserId); + cache.clear(id);cache.putGrace(id,null,"until");assertNull(cache.get(id).discordUserId); + cache.putGrace(id,"new","until");assertEquals("new",cache.get(id).discordUserId); + cache.putLinked(id,"discord","name");assertEquals("name",cache.get(id).discordUsername);assertEquals(1,cache.snapshot().size()); + cache.putUnlinked(id);assertFalse(cache.isEligible(id));cache.clear(id);assertEquals(0,cache.size()); + } + @Test void expiryLabelsHandlePastFutureAndMalformedDates() { + assertNull(ExpiryFormat.relativeLabel(null));assertNull(ExpiryFormat.relativeLabel(""));assertNull(ExpiryFormat.relativeLabel("bad")); + Instant now=Instant.now();assertEquals("Expired",ExpiryFormat.relativeLabel(now.minusSeconds(1).toString())); + assertEquals("Expires in ~2h",ExpiryFormat.relativeLabel(now.plusSeconds(7500).toString())); + assertEquals("Expires in ~30m",ExpiryFormat.relativeLabel(now.plusSeconds(1830).toString())); + assertEquals("Expires in ~1m",ExpiryFormat.relativeLabel(now.plusSeconds(5).toString())); + } + @Test void messagesIncludeInteractiveCopyCode() { + Player player=mock(Player.class);ChatMessages.info(player,"info");ChatMessages.error(player,"error"); + verify(player).sendMessage(ChatMessages.PREFIX+"info");verify(player).sendMessage(ChatMessages.PREFIX+"§cerror"); + ChatMessages.sendCopyableCode(player,"intro","ABC");verify(player).sendMessage(ChatMessages.PREFIX+"intro"); + ArgumentCaptor message=ArgumentCaptor.forClass(Component.class);verify(player).sendMessage(message.capture()); + assertEquals(ClickEvent.copyToClipboard("ABC"),message.getValue().children().get(1).clickEvent()); + ChatMessages.sendCopyableCode(player,null,null);ChatMessages.sendCopyableCode(player,"","");verify(player,times(1)).sendMessage(any(Component.class)); + } + @Test void cooldownPermissionOrderAndStatuses() { + Player player=mock(Player.class);UUID id=UUID.randomUUID();when(player.getUniqueId()).thenReturn(id); + Cache.tokenCooldownDefaultDays=-1;Cache.tokenCooldownGroups=Arrays.asList(null,new Cache.TokenCooldownGroup(null,1),new Cache.TokenCooldownGroup("rank.low",3),new Cache.TokenCooldownGroup("rank.high",1)); + assertEquals(-1,TokenCooldownService.resolveCooldownDays(null));assertEquals(-1,TokenCooldownService.resolveCooldownDays(player)); + assertEquals("Your rank cannot create drink tokens",TokenCooldownService.checkSharedMint(player,"DRINK")); + assertEquals("Your rank cannot create skin tokens",TokenCooldownService.checkSharedMint(player,null)); + when(player.hasPermission("rank.low")).thenReturn(true);when(player.hasPermission("rank.high")).thenReturn(true);assertEquals(1,TokenCooldownService.resolveCooldownDays(player)); + Cache.tokenCooldownGroups=List.of();Cache.tokenCooldownDefaultDays=0;assertNull(TokenCooldownService.checkSharedMint(player,"skin"));Cache.tokenCooldownDefaultDays=3; + try(var api=mockStatic(ProvinceSystemClient.class)){ + for(String value:Arrays.asList(null," ","bad","2000-01-01 00:00:00","2000-01-01T00:00:00")){ + api.when(()->ProvinceSystemClient.getCosmeticMintStatus(id.toString())).thenReturn(ProvinceSystemClient.CosmeticMintStatus.success(value));assertNull(TokenCooldownService.checkSharedMint(player,"skin")); + } + api.when(()->ProvinceSystemClient.getCosmeticMintStatus(id.toString())).thenReturn(ProvinceSystemClient.CosmeticMintStatus.fail("down"));assertEquals("down",TokenCooldownService.checkSharedMint(player,"skin")); + api.when(()->ProvinceSystemClient.getCosmeticMintStatus(id.toString())).thenReturn(ProvinceSystemClient.CosmeticMintStatus.fail(null));assertEquals("Could not check mint cooldown.",TokenCooldownService.checkSharedMint(player,"skin")); + for(long remaining:new long[]{1800,7200,180000}){ + api.when(()->ProvinceSystemClient.getCosmeticMintStatus(id.toString())).thenReturn(ProvinceSystemClient.CosmeticMintStatus.success(Instant.now().minusSeconds(3*86400-remaining).toString())); + String label=TokenCooldownService.checkSharedMint(player,"skin");assertTrue(label.startsWith("Token cooldown: try again in ")); + if(remaining==1800)assertTrue(label.endsWith("1h"));if(remaining==180000)assertTrue(label.contains("d ")); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java new file mode 100644 index 0000000..4a762b8 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java @@ -0,0 +1,50 @@ +package net.tfminecraft.tfmcweb; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.*; +import java.util.Comparator; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; +import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService; +import net.tfminecraft.tfmcweb.gate.DiscordGateService; +import net.tfminecraft.tfmcweb.managers.PluginNoticePoller; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +class TFMCWebLifecycleTest { + TestState state;ServerMock server; + @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()); + for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning"})assertNotNull(plugin.getCommand(name).getExecutor()); + assertNotNull(plugin.getCommand("web").getTabCompleter());assertNotNull(plugin.getCommand("token").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(); + } + } + @Test void enableCreatesMissingDataDirectoryAndLogsMissingCommands() throws Exception { + try(var pollers=mockConstruction(PluginNoticePoller.class);var sync=mockStatic(PlayerMetaSyncService.class);var gates=mockConstruction(DiscordGateService.class,(mock,context)->when(mock.isRpcAvailable()).thenReturn(true))){ + TFMCWeb plugin=MockBukkit.load(TFMCWeb.class);TFMCWeb spy=spy(plugin); + for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning"})doReturn(null).when(spy).getCommand(name); + try(var paths=Files.walk(plugin.getDataFolder().toPath())){for(Path path:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(path);} + spy.onEnable();assertTrue(Files.isDirectory(plugin.getDataFolder().toPath()));assertTrue(Files.exists(plugin.getDataFolder().toPath().resolve("config.yml"))); + spy.onDisable();verify(pollers.constructed().getLast()).stop(); + } + } + @Test void presenceAndRealmDefaultsTolerateUnavailablePlugin() { + PluginManager plugins=mock(PluginManager.class);Plugin plugin=mock(Plugin.class); + try(var bukkit=mockStatic(Bukkit.class)){ + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);assertFalse(TFMCWeb.isPresent()); + when(plugins.getPlugin("TFMCWeb")).thenReturn(plugin);assertFalse(TFMCWeb.isPresent());when(plugin.isEnabled()).thenReturn(true);assertTrue(TFMCWeb.isPresent()); + Cache.realmId=null;assertEquals("main",TFMCWeb.getRealmId());Cache.realmId=" ";assertEquals("main",TFMCWeb.getRealmId()); + } + TFMCWeb uninitialized=mock(TFMCWeb.class);doCallRealMethod().when(uninitialized).onDisable();uninitialized.onDisable(); + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/TestState.java b/src/test/java/net/tfminecraft/tfmcweb/TestState.java new file mode 100644 index 0000000..8ee965e --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/TestState.java @@ -0,0 +1,27 @@ +package net.tfminecraft.tfmcweb; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** Restores every mutable runtime setting even when a test fails. */ +public final class TestState implements AutoCloseable { + private final Map values = new LinkedHashMap<>(); + private final Locale locale = Locale.getDefault(); + public TestState() throws Exception { + for (Class type : new Class[]{Cache.class, TFMCWeb.class}) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) && !Modifier.isFinal(field.getModifiers())) { + field.setAccessible(true); + values.put(field, field.get(null)); + } + } + } + } + @Override public void close() throws Exception { + for (var entry : values.entrySet()) entry.getKey().set(null, entry.getValue()); + Locale.setDefault(locale); + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/api/HttpFixture.java b/src/test/java/net/tfminecraft/tfmcweb/api/HttpFixture.java new file mode 100644 index 0000000..3102cfe --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/api/HttpFixture.java @@ -0,0 +1,42 @@ +package net.tfminecraft.tfmcweb.api; + +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import net.tfminecraft.tfmcweb.Cache; + +final class HttpFixture implements AutoCloseable { + record Reply(int status, byte[] body) {} + record Request(String method, String path, String body, String key, String contentType) {} + private final HttpServer server; + private final LinkedBlockingQueue replies = new LinkedBlockingQueue<>(); + private final LinkedBlockingQueue requests = new LinkedBlockingQueue<>(); + HttpFixture() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + try { + requests.add(new Request(exchange.getRequestMethod(), exchange.getRequestURI().toString(), + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8), + exchange.getRequestHeaders().getFirst("X-Plugin-Key"), + exchange.getRequestHeaders().getFirst("Content-Type"))); + Reply reply = replies.poll(); + if (reply == null) reply = new Reply(500, "Unexpected request".getBytes(StandardCharsets.UTF_8)); + exchange.sendResponseHeaders(reply.status(), reply.body().length == 0 ? -1 : reply.body().length); + exchange.getResponseBody().write(reply.body()); + } finally { exchange.close(); } + }); + server.start(); + Cache.apiBaseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + Cache.pluginKey = "unit-test-key"; + } + void reply(int status, String body) { reply(status, body.getBytes(StandardCharsets.UTF_8)); } + void reply(int status, byte[] body) { replies.add(new Reply(status, body)); } + Request request() throws Exception { + Request request = requests.poll(2, TimeUnit.SECONDS); + if (request == null) throw new AssertionError("HTTP request not received"); + return request; + } + @Override public void close() { server.stop(0); } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClientTest.java b/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClientTest.java new file mode 100644 index 0000000..c3d247c --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClientTest.java @@ -0,0 +1,162 @@ +package net.tfminecraft.tfmcweb.api; + +import static org.junit.jupiter.api.Assertions.*; +import com.google.gson.JsonParser; +import java.util.*; +import net.tfminecraft.tfmcweb.Cache; +import net.tfminecraft.tfmcweb.TestState; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*; +import org.junit.jupiter.api.*; + +class ProvinceSystemClientTest { + TestState state; + HttpFixture http; + @BeforeEach void setup() throws Exception { state = new TestState(); http = new HttpFixture(); } + @AfterEach void cleanup() throws Exception { http.close(); state.close(); } + + @Test void linkResponsesAndRequestEscaping() throws Exception { + http.reply(200, "{\"code\":\"ABC\",\"expires_at\":\"tomorrow\"}"); + CodeResult result = ProvinceSystemClient.startDiscordLink("id", "name\"\\\n\r\t"); + assertTrue(result.ok); assertFalse(result.alreadyLinked); assertEquals("ABC", result.code); + assertEquals("tomorrow", result.expiresAt); assertNull(result.error); + var req = http.request(); + assertEquals("POST", req.method()); assertEquals("/skins/discord/link/start", req.path()); + assertEquals("unit-test-key", req.key()); assertEquals("application/json", req.contentType()); + assertEquals("name\"\\\n\r\t", JsonParser.parseString(req.body()).getAsJsonObject().get("minecraft_name").getAsString()); + http.reply(200, "{\"already_linked\":true,\"discord_username\":\"ada\"}"); + result = ProvinceSystemClient.startDiscordLink(null, null); + assertTrue(result.alreadyLinked); assertEquals("ada", result.discordUsername); http.request(); + for (String body : List.of("{}", "{\"code\":\"\"}")) { + http.reply(200, body); assertFalse(ProvinceSystemClient.startDiscordLink("id", "a").ok); http.request(); + } + } + @Test void featuresValidateScopeAndFallbackResponse() throws Exception { + assertFalse(ProvinceSystemClient.issueFeatureCode(null, null).ok); + assertFalse(ProvinceSystemClient.issueFeatureCode("id", null).ok); + assertFalse(ProvinceSystemClient.issueFeatureCode("id", "unknown").ok); + for (String scope : List.of("skin", "drink", "profile", "skin_staff")) { + Cache.realmId = " DEV "; + http.reply(200, "{\"code\":\"x\",\"expires_at\":\"soon\"}"); + var result = ProvinceSystemClient.issueFeatureCode(" id ", scope.toUpperCase(Locale.ROOT)); + assertTrue(result.ok); assertEquals(scope, result.scope); assertEquals("x", result.code); assertEquals("soon", result.expiresAt); + var body = JsonParser.parseString(http.request().body()).getAsJsonObject(); + assertEquals("dev", body.get("realm_id").getAsString()); assertEquals("id", body.get("player_uuid").getAsString()); + } + for (String realm : Arrays.asList(null, " ")) { + Cache.realmId = realm; + http.reply(200, "{\"code\":\"x\",\"scope\":\"profile\"}"); + assertEquals("profile", ProvinceSystemClient.issueFeatureCode("id", "skin").scope); + assertTrue(http.request().body().contains("\"main\"")); + } + for (String body : List.of("{}", "{\"code\":\"\",\"scope\":\"\"}")) { + http.reply(200, body); assertFalse(ProvinceSystemClient.issueFeatureCode("id", "skin").ok); http.request(); + } + } + @Test void identityMintStatusAndValidation() throws Exception { + assertFalse(ProvinceSystemClient.getIdentityStatus(null).ok); + assertFalse(ProvinceSystemClient.getCosmeticMintStatus(" ").ok); + http.reply(200, "{\"linked\":true,\"eligible\":true,\"in_grace\":true,\"player_uuid\":\"id\",\"discord_user_id\":\"d\",\"discord_username\":\"Ada\",\"minecraft_name\":\"mc\",\"grace_until\":\"g\",\"left_guild_at\":\"l\"}"); + IdentityStatus result = ProvinceSystemClient.getIdentityStatus(" id "); + assertTrue(result.ok && result.linked && result.eligible && result.inGrace); + assertEquals("id", result.playerUuid); assertEquals("d", result.discordUserId); assertEquals("Ada", result.discordUsername); + assertEquals("mc", result.minecraftName); assertEquals("g", result.graceUntil); assertEquals("l", result.leftGuildAt); assertNull(result.error); + assertEquals("/skins/discord/status/id", http.request().path()); + http.reply(200, "{\"last_mint_at\":null}"); + assertNull(ProvinceSystemClient.getCosmeticMintStatus("a b").lastMintAt); + assertEquals("/skins/plugin/cosmetic-mint-status?player_uuid=a+b", http.request().path()); + } + @Test void simpleOperationsModerationAndOptionalFields() throws Exception { + assertFalse(ProvinceSystemClient.unlinkDiscord(null).ok); + assertFalse(ProvinceSystemClient.resetCosmeticMintCooldowns(null, null).ok); + assertFalse(ProvinceSystemClient.postWarning(null, "reason", null, null, null, null).ok); + assertFalse(ProvinceSystemClient.postWarning("id", null, null, null, null, null).ok); + assertFalse(ProvinceSystemClient.postBanEvent(null, null, null, null, null, null, null).ok); + assertTrue(ProvinceSystemClient.postBirdMail(null, null, null, null, null).ok); + assertFalse(ProvinceSystemClient.postBirdMail("id", "discord", null, null, null).ok); + http.reply(204, ""); assertTrue(ProvinceSystemClient.unlinkDiscord(" id ").ok); + assertEquals("{\"player_uuid\":\"id\"}", http.request().body()); + http.reply(200, "{}"); assertTrue(ProvinceSystemClient.resetCosmeticMintCooldowns("id", " staff ").ok); + assertTrue(http.request().body().contains("\"staff_uuid\":\"staff\"")); + http.reply(200, "{\"mirrored\":true}"); + assertTrue(ProvinceSystemClient.postWarning("id", " reason ", "staff", "Ada", "disc", "mc").mirrored); + assertEquals("reason", JsonParser.parseString(http.request().body()).getAsJsonObject().get("reason").getAsString()); + http.reply(200, "{}"); assertFalse(ProvinceSystemClient.postBanEvent(" BAN ", null, " ", "mc", "why", "1d", "staff").mirrored); + assertEquals("/skins/moderation/ban-events", http.request().path()); + http.reply(200, "{\"mirrored\":true}"); + assertTrue(ProvinceSystemClient.postBirdMail(" id ", "disc", " Char ", "sender", "letter").ok); + assertEquals("/skins/moderation/bird-mail", http.request().path()); + assertTrue(ProvinceSystemClient.ackPluginNotices(null).ok); assertTrue(ProvinceSystemClient.ackPluginNotices(List.of()).ok); + http.reply(200, "{}"); assertTrue(ProvinceSystemClient.ackPluginNotices(List.of(1, 2)).ok); + assertEquals("{\"ids\":[1,2]}", http.request().body()); + assertFalse(ProvinceSystemClient.putRpcPlayerMeta(null).ok); assertFalse(ProvinceSystemClient.putRpcPlayerMeta(" ").ok); + http.reply(200, "{}"); assertTrue(ProvinceSystemClient.putRpcPlayerMeta("{}").ok); assertEquals("PUT", http.request().method()); + http.reply(500, "failure"); assertEquals("failure", ProvinceSystemClient.putRpcPlayerMeta("{}").error); http.request(); + } + @Test void noticesParseFieldsAndCopyResults() throws Exception { + String valid = "{\"id\":12,\"player_uuid\":\"id\",\"type\":\"linked\",\"discord_username\":\"Ada\",\"discord_user_id\":\"d\",\"grace_until\":\"g\",\"created_at\":\"c\"}"; + http.reply(200, "{\"notices\":[{}, {\"id\":\"bad\"}, {\"id\":1},"+valid+"]}"); + var result = ProvinceSystemClient.listPluginNotices(); assertTrue(result.ok); assertEquals(1, result.notices.size()); + var notice = result.notices.getFirst(); assertEquals(12, notice.id); assertEquals("id", notice.playerUuid); + assertEquals("linked", notice.type); assertEquals("Ada", notice.discordUsername); assertEquals("d", notice.discordUserId); + assertEquals("g", notice.graceUntil); assertEquals("c", notice.createdAt); http.request(); + assertThrows(UnsupportedOperationException.class, () -> result.notices.clear()); + var mutable = new ArrayList<>(result.notices); var copy = PluginNoticesResult.success(mutable); mutable.clear(); assertEquals(1, copy.notices.size()); + assertTrue(PluginNoticesResult.success(null).notices.isEmpty()); + for (String json : Arrays.asList(null, "", "{}")) assertTrue(ProvinceSystemClient.parsePluginNotices(json).isEmpty()); + http.reply(200, "{}"); assertTrue(ProvinceSystemClient.ping().ok); http.request(); + http.reply(503, ""); assertEquals("HTTP 503", ProvinceSystemClient.ping().error); http.request(); + } + @Test void allTransportFamiliesReportHttpFailuresConfigurationAndExceptions() throws Exception { + List> operations = List.of( + () -> ProvinceSystemClient.startDiscordLink("id", "mc").error, + () -> ProvinceSystemClient.issueFeatureCode("id", "skin").error, + () -> ProvinceSystemClient.getCosmeticMintStatus("id").error, + () -> ProvinceSystemClient.getIdentityStatus("id").error, + () -> ProvinceSystemClient.listPluginNotices().error, + () -> ProvinceSystemClient.unlinkDiscord("id").error, + () -> ProvinceSystemClient.postWarning("id", "reason", null, null, null, null).error); + String base = Cache.apiBaseUrl; + for (var operation : operations) { + for (int status : new int[]{400, 401, 500}) { + http.reply(status, status == 500 ? "" : "{\"detail\":\"denied\"}"); + String error = operation.get(); assertNotNull(error); + assertTrue(error.contains(status == 500 ? "HTTP 500" : status == 401 ? "Unauthorized" : "denied"), error); http.request(); + } + Cache.apiBaseUrl = "invalid URL"; assertTrue(operation.get().startsWith("Could not reach API:")); + Cache.apiBaseUrl = null; assertTrue(operation.get().contains("not configured")); + Cache.apiBaseUrl = base; Cache.pluginKey = ""; assertTrue(operation.get().contains("not configured")); + Cache.pluginKey = "unit-test-key"; + } + } + @Test void jsonEscapesAndRootFieldsAreDecodedCorrectly() { + assertEquals("line\n\t\r\b\f/A", ProvinceSystemClient.jsonString("{\"value\":\"line\\n\\t\\r\\b\\f\\/\\u0041\"}", "value")); + assertEquals("root", ProvinceSystemClient.jsonString("{\"nested\":{\"value\":\"wrong\"},\"value\":\"root\"}", "value")); + String control = "a\u0001\b\f"; + assertEquals(control, JsonParser.parseString("\""+ProvinceSystemClient.escapeJson(control)+"\"").getAsString()); + assertFalse(ProvinceSystemClient.escapeJson(control).contains("\u0001")); + } + @Test void scopeAndRealmAreLocaleIndependent() throws Exception { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); Cache.realmId = "MAIN"; + http.reply(200, "{\"code\":\"yes\"}"); + assertTrue(ProvinceSystemClient.issueFeatureCode("id", "SKIN").ok); + assertTrue(http.request().body().contains("\"main\"")); + } + + @Test void noticesWithoutAPlayerUuidAreSkipped() { + assertEquals("3", ProvinceSystemClient.jsonString("{\"id\":\"3\"}", "id")); + assertTrue(ProvinceSystemClient.parsePluginNotices("{\"notices\":[{\"id\":\"3\",\"player_uuid\":\"\"}]}").isEmpty()); + assertTrue(ProvinceSystemClient.parsePluginNotices("{\"notices\":[{\"id\":\"3\",\"player_uuid\":null}]}").isEmpty()); + } + @Test void parserHandlesEmptyMalformedAndNestedDocuments() { + assertNull(ProvinceSystemClient.jsonString(null,"x")); assertNull(ProvinceSystemClient.jsonString("{}",null)); + for(String json:List.of("[]","null","{bad","{\"x\":null}","{\"x\":{}}")) assertNull(ProvinceSystemClient.jsonString(json,"x")); + assertEquals("42",ProvinceSystemClient.jsonString("{\"x\":42}","x")); + assertTrue(ProvinceSystemClient.parsePluginNotices("{\"notices\":[{}, {\"id\":1}, {\"id\":\"\"}, {\"id\":\"no\"}]}").isEmpty()); + assertNull(ProvinceSystemClient.jsonArrayBody(null,"x")); assertNull(ProvinceSystemClient.jsonArrayBody("{}",null)); + for(String json:List.of("{}","{\"x\"}","{\"x\":", "{\"x\":1}","{\"x\":[")) assertNull(ProvinceSystemClient.jsonArrayBody(json,"x")); + String array="[1],{\"text\":\"quoted\\\" ] \\\\ \",\"nested\":{\"a\":1}}"; + assertEquals(array,ProvinceSystemClient.jsonArrayBody("{\"x\": ["+array+"]}","x")); + assertTrue(ProvinceSystemClient.splitJsonObjects(null).isEmpty()); + assertEquals(List.of("{\"text\":\"quoted\\\" ] \\\\ \",\"nested\":{\"a\":1}}"),ProvinceSystemClient.splitJsonObjects(array)); + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGatewayTest.java b/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGatewayTest.java new file mode 100644 index 0000000..7bb57f5 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGatewayTest.java @@ -0,0 +1,108 @@ +package net.tfminecraft.tfmcweb.api; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import net.tfminecraft.tfmcweb.Cache; +import net.tfminecraft.tfmcweb.TestState; +import org.junit.jupiter.api.*; + +class ProvinceSystemGatewayTest { + TestState state; + HttpFixture http; + @BeforeEach void setup() throws Exception { state = new TestState(); http = new HttpFixture(); Cache.realmId = "dev"; } + @AfterEach void cleanup() throws Exception { http.close(); state.close(); } + @Test void requestsUseMethodsHeadersBodiesAndTrimBaseSlash() throws Exception { + Cache.apiBaseUrl += "/"; + http.reply(201, "one\ntwo"); + var result = ProvinceSystemGateway.request(" post ", "/other", "{\"a\":1}"); + assertTrue(result.ok); assertEquals(201, result.status); assertEquals("one\ntwo", result.body); assertNull(result.error); + var request = http.request(); assertEquals("POST", request.method()); assertEquals("/other", request.path()); + assertEquals("unit-test-key", request.key()); assertEquals("application/json", request.contentType()); assertEquals("{\"a\":1}", request.body()); + http.reply(200, ""); assertTrue(ProvinceSystemGateway.request(null, "/other", null).ok); + assertEquals("GET", http.request().method()); + http.reply(204, ""); assertTrue(ProvinceSystemGateway.request(" ", "/other", null).ok); assertEquals("GET", http.request().method()); + assertEquals("", ProvinceSystemGateway.GatewayResult.success(200, null).body); + assertEquals(0, ProvinceSystemGateway.GatewayResult.fail("bad").status); + } + @Test void uploadsAndDownloadsPreserveBytes() throws Exception { + byte[] bytes = new byte[]{0, 1, -1, 42}; + http.reply(200, "ok"); assertTrue(ProvinceSystemGateway.requestBytes(null, "/file", bytes, null).ok); + var request = http.request(); assertEquals("PUT", request.method()); assertEquals("application/octet-stream", request.contentType()); + http.reply(200, "ok"); assertTrue(ProvinceSystemGateway.requestBytes("POST", "/file", bytes, "image/png").ok); + assertEquals("image/png", http.request().contentType()); + http.reply(200, bytes); assertArrayEquals(bytes, ProvinceSystemGateway.download("/file").data); http.request(); + http.reply(200, ""); assertEquals("Empty file download", ProvinceSystemGateway.download("/file").error); http.request(); + } + @Test void failuresKeepStatusAndBackendDetails() throws Exception { + for (int status : new int[]{400, 401, 500}) { + http.reply(status, status == 500 ? "" : "{\"detail\":\"denied\"}"); + var result = ProvinceSystemGateway.request("GET", "/fail", null); + assertFalse(result.ok); assertEquals(status, result.status); assertTrue(result.error.contains(status == 500 ? "HTTP 500" : "denied")); http.request(); + http.reply(status, "plain error"); assertTrue(ProvinceSystemGateway.download("/fail").error.contains("plain error")); http.request(); + } + for (String base : Arrays.asList(null, "")) { + Cache.apiBaseUrl = base; assertTrue(ProvinceSystemGateway.request("GET", "/", null).error.contains("not configured")); + assertTrue(ProvinceSystemGateway.download("/").error.contains("not configured")); + } + Cache.apiBaseUrl = "bad URL"; + assertTrue(ProvinceSystemGateway.request("GET", "/", null).error.startsWith("Could not reach API:")); + assertTrue(ProvinceSystemGateway.download("/").error.startsWith("Could not download:")); + } + @Test void injectsQueryOnlyOnAllowlistedGetsAndPreservesExplicitRealm() { + assertNull(ProvinceSystemGateway.injectRealmPath("GET", null)); + assertEquals(" ", ProvinceSystemGateway.injectRealmPath("GET", " ")); + for (String path : List.of("/characters/plugin/pending", "/skins/plugin/approved", "/drinks/plugin/pending-apply", "/characters/plugin/lore-items/pending")) { + assertEquals(path+"?realm_id=dev", ProvinceSystemGateway.injectRealmPath("GET", path)); + assertEquals(path+"/?realm_id=dev", ProvinceSystemGateway.injectRealmPath("GET", path+"/")); + assertEquals(path+"?a=1&realm_id=dev", ProvinceSystemGateway.injectRealmPath("GET", path+"?a=1")); + assertEquals(path+"?realm_id=dev", ProvinceSystemGateway.injectRealmPath("GET", path+"?")); + assertEquals(path+"?realm_id=main", ProvinceSystemGateway.injectRealmPath("GET", path+"?realm_id=main")); + assertEquals(path+"?REALM_ID", ProvinceSystemGateway.injectRealmPath("GET", path+"?REALM_ID")); + assertEquals(path, ProvinceSystemGateway.injectRealmPath("POST", path)); + } + assertEquals("/other", ProvinceSystemGateway.injectRealmPath("GET", "/other")); + Cache.realmId = " dev space "; + assertEquals("/characters/plugin/pending?realm_id=dev+space", ProvinceSystemGateway.injectRealmPath("GET", "/characters/plugin/pending")); + } + @Test void injectsBodiesOnlyOnAllowlistedWrites() { + for (String method : List.of("PUT", "POST")) { + List paths = method.equals("PUT") ? List.of("/characters/plugin/roster", "/characters/plugin/rpc-player-meta") : List.of("/wars/declare-codes/validate", "/wars/declare-codes/redeem"); + for (String path : paths) { + assertEquals("{\"realm_id\":\"dev\"}", ProvinceSystemGateway.injectRealmBody(method, path, "{}")); + assertEquals("{\"a\":1,\"realm_id\":\"dev\"}", ProvinceSystemGateway.injectRealmBody(method, path+"?x=1", " { \"a\":1 } ")); + assertEquals("{\"realm_id\":\"main\"}", ProvinceSystemGateway.injectRealmBody(method, path, "{\"realm_id\":\"main\"}")); + for (String body : Arrays.asList(null, "", " ", "[]", "{bad", "bad}")) assertEquals(body, ProvinceSystemGateway.injectRealmBody(method, path, body)); + } + } + assertEquals("{}", ProvinceSystemGateway.injectRealmBody("GET", "/characters/plugin/roster", "{}")); + assertEquals("{}", ProvinceSystemGateway.injectRealmBody("PUT", "/other", "{}")); + assertEquals("{}", ProvinceSystemGateway.injectRealmBody("PUT", null, "{}")); + } + @Test void patchWorksOnSupportedJavaWithoutOpeningJdkModules() throws Exception { + http.reply(200, "patched"); + var result = ProvinceSystemGateway.request("PATCH", "/resource", "{}"); + assertTrue(result.ok, result.error); assertEquals("patched", result.body); + assertEquals("PATCH", http.request().method()); + } + @Test void backendUnicodeErrorsAreDecoded() throws Exception { + http.reply(400, "{\"detail\":\"bad\\n\\t\\r\\u0041\\\\\\\"\"}"); + assertEquals("bad\n\t\rA\\\"", ProvinceSystemGateway.request("GET", "/", null).error); http.request(); + } + @Test void nestedRealmDoesNotSuppressTopLevelInjection() { + String actual = ProvinceSystemGateway.injectRealmBody("PUT", "/characters/plugin/roster", "{\"nested\":{\"realm_id\":\"other\"}}"); + assertEquals("dev", com.google.gson.JsonParser.parseString(actual).getAsJsonObject().get("realm_id").getAsString()); + } + + @Test void patchHandlesHttpErrorsInvalidUrlsAndInterruption() throws Exception { + http.reply(400,"{\"detail\":\"no\"}"); + assertEquals("no",ProvinceSystemGateway.request("PATCH","/",null).error);http.request(); + String base=Cache.apiBaseUrl;Cache.apiBaseUrl="invalid URL"; + assertTrue(ProvinceSystemGateway.request("PATCH","/",null).error.startsWith("Could not reach API:"));Cache.apiBaseUrl=base; + try { + Thread.currentThread().interrupt(); + assertEquals("Could not reach API: request interrupted",ProvinceSystemGateway.request("PATCH","/",null).error); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { Thread.interrupted(); } + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/entitlements/EntitlementsTest.java b/src/test/java/net/tfminecraft/tfmcweb/entitlements/EntitlementsTest.java new file mode 100644 index 0000000..f40c357 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/entitlements/EntitlementsTest.java @@ -0,0 +1,96 @@ +package net.tfminecraft.tfmcweb.entitlements; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import com.google.gson.JsonParser; +import net.tfminecraft.tfmcweb.*; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; + +class EntitlementsTest { + TestState state; + @BeforeEach void setup() throws Exception { state = new TestState(); } + @AfterEach void cleanup() throws Exception { state.close(); } + MetaGroupDefinition group(String permission, int tier, Map perks, List kinds, Boolean helmet, Boolean texture, Boolean message) { + return new MetaGroupDefinition(permission, tier, perks, kinds, helmet, texture, message); + } + @Test void definitionsNormalizeCopyAndFallback() { + var empty = group(null, -1, null, null, null, null, null); + assertEquals("", empty.getPermission()); assertEquals(-1, empty.getTier()); assertFalse(empty.hasIntPerk("x")); assertEquals(5,empty.getIntPerk("x",5)); + assertFalse(empty.hasAllowArmor3dHelmet()); assertTrue(empty.getAllowArmor3dHelmet(true)); + assertFalse(empty.hasAllowDrinkTexture()); assertTrue(empty.getAllowDrinkTexture(true)); + assertFalse(empty.hasAllowDrinkMessage()); assertTrue(empty.getAllowDrinkMessage(true)); + assertTrue(empty.getSkinKinds().isEmpty()); + var perks = new HashMap<>(Map.of("x",2)); var kinds = new ArrayList<>(Arrays.asList(null," "," ITEM ","item","book")); + var full = group(" rank ", 3, perks, kinds, true, false, true); perks.put("x",9); kinds.clear(); + assertEquals("rank", full.getPermission()); assertEquals(2, full.getIntPerk("x",0)); assertEquals(List.of("item","book"),full.getSkinKinds()); + assertTrue(full.hasAllowArmor3dHelmet()); assertTrue(full.getAllowArmor3dHelmet(false)); + assertTrue(full.hasAllowDrinkTexture()); assertFalse(full.getAllowDrinkTexture(true)); + assertTrue(full.hasAllowDrinkMessage()); assertTrue(full.getAllowDrinkMessage(false)); + assertThrows(UnsupportedOperationException.class, () -> full.getSkinKinds().clear()); + assertTrue(MetaGroupDefinition.copyList(null).isEmpty()); assertTrue(MetaGroupDefinition.copyList(List.of()).isEmpty()); + var list = new ArrayList<>(List.of(full)); var copy=MetaGroupDefinition.copyList(list); list.clear(); assertEquals(List.of(full),copy); assertTrue(MetaGroupDefinition.emptyList().isEmpty()); + } + @Test void defaultAndRankLaddersResolveClampedMaximaAndInheritedSkinPerks() { + Cache.rpcMetaDefaults=Map.of(); Cache.skinsMetaDefaults=Map.of(); Cache.drinksMetaDefaults=Map.of(); + Cache.rpcMetaGroups=List.of();Cache.skinsMetaGroups=List.of();Cache.drinksMetaGroups=List.of(); + Cache.skinsMetaDefaultKinds=Arrays.asList(null," "," ITEM "); + var defaults=EntitlementResolver.resolve(null); + assertEquals(1,defaults.maxAliveCharacters);assertEquals(1,defaults.wardrobeSkinSlots);assertEquals(30720,defaults.max3dPairBytes);assertEquals(-1,defaults.skinTokenCooldownDays);assertEquals(List.of("item"),defaults.skinKinds);assertEquals(0,defaults.donatorTier); + Player player=mock(Player.class); when(player.hasPermission("rank.high")).thenReturn(true);when(player.hasPermission("allowed")).thenReturn(true); + var low=group("rank.low",1,Map.of("name-colour-stops",2,"skin-token-cooldown-days",5),List.of("book"),true,false,false); + var high=group("rank.high",3,Map.of("name-colour-stops",4,"max-alive-characters",8,"wardrobe-skin-slots",8,"max-3d-pair-bytes",60000),List.of("armor"),null,true,true); + var higher=group("rank.none",4,Map.of("skin-token-cooldown-days",0),List.of("forbidden"),false,true,true); + var blank=group(null,0,null,null,null,null,null); + Cache.rpcMetaGroups=Arrays.asList(null,blank,low,high,higher); + Cache.skinsMetaGroups=Arrays.asList(null,blank,low,high,higher); + Cache.drinksMetaGroups=Arrays.asList(null,blank,low,high,higher); + Cache.playerMetaSyncPermissions=Arrays.asList(null," "," allowed ","denied"); + var meta=EntitlementResolver.resolve(player); + assertEquals(4,meta.nameColourStops);assertEquals(8,meta.maxAliveCharacters);assertEquals(3,meta.wardrobeSkinSlots);assertEquals(60000,meta.max3dPairBytes); + assertEquals(5,meta.skinTokenCooldownDays);assertEquals(List.of("item","book","armor"),meta.skinKinds);assertTrue(meta.allowArmor3dHelmet && meta.allowDrinkTexture && meta.allowDrinkMessage); + assertEquals(Map.of("allowed",true,"denied",false),meta.permissionFlags);assertEquals(3,meta.donatorTier); + Cache.skinsMetaGroups=List.of(high); assertEquals(-1,EntitlementResolver.resolve(player).skinTokenCooldownDays); assertFalse(EntitlementResolver.resolve(player).allowArmor3dHelmet); + Cache.drinksMetaGroups=List.of(low);when(player.hasPermission("rank.low")).thenReturn(true);assertFalse(EntitlementResolver.resolve(player).allowDrinkTexture);assertFalse(EntitlementResolver.resolve(player).allowDrinkMessage); + when(player.hasPermission("rank.low")).thenReturn(false);Cache.rpcMetaGroups=List.of(low);assertEquals(0,EntitlementResolver.highestRpcTier(player)); + } + @Test void syncJsonCopiesValuesAndSkipsBlankKeys() { + UUID id=UUID.randomUUID(); Cache.realmId="dev"; + var meta=new EntitlementResolver.ResolvedMeta(3,true,false,8,2,40000,5,List.of(" BOOK "," ","item"),true,Map.of("p\"\\",true,"q",false," ",true),-2); + var json=JsonParser.parseString(PlayerMetaSyncService.toJson(id,meta)).getAsJsonObject(); + assertEquals(id.toString(),json.get("player_uuid").getAsString());assertEquals("dev",json.get("realm_id").getAsString());assertEquals(8,json.get("max_alive_characters").getAsInt()); + assertEquals(0,json.get("donator_tier").getAsInt());assertEquals(2,json.getAsJsonArray("skin_kinds").size());assertEquals(2,json.getAsJsonObject("permission_flags").size()); + var minimal=new EntitlementResolver.ResolvedMeta(0,false,false,null,1,1,0,List.of(),false,Map.of(),0); + assertFalse(JsonParser.parseString(PlayerMetaSyncService.toJson(id,minimal)).getAsJsonObject().has("max_alive_characters")); + } + @Test void syncCapturesPermissionsBeforeSchedulingAndHandlesOfflineAndFailures() { + UUID id=UUID.randomUUID();Player player=mock(Player.class);when(player.getUniqueId()).thenReturn(id);when(player.isOnline()).thenReturn(true); + TFMCWeb plugin=mock(TFMCWeb.class);var logger=mock(java.util.logging.Logger.class);when(plugin.getLogger()).thenReturn(logger); + BukkitScheduler scheduler=mock(BukkitScheduler.class);List async=new ArrayList<>(); + doAnswer(call->{call.getArgument(1,Runnable.class).run();return null;}).when(scheduler).runTask(eq(plugin),any(Runnable.class)); + doAnswer(call->{async.add(call.getArgument(1,Runnable.class));return null;}).when(scheduler).runTaskAsynchronously(eq(plugin),any(Runnable.class)); + try(MockedStatic b=mockStatic(Bukkit.class);MockedStatic api=mockStatic(ProvinceSystemClient.class)) { + b.when(Bukkit::getScheduler).thenReturn(scheduler);b.when(()->Bukkit.getPlayer(id)).thenReturn(player);b.when(Bukkit::getOnlinePlayers).thenReturn(Arrays.asList(null,player)); + TFMCWeb.plugin=null;PlayerMetaSyncService.pushForPlayer(player);PlayerMetaSyncService.pushAsync(id);PlayerMetaSyncService.pushAllOnlineAsync();assertTrue(async.isEmpty()); + TFMCWeb.plugin=plugin;PlayerMetaSyncService.pushForPlayer(null);PlayerMetaSyncService.pushAsync(null); + api.when(()->ProvinceSystemClient.putRpcPlayerMeta(anyString())).thenReturn(ProvinceSystemClient.SimpleResult.success()); + PlayerMetaSyncService.pushAsync(id);assertEquals(1,async.size());async.removeFirst().run(); + when(player.isOnline()).thenReturn(false);PlayerMetaSyncService.pushAsync(id);assertTrue(async.isEmpty()); + b.when(()->Bukkit.getPlayer(id)).thenReturn(null);PlayerMetaSyncService.pushAsync(id);assertTrue(async.isEmpty()); + api.when(()->ProvinceSystemClient.putRpcPlayerMeta(anyString())).thenReturn(ProvinceSystemClient.SimpleResult.fail("unavailable")); + PlayerMetaSyncService.pushAllOnlineAsync();assertEquals(1,async.size());async.removeFirst().run();verify(logger).warning(contains("unavailable")); + } + } + @Test void syncEscapesControlCharactersAndUsesRootLocale() { + Locale.setDefault(Locale.forLanguageTag("tr-TR"));Cache.realmId="MAIN"; + var meta=new EntitlementResolver.ResolvedMeta(0,false,false,1,1,1,0,List.of("ITEM"),false,Map.of("a\n\u0001",true),0); + String payload=PlayerMetaSyncService.toJson(UUID.randomUUID(),meta); + assertFalse(payload.contains("\n"));assertFalse(payload.contains("\u0001")); + var json=JsonParser.parseString(payload).getAsJsonObject();assertEquals("item",json.getAsJsonArray("skin_kinds").get(0).getAsString()); + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/gate/DiscordGateServiceTest.java b/src/test/java/net/tfminecraft/tfmcweb/gate/DiscordGateServiceTest.java index bb30502..a8928de 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/gate/DiscordGateServiceTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/gate/DiscordGateServiceTest.java @@ -143,6 +143,49 @@ void toleratesMissingRpcAndStopsCallingDisabledRpc() { assertEquals(Boolean.TRUE, RpcPlugin.lastRequired); } + @Test + void fetchCachesOnlySuccessfulIdentityAndOnlineUuidUsesPlayerGate() { + var linked = net.tfminecraft.tfmcweb.api.ProvinceSystemClient.IdentityStatus.fromJson("{\"eligible\":true}"); + var failed = net.tfminecraft.tfmcweb.api.ProvinceSystemClient.IdentityStatus.fail("down"); + try (var api = mockStatic(net.tfminecraft.tfmcweb.api.ProvinceSystemClient.class)) { + api.when(() -> net.tfminecraft.tfmcweb.api.ProvinceSystemClient.getIdentityStatus(uuid.toString())).thenReturn(linked, failed); + assertTrue(gate.fetchAndCache(uuid).ok); + assertTrue(cache.isEligible(uuid)); + assertFalse(gate.fetchAndCache(uuid).ok); + assertTrue(cache.isEligible(uuid)); + } + when(rpc.isEnabled()).thenReturn(true); + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + gate.applyGate(uuid, false); + assertEquals(Boolean.TRUE, RpcPlugin.lastRequired); + gate.applyGate((Player) null); + gate.applyGate((Player) null, false); + gate.applyGate((UUID) null, false); + gate.clearGate(null); + when(player.isOnline()).thenReturn(false); + gate.applyGate(player); + } + + @Test + void unavailableOrFailingReflectiveApiIsReportedWithoutThrowing() { + JavaPlugin incompatible = mock(JavaPlugin.class); + when(incompatible.isEnabled()).thenReturn(true); + when(plugins.getPlugin("RPCharacters")).thenReturn(incompatible); + assertFalse(gate.isRpcAvailable()); + assertFalse(gate.isRpcAvailable()); + ThrowingRpc failing = mock(ThrowingRpc.class); + when(failing.isEnabled()).thenReturn(true); + when(plugins.getPlugin("RPCharacters")).thenReturn(failing); + assertTrue(gate.isRpcAvailable()); + gate.applyGate(uuid, false); + assertNull(RpcPlugin.lastRequired); + } + + public static class ThrowingRpc extends JavaPlugin { + public static void setDiscordGate(UUID uuid, boolean required) { throw new IllegalStateException("fixture failure"); } + public static void setDiscordGate(Player player, boolean required) { throw new IllegalStateException("fixture failure"); } + } + public static class RpcPlugin extends JavaPlugin { static UUID lastUuid; static Boolean lastRequired; diff --git a/src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java b/src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java new file mode 100644 index 0000000..08b5927 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..5987bf8 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java @@ -0,0 +1,128 @@ +package net.tfminecraft.tfmcweb.loaders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.tfmcweb.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class ConfigurationTest { + @TempDir Path temp; + TestState state; + @BeforeEach void setup() throws Exception { state=new TestState(); } + @AfterEach void cleanup() throws Exception { state.close(); } + void load(String yaml) throws Exception { + Path file=temp.resolve("config.yml");Files.writeString(file,yaml);new ConfigLoader().load(file.toFile()); + } + @Test void defaultsAndMissingOrInvalidFiles() throws Exception { + load("");assertEquals("",Cache.apiBaseUrl);assertEquals("",Cache.pluginKey);assertEquals("main",Cache.realmId); + 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()); + Cache.apiBaseUrl="preserved";new ConfigLoader().load(temp.resolve("missing.yml").toFile());assertEquals("preserved",Cache.apiBaseUrl); + load("bad: [");assertEquals("preserved",Cache.apiBaseUrl); + load("realm:\n id: ' '\ntokens:\n enabled-scopes: []\n");assertEquals("main",Cache.realmId);assertTrue(Cache.tokenEnabledScopes.isEmpty()); + } + @Test void normalizesApiTokensAndCooldownRows() throws Exception { + load(""" + api: + base-url: ' http://localhost/// ' + plugin-key: ' key ' + realm: + id: ' DEV ' + tokens: + enabled-scopes: [null, ' ', SKIN, skin, drink, profile, skin_staff, bogus] + token-cooldowns: + shared-scopes: [null, ' ', ' SKIN '] + defaults: + cooldown-days: 3 + groups: + - nonsense + - {permission: ' '} + - {permission: one, cooldown-days: 5} + - {permission: two, cooldown-days: '7'} + - {permission: three, cooldown-days: bad} + """); + assertEquals("http://localhost",Cache.apiBaseUrl);assertEquals("key",Cache.pluginKey);assertEquals("dev",Cache.realmId); + assertEquals(4,Cache.tokenEnabledScopes.size());assertEquals(List.of("skin"),Cache.tokenCooldownSharedScopes); + assertEquals(3,Cache.tokenCooldownDefaultDays);assertEquals(3,Cache.tokenCooldownGroups.size()); + assertEquals(List.of(5,7,-1),Cache.tokenCooldownGroups.stream().map(g->g.cooldownDays).toList()); + assertFalse(Cache.isTokenScopeEnabled(null));assertFalse(Cache.isTokenScopeEnabled(" "));assertTrue(Cache.isTokenScopeEnabled(" SKIN "));assertFalse(Cache.isTokenScopeEnabled("no")); + Cache.tokenEnabledScopes=Arrays.asList(null," DRINK ");assertTrue(Cache.isTokenScopeEnabled("drink")); + assertFalse(Cache.isSharedMintScope(null));assertFalse(Cache.isSharedMintScope(" "));assertFalse(Cache.isSharedMintScope("profile"));assertTrue(Cache.isSharedMintScope("SKIN")); + Cache.tokenCooldownSharedScopes=Arrays.asList(null," DRINK ");assertTrue(Cache.isSharedMintScope("drink")); + assertEquals("",new Cache.TokenCooldownGroup(null,0).permission);assertTrue(Cache.newIntMap().isEmpty()); + } + @Test void mapsAndConfigurationSectionsLoadAllLadders() { + YamlConfiguration config=new YamlConfiguration(); + config.set("player-meta.sync-permissions",Arrays.asList(null," "," node ",123)); + var row=new HashMap();row.put("permission","rank");row.put("tier","2"); + row.put("name-colour-stops",3);row.put("max-alive-characters","bad");row.put("wardrobe-skin-slots",null); + row.put("max-3d-pair-bytes","40000");row.put("skin-token-cooldown-days",2); + row.put("skin-kinds",Arrays.asList(null," "," BOOK "));row.put("allow-armor-3d-helmet","yes"); + row.put("allow-drink-texture",1);row.put("allow-drink-message",false); + var section=new YamlConfiguration();row.forEach(section::set);section.set("tier",1); + section.set("name-colour-stops",7);section.set("allow-armor-3d-helmet",true);section.set("allow-drink-texture",true);section.set("allow-drink-message",true); + for(String ladder:List.of("rpc","skins","drinks")){ + config.set("player-meta."+ladder+".groups",Arrays.asList(null,"bad",Map.of(),row,section)); + config.set("player-meta."+ladder+".defaults.name-colour-stops",2); + } + config.set("player-meta.skins.defaults.skin-kinds",List.of(" ITEM ")); + config.set("player-meta.skins.defaults.allow-armor-3d-helmet",true); + config.set("player-meta.drinks.defaults.allow-drink-texture",true); + config.set("player-meta.drinks.defaults.allow-drink-message",true); + PlayerMetaConfigLoader.load(config); + assertEquals(List.of("node","123"),Cache.playerMetaSyncPermissions); + assertEquals(List.of(1,2),Cache.rpcMetaGroups.stream().map(g->g.getTier()).toList()); + assertEquals(List.of("book"),Cache.skinsMetaGroups.get(1).getSkinKinds());assertTrue(Cache.skinsMetaGroups.get(1).getAllowArmor3dHelmet(false)); + assertTrue(Cache.drinksMetaGroups.get(1).getAllowDrinkTexture(false));assertFalse(Cache.drinksMetaGroups.get(1).getAllowDrinkMessage(true)); + assertEquals(0,Cache.rpcMetaGroups.get(1).getIntPerk("max-alive-characters",9)); + } + @Test void booleanVariantsAndNonListKindsFallBackSafely() { + for(Object value:Arrays.asList(null,true,false,1,0,"true","yes","on","1","false","no","off","0","bad")) { + YamlConfiguration config=new YamlConfiguration();var row=new HashMap();row.put("permission","rank");row.put("tier","bad"); + row.put("skin-kinds","not-list");row.put("allow-armor-3d-helmet",value);row.put("allow-drink-texture",value);row.put("allow-drink-message",value); + config.set("player-meta.skins.groups",List.of(row));config.set("player-meta.drinks.groups",List.of(row));PlayerMetaConfigLoader.load(config); + boolean expected=Arrays.asList(true,1,"true","yes","on","1").contains(value); + assertEquals(expected,Cache.skinsMetaGroups.getFirst().getAllowArmor3dHelmet(false));assertEquals(expected,Cache.drinksMetaGroups.getFirst().getAllowDrinkTexture(false)); + assertTrue(Cache.skinsMetaGroups.getFirst().getSkinKinds().isEmpty());assertEquals(0,Cache.skinsMetaGroups.getFirst().getTier()); + } + } + @Test void realmOverlaysDefaultsAndReplacesGroupLists() { + for(String realm:Arrays.asList(null," "," MAIN ")){ + Cache.realmId=realm;YamlConfiguration config=new YamlConfiguration(); + for(String ladder:List.of("rpc","skins","drinks")){ + config.set("player-meta."+ladder+".defaults.name-colour-stops",1); + config.set("player-meta.by-realm.main."+ladder+".defaults.name-colour-stops",4); + config.set("player-meta.by-realm.main."+ladder+".groups",List.of(Map.of("permission","override"))); + } + config.set("player-meta.by-realm.main.skins.defaults.skin-kinds",List.of("book")); + config.set("player-meta.by-realm.main.skins.defaults.allow-armor-3d-helmet",true); + config.set("player-meta.by-realm.main.drinks.defaults.allow-drink-texture",true); + config.set("player-meta.by-realm.main.drinks.defaults.allow-drink-message",true); + PlayerMetaConfigLoader.load(config);assertEquals(4,Cache.rpcMetaDefaults.get("name-colour-stops"));assertEquals(4,Cache.skinsMetaDefaults.get("name-colour-stops")); + assertEquals(4,Cache.drinksMetaDefaults.get("name-colour-stops"));assertEquals("override",Cache.rpcMetaGroups.getFirst().getPermission()); + assertEquals(List.of("book"),Cache.skinsMetaDefaultKinds);assertTrue(Cache.skinsMetaDefaultAllowArmor3dHelmet&&Cache.drinksMetaDefaultAllowTexture&&Cache.drinksMetaDefaultAllowMessage); + } + } + @Test void partialRealmSkinDefaultsPreserveInheritedKinds() { + Cache.realmId="dev";YamlConfiguration config=new YamlConfiguration(); + config.set("player-meta.skins.defaults.skin-kinds",List.of("item","book")); + config.set("player-meta.by-realm.dev.skins.defaults.name-colour-stops",3); + PlayerMetaConfigLoader.load(config);assertEquals(List.of("item","book"),Cache.skinsMetaDefaultKinds); + } + @Test void cooldownConfigurationSectionsAreSupported() throws Exception { + YamlConfiguration source=new YamlConfiguration();var row=new YamlConfiguration();row.set("permission","rank");row.set("cooldown-days",4); + source.set("token-cooldowns.groups",List.of(row)); + try(var construction=mockConstruction(YamlConfiguration.class,(mock,context)->{ + when(mock.getString(anyString(),anyString())).thenAnswer(call->source.getString(call.getArgument(0),call.getArgument(1))); + when(mock.getList(anyString())).thenAnswer(call->source.getList(call.getArgument(0))); + when(mock.getInt(anyString(),anyInt())).thenAnswer(call->source.getInt(call.getArgument(0),call.getArgument(1))); + })){ + new ConfigLoader().load(temp.resolve("unused.yml").toFile());assertEquals(4,Cache.tokenCooldownGroups.getFirst().cooldownDays); + } + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/CommandFixture.java b/src/test/java/net/tfminecraft/tfmcweb/managers/CommandFixture.java new file mode 100644 index 0000000..63fcd37 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/managers/CommandFixture.java @@ -0,0 +1,43 @@ +package net.tfminecraft.tfmcweb.managers; + +import static org.mockito.Mockito.*; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.tfmcweb.*; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; +import net.tfminecraft.tfmcweb.cache.LinkCache; +import net.tfminecraft.tfmcweb.gate.DiscordGateService; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.mockito.MockedStatic; + +final class CommandFixture implements AutoCloseable { + final TestState state=new TestState(); + final JavaPlugin plugin=mock(JavaPlugin.class); + final Player player=mock(Player.class); + final CommandSender console=mock(CommandSender.class); + final UUID id=UUID.randomUUID(); + final LinkCache cache=new LinkCache(); + final DiscordGateService gate=mock(DiscordGateService.class); + final BukkitScheduler scheduler=mock(BukkitScheduler.class); + final Logger logger=mock(Logger.class); + final MockedStatic bukkit=mockStatic(Bukkit.class); + final MockedStatic api=mockStatic(ProvinceSystemClient.class, call -> call.getMethod().getName().equals("jsonString") ? call.callRealMethod() : org.mockito.Answers.RETURNS_DEFAULTS.answer(call)); + final List messages=new ArrayList<>(); + CommandFixture() throws Exception { + when(plugin.getLogger()).thenReturn(logger);when(player.getUniqueId()).thenReturn(id);when(player.getName()).thenReturn("Ada"); + when(player.getPlayer()).thenReturn(player);when(player.isOnline()).thenReturn(true);when(player.hasPlayedBefore()).thenReturn(true);when(console.getName()).thenReturn("Console"); + doAnswer(c->{messages.add(c.getArgument(0));return null;}).when(player).sendMessage(anyString()); + doAnswer(c->{messages.add(c.getArgument(0));return null;}).when(console).sendMessage(anyString()); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler);bukkit.when(()->Bukkit.getPlayerExact("Ada")).thenReturn(player); + bukkit.when(()->Bukkit.getPlayer(id)).thenReturn(player);bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + doAnswer(c->{c.getArgument(1,Runnable.class).run();return null;}).when(scheduler).runTask(eq(plugin),any(Runnable.class)); + doAnswer(c->{c.getArgument(1,Runnable.class).run();return null;}).when(scheduler).runTaskAsynchronously(eq(plugin),any(Runnable.class)); + } + void permissions(CommandSender sender,boolean allowed){when(sender.hasPermission(anyString())).thenReturn(allowed);} + boolean contains(String text){return messages.stream().anyMatch(s->s.contains(text));} + @Override public void close() throws Exception {api.close();bukkit.close();state.close();} +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/NoticeAndJoinTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/NoticeAndJoinTest.java new file mode 100644 index 0000000..e01c30f --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/managers/NoticeAndJoinTest.java @@ -0,0 +1,74 @@ +package net.tfminecraft.tfmcweb.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import net.tfminecraft.tfmcweb.TFMCWeb; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*; +import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService; +import net.tfminecraft.tfmcweb.listeners.PlayerJoinListener; +import net.tfminecraft.tfmcweb.mail.BirdMailGateway; +import org.bukkit.Bukkit; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; + +class NoticeAndJoinTest { + CommandFixture f; + @BeforeEach void setup() throws Exception {f=new CommandFixture();} + @AfterEach void cleanup() throws Exception {f.close();} + PluginNotice notice(int id,String type,String uuid,String name){return new PluginNotice(id,type,uuid,name,"discord","soon","now");} + @Test void pollerLifecycleFailuresAndEmptyResponses() { + BukkitTask task=mock(BukkitTask.class);when(f.scheduler.runTaskTimerAsynchronously(eq(f.plugin),any(Runnable.class),eq(20L),eq(20L))).thenReturn(task); + var poller=new PluginNoticePoller(f.plugin,f.cache,f.gate);poller.stop();poller.start();poller.start(); + ArgumentCaptor ticks=ArgumentCaptor.forClass(Runnable.class);verify(f.scheduler).runTaskTimerAsynchronously(eq(f.plugin),ticks.capture(),eq(20L),eq(20L)); + f.api.when(ProvinceSystemClient::listPluginNotices).thenThrow(new IllegalStateException("offline"));ticks.getValue().run();verify(f.logger).log(eq(java.util.logging.Level.WARNING),contains("poll failed"),any(Exception.class)); + f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.fail("down"));ticks.getValue().run();verify(f.logger).fine(contains("down")); + f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.success(List.of()));ticks.getValue().run(); + poller.stop();poller.stop();verify(task).cancel(); + } + @Test void noticeDeliveryUpdatesCacheAndAcknowledgesOnlyOwnedDeliverableNotices() { + List ticks=new ArrayList<>();when(f.scheduler.runTaskTimerAsynchronously(eq(f.plugin),any(Runnable.class),eq(20L),eq(20L))).thenAnswer(c->{ticks.add(c.getArgument(1));return mock(BukkitTask.class);}); + var poller=new PluginNoticePoller(f.plugin,f.cache,f.gate);poller.start();Runnable tick=ticks.getFirst(); + f.api.when(()->ProvinceSystemClient.ackPluginNotices(anyList())).thenReturn(SimpleResult.success()); + int id=0; + for(String type:Arrays.asList("link_success","guild_left_grace","guild_rejoined","grace_expired","unknown",null)){ + f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.success(List.of(notice(++id,type,f.id.toString()," AdaDiscord "))));tick.run(); + } + assertFalse(f.cache.isEligible(f.id));verify(f.gate).applyGate(f.player,false);assertTrue(f.contains("AdaDiscord")); + f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.success(List.of(notice(20,"link_success",f.id.toString()," "),notice(21,"unknown","invalid",null)))); + f.api.when(()->ProvinceSystemClient.ackPluginNotices(anyList())).thenReturn(SimpleResult.fail("down"));tick.run();assertTrue(f.contains("Discord linked successfully."));verify(f.logger).warning(contains("ack failed")); + f.bukkit.when(()->Bukkit.getPlayer(f.id)).thenReturn(null); + for(String type:List.of("link_success","guild_left_grace","guild_rejoined","grace_expired")){ + f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.success(List.of(notice(++id,type,f.id.toString(),null))));tick.run(); + } + verify(f.gate).applyGate(f.id,false); + f.api.verify(()->ProvinceSystemClient.ackPluginNotices(List.of(1))); + f.api.verify(()->ProvinceSystemClient.ackPluginNotices(List.of(7)),never()); + } + @Test void joinFetchAppliesGateAndHandlesOfflineFailuresOrExceptions() { + var listener=new PlayerJoinListener(f.plugin,f.gate);var event=mock(PlayerJoinEvent.class);when(event.getPlayer()).thenReturn(f.player); + try(var sync=mockStatic(PlayerMetaSyncService.class)){ + var expectedIdentity0=IdentityStatus.fromJson("{\"eligible\":true}"); + when(f.gate.fetchAndCache(f.id)).thenReturn(expectedIdentity0);listener.onJoin(event);verify(f.gate).applyGate(f.player,true);sync.verify(()->PlayerMetaSyncService.pushForPlayer(f.player)); + when(f.gate.fetchAndCache(f.id)).thenReturn(IdentityStatus.fail("down"));listener.onJoin(event);verify(f.gate).applyGate(f.player);verify(f.logger).fine(contains("down")); + when(f.player.isOnline()).thenReturn(false);listener.onJoin(event);verify(f.gate,times(1)).applyGate(f.player); + when(f.gate.fetchAndCache(f.id)).thenThrow(new IllegalStateException("bad"));listener.onJoin(event);verify(f.logger).log(eq(java.util.logging.Level.WARNING),contains("identity sync failed"),any(Exception.class)); + } + } + @Test void birdGatewayValidatesPluginAndUsesCachedOrFetchedIdentity() { + assertFalse(BirdMailGateway.enqueueArrival(null,"Char",null,null));assertFalse(BirdMailGateway.enqueueArrival(f.id,null,null,null));assertFalse(BirdMailGateway.enqueueArrival(f.id," ",null,null)); + TFMCWeb.plugin=null;assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));TFMCWeb plugin=mock(TFMCWeb.class);TFMCWeb.plugin=plugin; + assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));when(plugin.isEnabled()).thenReturn(true);when(plugin.getLinkCache()).thenReturn(f.cache);when(plugin.getLogger()).thenReturn(f.logger); + f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(IdentityStatus.fail("down"));assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null)); + var expectedIdentity1=IdentityStatus.fromJson("{\"discord_user_id\":\"d\"}"); + f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(expectedIdentity1); + f.api.when(()->ProvinceSystemClient.postBirdMail(f.id.toString(),"d","Char",null,null)).thenReturn(MirrorResult.success(true)); + assertTrue(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));assertEquals("d",f.cache.get(f.id).discordUserId); + assertTrue(BirdMailGateway.enqueueArrival(f.id,"Char",null,null)); + f.api.when(()->ProvinceSystemClient.postBirdMail(f.id.toString(),"d","Char",null,null)).thenReturn(MirrorResult.fail("down"));assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null)); + when(plugin.getLinkCache()).thenReturn(null);assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null)); + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java new file mode 100644 index 0000000..e045fc9 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java @@ -0,0 +1,100 @@ +package net.tfminecraft.tfmcweb.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.time.Instant; +import java.util.*; +import net.tfminecraft.tfmcweb.*; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*; +import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandExecutor; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; + +class PlayerAndAdminCommandsTest { + CommandFixture f; + @BeforeEach void setup() throws Exception {f=new CommandFixture();} + @AfterEach void cleanup() throws Exception {f.close();} + void run(CommandExecutor command,org.bukkit.command.CommandSender sender,String...args){assertTrue(command.onCommand(sender,null,"cmd",args));} + @Test void linkShowsCodesFailuresOrExistingIdentityAndIgnoresOfflinePlayer() { + var cmd=new LinkDiscordCommand(f.plugin,f.cache,f.gate);run(cmd,f.console);assertTrue(f.contains("Players only")); + for(CodeResult result:List.of(CodeResult.fail(null),CodeResult.fail("backend"),CodeResult.alreadyLinked(" AdaDiscord "),CodeResult.alreadyLinked(" "),CodeResult.success("ABC",Instant.now().plusSeconds(4000).toString()),CodeResult.success("ABC",null))){ + f.messages.clear();f.api.when(()->ProvinceSystemClient.startDiscordLink(f.id.toString(),"Ada")).thenReturn(result);run(cmd,f.player); + assertTrue(f.contains(!result.ok?result.error==null?"Link failed":"backend":result.alreadyLinked?"Already linked":"Discord link code")); + if(result.alreadyLinked)assertTrue(f.cache.isEligible(f.id)); + } + f.messages.clear();when(f.player.isOnline()).thenReturn(false);run(cmd,f.player);assertTrue(f.messages.isEmpty());verify(f.gate,times(2)).applyGate(f.player,true); + } + @Test void unlinkUpdatesCacheAndGateOnlyWhenSuccessfulAndOnline() { + var cmd=new UnlinkDiscordCommand(f.plugin,f.cache,f.gate);run(cmd,f.console);assertTrue(f.contains("Players only")); + for(SimpleResult result:List.of(SimpleResult.fail(null),SimpleResult.fail("backend"),SimpleResult.success())){ + f.messages.clear();f.cache.putLinked(f.id,"d","name");f.api.when(()->ProvinceSystemClient.unlinkDiscord(f.id.toString())).thenReturn(result);run(cmd,f.player); + assertEquals(result.ok,!f.cache.isEligible(f.id));assertTrue(f.contains(result.ok?"Discord unlinked":result.error==null?"Unlink failed":"backend")); + } + f.messages.clear();when(f.player.isOnline()).thenReturn(false);run(cmd,f.player);assertTrue(f.messages.isEmpty());verify(f.gate).applyGate(f.player,false); + } + @Test void warningsValidateTargetsAndMirrorCachedOrFetchedIdentity() { + var cmd=new WarningCommand(f.plugin,f.cache);run(cmd,f.console,"Ada","reason");assertTrue(f.contains("No permission")); + f.permissions(f.console,true);f.permissions(f.player,true);run(cmd,f.console);assertTrue(f.contains("Usage:")); + run(cmd,f.console," ","reason");run(cmd,f.console,"missing","reason");assertTrue(f.contains("Unknown player")); + run(cmd,f.console,"Ada"," ");assertTrue(f.contains("Reason is required")); + f.api.when(()->ProvinceSystemClient.postWarning(anyString(),anyString(),nullable(String.class),anyString(),nullable(String.class),anyString())).thenReturn(MirrorResult.success(true)); + f.cache.putLinked(f.id,"cached","name");run(cmd,f.player,"Ada","reason","words"); + f.api.verify(()->ProvinceSystemClient.postWarning(f.id.toString(),"reason words",f.id.toString(),"Ada","cached","Ada"));assertTrue(f.contains("[Warning]")); + var expectedIdentity0=IdentityStatus.fromJson("{\"discord_user_id\":\"fetched\"}"); + f.cache.clear(f.id);f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(expectedIdentity0); + f.api.when(()->ProvinceSystemClient.postWarning(anyString(),anyString(),nullable(String.class),anyString(),nullable(String.class),anyString())).thenReturn(MirrorResult.success(false)); + run(cmd,f.console,"Ada","warning");assertEquals("fetched",f.cache.get(f.id).discordUserId);assertTrue(f.contains("DM skipped")); + f.cache.clear(f.id);f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(IdentityStatus.fail("down")); + f.api.when(()->ProvinceSystemClient.postWarning(anyString(),anyString(),nullable(String.class),anyString(),nullable(String.class),anyString())).thenReturn(MirrorResult.fail("fail")); + OfflinePlayer offline=mock(OfflinePlayer.class);when(offline.getUniqueId()).thenReturn(f.id);f.bukkit.when(()->Bukkit.getOfflinePlayer("old")).thenReturn(offline); + run(cmd,f.console,"old","warning");assertTrue(f.contains("Warning store failed")); + } + @Test void webValidatesCommandsReloadsBothPluginKindsAndReportsStatus() { + var cmd=new WebCommand(f.plugin,f.cache,f.gate);run(cmd,f.console,"status");assertTrue(f.contains("No permission"));f.permissions(f.console,true); + run(cmd,f.console);run(cmd,f.console,"unknown");assertTrue(f.contains("Usage:"));assertTrue(f.contains("Unknown subcommand")); + Cache.apiBaseUrl="";Cache.pluginKey="";f.api.when(ProvinceSystemClient::ping).thenReturn(SimpleResult.success());run(cmd,f.console,"status");assertTrue(f.contains("API reachable")); + Cache.apiBaseUrl="local";Cache.pluginKey="key";when(f.gate.isRpcAvailable()).thenReturn(true); + for(String error:Arrays.asList(null,"down")){f.api.when(ProvinceSystemClient::ping).thenReturn(SimpleResult.fail(error));run(cmd,f.console,"status");assertTrue(f.contains(error==null?"unreachable":"down"));} + run(cmd,f.console,"reload");verify(f.plugin).reloadConfig(); + TFMCWeb plugin=mock(TFMCWeb.class);run(new WebCommand(plugin,f.cache,f.gate),f.console,"reload");verify(plugin).reloadLocalConfig(); + } + @Test void webLookupAndUnlinkHandleOfflineUnknownFailuresAndGating() { + var cmd=new WebCommand(f.plugin,f.cache,f.gate);f.permissions(f.console,true); + for(String action:List.of("lookup","unlink")){ + run(cmd,f.console,action);assertTrue(f.contains("Usage:"));run(cmd,f.console,action," ");run(cmd,f.console,action,"missing");assertTrue(f.contains("Unknown player")); + } + f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(IdentityStatus.fail("down"));run(cmd,f.console,"lookup","Ada");assertTrue(f.contains("live status: down")); + var status=IdentityStatus.fromJson("{\"eligible\":true,\"linked\":true,\"grace_until\":\"later\",\"discord_username\":\"Discord\"}"); + f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(status);run(cmd,f.console,"lookup","Ada");assertTrue(f.cache.isEligible(f.id));verify(f.gate).applyGate(f.player,true); + f.api.when(()->ProvinceSystemClient.unlinkDiscord(f.id.toString())).thenReturn(SimpleResult.fail("down"));run(cmd,f.console,"unlink","Ada");assertTrue(f.contains("Unlink failed")); + f.api.when(()->ProvinceSystemClient.unlinkDiscord(f.id.toString())).thenReturn(SimpleResult.success());run(cmd,f.console,"unlink","Ada");assertFalse(f.cache.isEligible(f.id));verify(f.gate).applyGate(f.player,false); + OfflinePlayer offline=mock(OfflinePlayer.class);when(offline.getUniqueId()).thenReturn(f.id);f.bukkit.when(()->Bukkit.getOfflinePlayer("old")).thenReturn(offline);f.bukkit.when(()->Bukkit.getPlayer(f.id)).thenReturn(null); + run(cmd,f.console,"unlink","old");verify(f.gate).applyGate(f.id,false); + f.messages.clear();run(cmd,f.console,"lookup","old"); + assertTrue(f.messages.stream().anyMatch(message -> message.endsWith("Discord"))); + } + @Test void webReconcileAndMetaSyncUseOnlineSnapshotAndSummarizeFailures() { + var cmd=new WebCommand(f.plugin,f.cache,f.gate);f.permissions(f.console,true);Player failed=mock(Player.class);UUID failedId=UUID.randomUUID();when(failed.getUniqueId()).thenReturn(failedId); + f.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(f.player,failed)); + var expectedIdentity1=IdentityStatus.fromJson("{\"eligible\":true}"); + f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(expectedIdentity1); + f.api.when(()->ProvinceSystemClient.getIdentityStatus(failedId.toString())).thenReturn(IdentityStatus.fail("down")); + run(cmd,f.console,"reconcile");assertTrue(f.contains("ok=1 fail=1"));assertTrue(f.cache.isEligible(f.id)); + try(var sync=mockStatic(PlayerMetaSyncService.class)){ + run(cmd,f.console,"syncmeta");sync.verify(PlayerMetaSyncService::pushAllOnlineAsync); + run(cmd,f.console,"syncmeta"," ");run(cmd,f.console,"syncmeta","missing");assertTrue(f.contains("Unknown player")); + run(cmd,f.console,"syncmeta","Ada");sync.verify(()->PlayerMetaSyncService.pushForPlayer(f.player)); + when(f.player.isOnline()).thenReturn(false);run(cmd,f.console,"syncmeta","Ada");assertTrue(f.contains("must be online")); + } + } + @Test void webCompletionsFilterActionsAndNames() { + var cmd=new WebCommand(f.plugin,f.cache,f.gate);assertTrue(cmd.onTabComplete(f.console,null,"web",new String[]{""}).isEmpty());f.permissions(f.console,true); + assertEquals(List.of("status","syncmeta"),cmd.onTabComplete(f.console,null,"web",new String[]{"s"})); + for(String action:List.of("lookup","unlink","syncmeta"))assertEquals(List.of("Ada"),cmd.onTabComplete(f.console,null,"web",new String[]{action,"A"})); + assertTrue(cmd.onTabComplete(f.console,null,"web",new String[]{"status",""}).isEmpty());assertTrue(cmd.onTabComplete(f.console,null,"web",new String[0]).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/TokenCommandTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/TokenCommandTest.java new file mode 100644 index 0000000..5b32409 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/managers/TokenCommandTest.java @@ -0,0 +1,80 @@ +package net.tfminecraft.tfmcweb.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.time.Instant; +import java.util.*; +import net.tfminecraft.tfmcweb.Cache; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient; +import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.*; + +class TokenCommandTest { + CommandFixture f;TokenCommand command; + @BeforeEach void setup() throws Exception {f=new CommandFixture();command=new TokenCommand(f.plugin);Cache.tokenCooldownDefaultDays=0;Cache.tokenCooldownGroups=List.of();Cache.tokenEnabledScopes=List.of("skin","drink","profile","skin_staff");} + @AfterEach void cleanup() throws Exception {f.close();} + void run(CommandSender sender,String...args){assertTrue(command.onCommand(sender,null,"token",args));} + void expect(CommandSender sender,String message,String...args){f.messages.clear();run(sender,args);assertTrue(f.contains(message),f.messages.toString());} + @Test void usageAndPermissionChecks() { + expect(f.player,"permission");expect(f.console,"permission");expect(f.player,"permission","create","skin"); + expect(f.console,"Players only","create","skin");expect(f.player,"Usage:","unknown");expect(f.console,"Usage:","unknown"); + expect(f.player,"permission","resetcooldowns","Ada");expect(f.console,"permission","resetcooldowns","Ada"); + f.permissions(f.player,true);f.permissions(f.console,true);expect(f.player,"/token create");expect(f.console,"resetcooldowns"); + expect(f.player,"Usage:","create"); + for(String[] args:List.of(new String[]{"create","profile","extra"},new String[]{"create","drink","extra"},new String[]{"create","skin","bad"},new String[]{"create","bad"}))expect(f.player,"Usage:",args); + when(f.player.hasPermission("tfmcweb.token.create")).thenReturn(false); + for(String kind:List.of("profile","drink","skin"))expect(f.player,"permission","create",kind); + when(f.player.hasPermission("tfmcweb.token.create")).thenReturn(true);when(f.player.hasPermission("tfmcweb.token.create.staff")).thenReturn(false); + expect(f.player,"staff skins token","create","skin","staff"); + Cache.tokenEnabledScopes=List.of();expect(f.player,"disabled","create","skin");expect(f.player,"disabled"); + Cache.tokenEnabledScopes=List.of("profile");expect(f.player,"not available","create","skin"); + } + @Test void createsAllFourKindsAndHandlesErrorsExpiryAndDisconnect() { + f.permissions(f.player,true); + for(String kind:List.of("skin","drink","profile","skin_staff")) { + f.api.when(()->ProvinceSystemClient.issueFeatureCode(f.id.toString(),kind)).thenReturn(FeatureCodeResult.success("ABC",Instant.now().plusSeconds(4000).toString(),kind)); + String[] args=kind.equals("skin_staff")?new String[]{"create","skin","staff"}:new String[]{"create",kind}; + expect(f.player,kind.equals("skin_staff")?"curated pack":kind.equals("profile")?"/profile":kind.equals("drink")?"/drinks":"skins website",args); + assertTrue(f.contains("Expires in")); + } + for(String error:Arrays.asList(null,"backend failed")){ + f.api.when(()->ProvinceSystemClient.issueFeatureCode(f.id.toString(),"profile")).thenReturn(FeatureCodeResult.fail(error)); + expect(f.player,error==null?"Could not create token":"backend failed","create","profile"); + } + f.messages.clear();when(f.player.isOnline()).thenReturn(false);run(f.player,"create","profile");assertTrue(f.messages.isEmpty());when(f.player.isOnline()).thenReturn(true); + Cache.tokenCooldownDefaultDays=-1;expect(f.player,"rank cannot","create","skin"); + when(f.player.isOnline()).thenReturn(false);f.messages.clear();run(f.player,"create","drink");assertTrue(f.messages.isEmpty()); + } + @Test void resetChecksTargetAndReportsBothSuccessAndFailure() { + for(CommandSender sender:List.of(f.player,f.console)){ + f.permissions(sender,true);expect(sender,"Usage:","resetcooldowns");expect(sender,"Unknown player","resetcooldowns"," "); + expect(sender,"Unknown player","resetcooldowns","missing"); + OfflinePlayer offline=mock(OfflinePlayer.class);f.bukkit.when(()->Bukkit.getOfflinePlayer("old")).thenReturn(offline); + expect(sender,"Unknown player","resetcooldowns","old");when(offline.getUniqueId()).thenReturn(f.id);expect(sender,"Unknown player","resetcooldowns","old"); + when(offline.hasPlayedBefore()).thenReturn(true); + String staff=sender==f.player?f.id.toString():null; + f.api.when(()->ProvinceSystemClient.resetCosmeticMintCooldowns(f.id.toString(),staff)).thenReturn(SimpleResult.success()); + expect(sender,"Reset shared","resetcooldowns","old");expect(sender,"for Ada","resetcooldowns","Ada"); + for(String error:Arrays.asList(null,"denied")){ + f.api.when(()->ProvinceSystemClient.resetCosmeticMintCooldowns(f.id.toString(),staff)).thenReturn(SimpleResult.fail(error)); + expect(sender,error==null?"Could not reset":"denied","resetcooldowns","Ada"); + } + } + when(f.player.isOnline()).thenReturn(false);f.messages.clear();run(f.player,"resetcooldowns","Ada");assertTrue(f.messages.isEmpty()); + f.api.when(()->ProvinceSystemClient.resetCosmeticMintCooldowns(f.id.toString(),f.id.toString())).thenReturn(SimpleResult.success());run(f.player,"resetcooldowns","Ada");assertTrue(f.messages.isEmpty()); + } + @Test void completionsRespectPermissionsEnabledScopesAndPrefix() { + assertTrue(command.onTabComplete(f.player,null,"token",new String[]{""}).isEmpty());f.permissions(f.player,true); + assertEquals(List.of("create","resetcooldowns"),command.onTabComplete(f.player,null,"token",new String[]{""})); + assertEquals(List.of("Ada"),command.onTabComplete(f.player,null,"token",new String[]{"resetcooldowns","a"})); + assertEquals(List.of("skin","drink","profile"),command.onTabComplete(f.player,null,"token",new String[]{"create",""})); + assertEquals(List.of("staff"),command.onTabComplete(f.player,null,"token",new String[]{"create","skin","s"})); + assertTrue(command.onTabComplete(f.player,null,"token",new String[]{"create","skin","x"}).isEmpty()); + assertTrue(command.onTabComplete(f.player,null,"token",new String[0]).isEmpty()); + when(f.player.hasPermission("tfmcweb.token.create")).thenReturn(false);assertEquals(List.of("skin"),command.onTabComplete(f.player,null,"token",new String[]{"create",""})); + Cache.tokenEnabledScopes=List.of();assertTrue(command.onTabComplete(f.player,null,"token",new String[]{"create",""}).isEmpty()); + } +}