feat: NickCore v1.0.0 - enterprise nickname plugin for Paper/Folia/Velocity
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package com.nickcore.common.config;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Shared configuration model for settings used across both Paper and Velocity modules.
|
||||
*
|
||||
* <p>Immutable record — loaded once from YAML and swapped atomically on reload.</p>
|
||||
*/
|
||||
public record NickCoreConfig(
|
||||
GeneralSettings general,
|
||||
DatabaseSettings database,
|
||||
SecuritySettings security,
|
||||
VelocitySettings velocity
|
||||
) {
|
||||
public record GeneralSettings(
|
||||
String prefix,
|
||||
String locale,
|
||||
boolean debug,
|
||||
boolean allowCustomNames,
|
||||
boolean allowDuplicateNicks,
|
||||
int maxNickLength,
|
||||
int minNickLength
|
||||
) {
|
||||
public GeneralSettings {
|
||||
if (prefix == null) prefix = "<gradient:#00BFFF:#1E90FF>[NickCore]</gradient>";
|
||||
if (locale == null) locale = "en_US";
|
||||
if (maxNickLength <= 0) maxNickLength = 16;
|
||||
if (minNickLength <= 0) minNickLength = 3;
|
||||
}
|
||||
}
|
||||
|
||||
public record DatabaseSettings(
|
||||
String type,
|
||||
String host,
|
||||
int port,
|
||||
String database,
|
||||
String username,
|
||||
String password,
|
||||
int maxPoolSize,
|
||||
int minIdle,
|
||||
long connectionTimeoutMs,
|
||||
long idleTimeoutMs,
|
||||
long maxLifetimeMs,
|
||||
String tablePrefix
|
||||
) {
|
||||
public DatabaseSettings {
|
||||
if (type == null) type = "sqlite";
|
||||
if (host == null) host = "localhost";
|
||||
if (port <= 0) port = 3306;
|
||||
if (database == null) database = "nickcore";
|
||||
if (username == null) username = "root";
|
||||
if (password == null) password = "";
|
||||
if (maxPoolSize <= 0) maxPoolSize = 10;
|
||||
if (minIdle <= 0) minIdle = 2;
|
||||
if (connectionTimeoutMs <= 0) connectionTimeoutMs = 5000;
|
||||
if (idleTimeoutMs <= 0) idleTimeoutMs = 300000;
|
||||
if (maxLifetimeMs <= 0) maxLifetimeMs = 600000;
|
||||
if (tablePrefix == null) tablePrefix = "nickcore_";
|
||||
}
|
||||
}
|
||||
|
||||
public record SecuritySettings(
|
||||
String allowedCharactersRegex,
|
||||
boolean blockHomoglyphs,
|
||||
boolean blockInvisibleChars,
|
||||
boolean preventImpersonation,
|
||||
int impersonationThreshold,
|
||||
List<String> reservedNames,
|
||||
List<String> protectedNames
|
||||
) {
|
||||
public SecuritySettings {
|
||||
if (allowedCharactersRegex == null) allowedCharactersRegex = "^[a-zA-Z0-9_]+$";
|
||||
if (impersonationThreshold <= 0) impersonationThreshold = 2;
|
||||
if (reservedNames == null) reservedNames = List.of("Server", "Console", "Admin", "Moderator", "Owner");
|
||||
if (protectedNames == null) protectedNames = List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public record VelocitySettings(
|
||||
boolean enabled,
|
||||
String serverName
|
||||
) {
|
||||
public VelocitySettings {
|
||||
if (serverName == null) serverName = "default";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.nickcore.common.dto;
|
||||
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Network transfer object for synchronizing nick state across Velocity.
|
||||
*
|
||||
* <p>Supports serialization to/from byte arrays for plugin messaging channels.
|
||||
* Includes a protocol version header for forward compatibility.</p>
|
||||
*
|
||||
* <p>Immutable and thread-safe.</p>
|
||||
*/
|
||||
public record NickSyncMessage(
|
||||
int protocolVersion,
|
||||
Action action,
|
||||
UUID playerUuid,
|
||||
String nickname,
|
||||
String rankId,
|
||||
String skinId,
|
||||
String skinValue,
|
||||
String skinSignature,
|
||||
String sourceServer
|
||||
) {
|
||||
/** Current protocol version for encoding/decoding. */
|
||||
public static final int CURRENT_PROTOCOL_VERSION = 1;
|
||||
|
||||
/** Plugin messaging channel identifier. */
|
||||
public static final String CHANNEL = "nickcore:sync";
|
||||
|
||||
/**
|
||||
* Actions that can be synchronized across the network.
|
||||
*/
|
||||
public enum Action {
|
||||
NICK_UPDATE,
|
||||
RANK_UPDATE,
|
||||
SKIN_UPDATE,
|
||||
FULL_SYNC,
|
||||
RESET,
|
||||
CACHE_INVALIDATE,
|
||||
PLAYER_JOIN,
|
||||
PLAYER_QUIT
|
||||
}
|
||||
|
||||
public NickSyncMessage {
|
||||
Objects.requireNonNull(action, "action must not be null");
|
||||
Objects.requireNonNull(playerUuid, "playerUuid must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sync message from a NickProfile.
|
||||
*/
|
||||
public static NickSyncMessage fullSync(NickProfile profile, String sourceServer) {
|
||||
return new NickSyncMessage(
|
||||
CURRENT_PROTOCOL_VERSION,
|
||||
Action.FULL_SYNC,
|
||||
profile.uuid(),
|
||||
profile.nickname(),
|
||||
profile.rankId(),
|
||||
profile.skinId(),
|
||||
profile.skinValue(),
|
||||
profile.skinSignature(),
|
||||
sourceServer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a nick update message.
|
||||
*/
|
||||
public static NickSyncMessage nickUpdate(UUID uuid, String nickname, String sourceServer) {
|
||||
return new NickSyncMessage(
|
||||
CURRENT_PROTOCOL_VERSION, Action.NICK_UPDATE, uuid,
|
||||
nickname, null, null, null, null, sourceServer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a rank update message.
|
||||
*/
|
||||
public static NickSyncMessage rankUpdate(UUID uuid, String rankId, String sourceServer) {
|
||||
return new NickSyncMessage(
|
||||
CURRENT_PROTOCOL_VERSION, Action.RANK_UPDATE, uuid,
|
||||
null, rankId, null, null, null, sourceServer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reset message.
|
||||
*/
|
||||
public static NickSyncMessage reset(UUID uuid, String sourceServer) {
|
||||
return new NickSyncMessage(
|
||||
CURRENT_PROTOCOL_VERSION, Action.RESET, uuid,
|
||||
null, null, null, null, null, sourceServer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cache invalidation message.
|
||||
*/
|
||||
public static NickSyncMessage cacheInvalidate(UUID uuid, String sourceServer) {
|
||||
return new NickSyncMessage(
|
||||
CURRENT_PROTOCOL_VERSION, Action.CACHE_INVALIDATE, uuid,
|
||||
null, null, null, null, null, sourceServer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes this message to a byte array for plugin messaging.
|
||||
*/
|
||||
public byte[] encode() {
|
||||
try (var baos = new ByteArrayOutputStream();
|
||||
var dos = new DataOutputStream(baos)) {
|
||||
|
||||
dos.writeInt(protocolVersion);
|
||||
dos.writeUTF(action.name());
|
||||
dos.writeLong(playerUuid.getMostSignificantBits());
|
||||
dos.writeLong(playerUuid.getLeastSignificantBits());
|
||||
writeNullableString(dos, nickname);
|
||||
writeNullableString(dos, rankId);
|
||||
writeNullableString(dos, skinId);
|
||||
writeNullableString(dos, skinValue);
|
||||
writeNullableString(dos, skinSignature);
|
||||
writeNullableString(dos, sourceServer);
|
||||
|
||||
dos.flush();
|
||||
return baos.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to encode NickSyncMessage", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a message from a byte array.
|
||||
*
|
||||
* @param data the raw bytes from the plugin messaging channel
|
||||
* @return the decoded message
|
||||
* @throws IllegalArgumentException if the protocol version is unsupported
|
||||
*/
|
||||
public static NickSyncMessage decode(byte[] data) {
|
||||
try (var bais = new ByteArrayInputStream(data);
|
||||
var dis = new DataInputStream(bais)) {
|
||||
|
||||
int version = dis.readInt();
|
||||
if (version != CURRENT_PROTOCOL_VERSION) {
|
||||
throw new IllegalArgumentException(
|
||||
"Unsupported protocol version: " + version + " (expected " + CURRENT_PROTOCOL_VERSION + ")"
|
||||
);
|
||||
}
|
||||
|
||||
Action action = Action.valueOf(dis.readUTF());
|
||||
UUID uuid = new UUID(dis.readLong(), dis.readLong());
|
||||
String nickname = readNullableString(dis);
|
||||
String rankId = readNullableString(dis);
|
||||
String skinId = readNullableString(dis);
|
||||
String skinValue = readNullableString(dis);
|
||||
String skinSignature = readNullableString(dis);
|
||||
String sourceServer = readNullableString(dis);
|
||||
|
||||
return new NickSyncMessage(version, action, uuid, nickname, rankId,
|
||||
skinId, skinValue, skinSignature, sourceServer);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to decode NickSyncMessage", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeNullableString(DataOutputStream dos, String value) throws IOException {
|
||||
if (value == null) {
|
||||
dos.writeBoolean(false);
|
||||
} else {
|
||||
dos.writeBoolean(true);
|
||||
dos.writeUTF(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readNullableString(DataInputStream dis) throws IOException {
|
||||
if (dis.readBoolean()) {
|
||||
return dis.readUTF();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.nickcore.common.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Immutable audit record for a nickname/rank change event.
|
||||
*
|
||||
* <p>Used for admin review and audit logging. Every nick, rank, or skin change
|
||||
* produces a history entry.</p>
|
||||
*/
|
||||
public record NickHistory(
|
||||
long id,
|
||||
UUID uuid,
|
||||
String oldNickname,
|
||||
String newNickname,
|
||||
String oldRank,
|
||||
String newRank,
|
||||
UUID changedBy,
|
||||
String reason,
|
||||
Instant timestamp
|
||||
) {
|
||||
/**
|
||||
* Canonical constructor with null-safe timestamp.
|
||||
*/
|
||||
public NickHistory {
|
||||
Objects.requireNonNull(uuid, "uuid must not be null");
|
||||
if (timestamp == null) timestamp = Instant.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new history entry for a nickname change.
|
||||
*/
|
||||
public static NickHistory nickChange(UUID uuid, String oldNick, String newNick, UUID changedBy, String reason) {
|
||||
return new NickHistory(0, uuid, oldNick, newNick, null, null, changedBy, reason, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new history entry for a rank change.
|
||||
*/
|
||||
public static NickHistory rankChange(UUID uuid, String oldRank, String newRank, UUID changedBy, String reason) {
|
||||
return new NickHistory(0, uuid, null, null, oldRank, newRank, changedBy, reason, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new history entry for a full reset.
|
||||
*/
|
||||
public static NickHistory reset(UUID uuid, String oldNick, String oldRank, UUID changedBy) {
|
||||
return new NickHistory(0, uuid, oldNick, null, oldRank, null, changedBy, "Reset", Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether this change was made by an admin (not self).
|
||||
*/
|
||||
public boolean isAdminAction() {
|
||||
return changedBy != null && !changedBy.equals(uuid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.nickcore.common.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Immutable representation of a player's nick profile.
|
||||
* Contains all identity overrides: nickname, visual rank, and skin.
|
||||
*
|
||||
* <p>Thread-safe by design — all fields are final and the record is immutable.</p>
|
||||
*/
|
||||
public record NickProfile(
|
||||
UUID uuid,
|
||||
String nickname,
|
||||
String rankId,
|
||||
String skinId,
|
||||
String skinValue,
|
||||
String skinSignature,
|
||||
boolean nicked,
|
||||
Instant createdAt,
|
||||
Instant updatedAt
|
||||
) {
|
||||
/**
|
||||
* Canonical constructor with null safety on timestamps.
|
||||
*/
|
||||
public NickProfile {
|
||||
Objects.requireNonNull(uuid, "uuid must not be null");
|
||||
if (createdAt == null) createdAt = Instant.now();
|
||||
if (updatedAt == null) updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new empty profile for a player with no active nick.
|
||||
*/
|
||||
public static NickProfile empty(UUID uuid) {
|
||||
return new NickProfile(uuid, null, null, null, null, null, false, Instant.now(), Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the nickname updated.
|
||||
*/
|
||||
public NickProfile withNickname(String nickname) {
|
||||
return new NickProfile(uuid, nickname, rankId, skinId, skinValue, skinSignature,
|
||||
nickname != null, createdAt, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the rank updated.
|
||||
*/
|
||||
public NickProfile withRank(String rankId) {
|
||||
return new NickProfile(uuid, nickname, rankId, skinId, skinValue, skinSignature,
|
||||
nicked, createdAt, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the skin updated.
|
||||
*/
|
||||
public NickProfile withSkin(String skinId, String skinValue, String skinSignature) {
|
||||
return new NickProfile(uuid, nickname, rankId, skinId, skinValue, skinSignature,
|
||||
nicked, createdAt, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a fully reset profile (all overrides cleared).
|
||||
*/
|
||||
public NickProfile reset() {
|
||||
return new NickProfile(uuid, null, null, null, null, null, false, createdAt, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective display name: nickname if set, otherwise null (caller uses real name).
|
||||
*/
|
||||
public String effectiveDisplayName() {
|
||||
return nicked && nickname != null ? nickname : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether this profile has an active skin override.
|
||||
*/
|
||||
public boolean hasSkinOverride() {
|
||||
return skinValue != null && skinSignature != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.nickcore.common.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Immutable definition of a visual rank that can be applied to players.
|
||||
*
|
||||
* <p>Ranks affect chat prefixes/suffixes, TAB display, nametags, and placeholder output.
|
||||
* Each rank requires a specific permission node for selection.</p>
|
||||
*/
|
||||
public record RankDefinition(
|
||||
String id,
|
||||
String displayName,
|
||||
String prefix,
|
||||
String suffix,
|
||||
int weight,
|
||||
int priority,
|
||||
String color,
|
||||
String permission
|
||||
) {
|
||||
/**
|
||||
* Canonical constructor with validation.
|
||||
*/
|
||||
public RankDefinition {
|
||||
Objects.requireNonNull(id, "id must not be null");
|
||||
Objects.requireNonNull(displayName, "displayName must not be null");
|
||||
if (prefix == null) prefix = "";
|
||||
if (suffix == null) suffix = "";
|
||||
if (color == null) color = "white";
|
||||
if (permission == null) permission = "nickcore.rank." + id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the MiniMessage formatted prefix string.
|
||||
*/
|
||||
public String formattedPrefix() {
|
||||
if (prefix.isEmpty()) return "";
|
||||
return prefix + " ";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the MiniMessage formatted suffix string.
|
||||
*/
|
||||
public String formattedSuffix() {
|
||||
if (suffix.isEmpty()) return "";
|
||||
return " " + suffix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.nickcore.common.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Immutable container for Mojang-compatible skin texture data.
|
||||
*
|
||||
* <p>The {@code textureValue} and {@code textureSignature} are Base64-encoded
|
||||
* strings obtained from the Mojang Session API. Both are required for the
|
||||
* client to accept and render the skin.</p>
|
||||
*/
|
||||
public record SkinData(
|
||||
String name,
|
||||
String textureValue,
|
||||
String textureSignature,
|
||||
Instant fetchedAt,
|
||||
Instant expiresAt
|
||||
) {
|
||||
/**
|
||||
* Canonical constructor with validation.
|
||||
*/
|
||||
public SkinData {
|
||||
Objects.requireNonNull(name, "name must not be null");
|
||||
Objects.requireNonNull(textureValue, "textureValue must not be null");
|
||||
Objects.requireNonNull(textureSignature, "textureSignature must not be null");
|
||||
if (fetchedAt == null) fetchedAt = Instant.now();
|
||||
if (expiresAt == null) expiresAt = fetchedAt.plusSeconds(3600); // 1 hour default
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether this skin data has expired and should be re-fetched.
|
||||
*/
|
||||
public boolean isExpired() {
|
||||
return Instant.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with a new expiration time.
|
||||
*/
|
||||
public SkinData withExpiration(Instant expiresAt) {
|
||||
return new SkinData(name, textureValue, textureSignature, fetchedAt, expiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.nickcore.common.validation;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Input sanitization utilities for all user-provided strings.
|
||||
*
|
||||
* <p>Prevents MiniMessage injection, control character abuse, and other
|
||||
* text-based exploits. Should be applied to all user input before processing.</p>
|
||||
*
|
||||
* <p>Thread-safe: stateless utility class.</p>
|
||||
*/
|
||||
public final class InputSanitizer {
|
||||
|
||||
/** ASCII control characters (0x00-0x1F except tab, newline, carriage return). */
|
||||
private static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]");
|
||||
|
||||
/** MiniMessage tags that could be injected by malicious input. */
|
||||
private static final Pattern MINIMESSAGE_TAGS = Pattern.compile("<[^>]+>");
|
||||
|
||||
/** Legacy Minecraft color codes (§ and & prefixed). */
|
||||
private static final Pattern LEGACY_COLOR_CODES = Pattern.compile("[§&][0-9a-fk-orA-FK-OR]");
|
||||
|
||||
/** Consecutive whitespace normalization. */
|
||||
private static final Pattern MULTI_WHITESPACE = Pattern.compile("\\s{2,}");
|
||||
|
||||
private InputSanitizer() {
|
||||
throw new AssertionError("Utility class");
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs full sanitization of user input.
|
||||
*
|
||||
* <p>Strips control characters, MiniMessage tags, legacy color codes,
|
||||
* and normalizes whitespace.</p>
|
||||
*
|
||||
* @param input the raw user input
|
||||
* @return sanitized string, or empty string if input is null
|
||||
*/
|
||||
public static String sanitize(String input) {
|
||||
if (input == null) return "";
|
||||
|
||||
String result = input;
|
||||
result = CONTROL_CHARS.matcher(result).replaceAll("");
|
||||
result = MINIMESSAGE_TAGS.matcher(result).replaceAll("");
|
||||
result = LEGACY_COLOR_CODES.matcher(result).replaceAll("");
|
||||
result = MULTI_WHITESPACE.matcher(result).replaceAll(" ");
|
||||
result = result.trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes MiniMessage tags in user input so they render as literal text
|
||||
* rather than being parsed as formatting.
|
||||
*
|
||||
* <p>Use this when embedding user input into a MiniMessage template
|
||||
* to prevent injection attacks.</p>
|
||||
*
|
||||
* @param input the raw user input
|
||||
* @return escaped string safe for MiniMessage embedding
|
||||
*/
|
||||
public static String escapeMiniMessage(String input) {
|
||||
if (input == null) return "";
|
||||
|
||||
return input
|
||||
.replace("\\", "\\\\")
|
||||
.replace("<", "\\<")
|
||||
.replace(">", "\\>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips all color and formatting codes from a string, returning plain text.
|
||||
*
|
||||
* @param input the formatted string
|
||||
* @return plain text without any formatting
|
||||
*/
|
||||
public static String stripFormatting(String input) {
|
||||
if (input == null) return "";
|
||||
|
||||
String result = MINIMESSAGE_TAGS.matcher(input).replaceAll("");
|
||||
result = LEGACY_COLOR_CODES.matcher(result).replaceAll("");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a string contains only safe characters for use as an identifier.
|
||||
*
|
||||
* @param input the string to validate
|
||||
* @return true if the string is safe for use as an identifier
|
||||
*/
|
||||
public static boolean isSafeIdentifier(String input) {
|
||||
if (input == null || input.isEmpty()) return false;
|
||||
return input.matches("^[a-zA-Z0-9_-]+$");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.nickcore.common.validation;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Comprehensive nickname validation engine.
|
||||
*
|
||||
* <p>Validates nicknames against configurable rules to prevent abuse including
|
||||
* unicode exploits, invisible characters, formatting injection, impersonation,
|
||||
* and reserved name violations.</p>
|
||||
*
|
||||
* <p>Thread-safe: all state is effectively immutable after construction.</p>
|
||||
*/
|
||||
public final class NicknameValidator {
|
||||
|
||||
/** Default regex allowing alphanumeric characters and underscores. */
|
||||
private static final Pattern DEFAULT_ALLOWED_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$");
|
||||
|
||||
/** Zero-width and invisible Unicode characters. */
|
||||
private static final Pattern INVISIBLE_CHARS = Pattern.compile(
|
||||
"[\\u200B\\u200C\\u200D\\u200E\\u200F\\u2060\\u2061\\u2062\\u2063\\u2064"
|
||||
+ "\\uFEFF\\u00AD\\u034F\\u061C\\u115F\\u1160\\u17B4\\u17B5"
|
||||
+ "\\u180E\\u2000-\\u200A\\u202A-\\u202E\\u2066-\\u2069"
|
||||
+ "\\u00A0\\u1680\\u2028\\u2029\\u202F\\u205F\\u3000]"
|
||||
);
|
||||
|
||||
/** Common homoglyph patterns (Cyrillic/Greek chars that look like Latin). */
|
||||
private static final Pattern HOMOGLYPHS = Pattern.compile(
|
||||
"[\\u0400-\\u04FF\\u0370-\\u03FF\\u0500-\\u052F\\u2DE0-\\u2DFF"
|
||||
+ "\\uA640-\\uA69F\\u1C80-\\u1C8F\\uFF01-\\uFF5E]"
|
||||
);
|
||||
|
||||
/** MiniMessage/legacy color code patterns that could be used for injection. */
|
||||
private static final Pattern FORMAT_CODES = Pattern.compile(
|
||||
"(<[^>]+>)|([§&][0-9a-fk-or])"
|
||||
);
|
||||
|
||||
private final int minLength;
|
||||
private final int maxLength;
|
||||
private final Pattern allowedPattern;
|
||||
private final Set<String> reservedNames;
|
||||
private final Set<String> protectedNames;
|
||||
private final boolean blockHomoglyphs;
|
||||
private final boolean blockInvisibleChars;
|
||||
|
||||
private NicknameValidator(Builder builder) {
|
||||
this.minLength = builder.minLength;
|
||||
this.maxLength = builder.maxLength;
|
||||
this.allowedPattern = builder.allowedPattern;
|
||||
this.reservedNames = Set.copyOf(builder.reservedNames);
|
||||
this.protectedNames = Set.copyOf(builder.protectedNames);
|
||||
this.blockHomoglyphs = builder.blockHomoglyphs;
|
||||
this.blockInvisibleChars = builder.blockInvisibleChars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a nickname and returns a result containing all violations found.
|
||||
*
|
||||
* @param nickname the nickname to validate
|
||||
* @return validation result (never null)
|
||||
*/
|
||||
public ValidationResult validate(String nickname) {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
if (nickname == null || nickname.isBlank()) {
|
||||
violations.add("Nickname cannot be empty");
|
||||
return new ValidationResult(false, List.copyOf(violations));
|
||||
}
|
||||
|
||||
String trimmed = nickname.trim();
|
||||
|
||||
// Length checks
|
||||
if (trimmed.length() < minLength) {
|
||||
violations.add("Nickname must be at least " + minLength + " characters");
|
||||
}
|
||||
if (trimmed.length() > maxLength) {
|
||||
violations.add("Nickname must be at most " + maxLength + " characters");
|
||||
}
|
||||
|
||||
// Character whitelist check
|
||||
if (!allowedPattern.matcher(trimmed).matches()) {
|
||||
violations.add("Nickname contains invalid characters");
|
||||
}
|
||||
|
||||
// Invisible character check
|
||||
if (blockInvisibleChars && INVISIBLE_CHARS.matcher(trimmed).find()) {
|
||||
violations.add("Nickname contains invisible characters");
|
||||
}
|
||||
|
||||
// Homoglyph check
|
||||
if (blockHomoglyphs && HOMOGLYPHS.matcher(trimmed).find()) {
|
||||
violations.add("Nickname contains look-alike characters");
|
||||
}
|
||||
|
||||
// Format code injection check
|
||||
if (FORMAT_CODES.matcher(trimmed).find()) {
|
||||
violations.add("Nickname contains formatting codes");
|
||||
}
|
||||
|
||||
// Reserved name check (case-insensitive)
|
||||
if (reservedNames.stream().anyMatch(r -> r.equalsIgnoreCase(trimmed))) {
|
||||
violations.add("This nickname is reserved");
|
||||
}
|
||||
|
||||
// Protected name check (staff impersonation)
|
||||
if (protectedNames.stream().anyMatch(p -> p.equalsIgnoreCase(trimmed))) {
|
||||
violations.add("This nickname belongs to a protected player");
|
||||
}
|
||||
|
||||
return new ValidationResult(violations.isEmpty(), List.copyOf(violations));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an impersonation check using Levenshtein distance.
|
||||
*
|
||||
* @param nickname the proposed nickname
|
||||
* @param onlineNames names of currently online players
|
||||
* @param threshold maximum edit distance to flag (typically 1-2)
|
||||
* @return list of similar player names, empty if none
|
||||
*/
|
||||
public List<String> findSimilarNames(String nickname, Set<String> onlineNames, int threshold) {
|
||||
Objects.requireNonNull(nickname);
|
||||
if (onlineNames == null || onlineNames.isEmpty()) return List.of();
|
||||
|
||||
String lower = nickname.toLowerCase();
|
||||
List<String> similar = new ArrayList<>();
|
||||
|
||||
for (String online : onlineNames) {
|
||||
if (online.equalsIgnoreCase(nickname)) {
|
||||
similar.add(online);
|
||||
continue;
|
||||
}
|
||||
if (levenshteinDistance(lower, online.toLowerCase()) <= threshold) {
|
||||
similar.add(online);
|
||||
}
|
||||
}
|
||||
|
||||
return List.copyOf(similar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the Levenshtein edit distance between two strings.
|
||||
* Uses an optimized single-row DP approach for minimal allocation.
|
||||
*/
|
||||
private static int levenshteinDistance(String a, String b) {
|
||||
int lenA = a.length();
|
||||
int lenB = b.length();
|
||||
|
||||
if (lenA == 0) return lenB;
|
||||
if (lenB == 0) return lenA;
|
||||
|
||||
int[] prev = new int[lenB + 1];
|
||||
int[] curr = new int[lenB + 1];
|
||||
|
||||
for (int j = 0; j <= lenB; j++) {
|
||||
prev[j] = j;
|
||||
}
|
||||
|
||||
for (int i = 1; i <= lenA; i++) {
|
||||
curr[0] = i;
|
||||
for (int j = 1; j <= lenB; j++) {
|
||||
int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1;
|
||||
curr[j] = Math.min(Math.min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
|
||||
}
|
||||
int[] temp = prev;
|
||||
prev = curr;
|
||||
curr = temp;
|
||||
}
|
||||
|
||||
return prev[lenB];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new builder with default settings.
|
||||
*/
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result container.
|
||||
*/
|
||||
public record ValidationResult(boolean valid, List<String> violations) {
|
||||
public ValidationResult {
|
||||
violations = violations != null ? violations : List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for configuring the validator.
|
||||
*/
|
||||
public static final class Builder {
|
||||
private int minLength = 3;
|
||||
private int maxLength = 16;
|
||||
private Pattern allowedPattern = DEFAULT_ALLOWED_PATTERN;
|
||||
private Set<String> reservedNames = Set.of();
|
||||
private Set<String> protectedNames = Set.of();
|
||||
private boolean blockHomoglyphs = true;
|
||||
private boolean blockInvisibleChars = true;
|
||||
|
||||
private Builder() {}
|
||||
|
||||
public Builder minLength(int minLength) {
|
||||
this.minLength = minLength;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder maxLength(int maxLength) {
|
||||
this.maxLength = maxLength;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder allowedPattern(String regex) {
|
||||
this.allowedPattern = Pattern.compile(regex);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder reservedNames(Set<String> names) {
|
||||
this.reservedNames = names;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder protectedNames(Set<String> names) {
|
||||
this.protectedNames = names;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder blockHomoglyphs(boolean block) {
|
||||
this.blockHomoglyphs = block;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder blockInvisibleChars(boolean block) {
|
||||
this.blockInvisibleChars = block;
|
||||
return this;
|
||||
}
|
||||
|
||||
public NicknameValidator build() {
|
||||
return new NicknameValidator(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.nickcore.common.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for NickSyncMessage encode/decode.
|
||||
*/
|
||||
class NickSyncMessageTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Full sync message round-trips correctly")
|
||||
void fullSyncRoundTrip() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
var original = new NickSyncMessage(
|
||||
NickSyncMessage.CURRENT_PROTOCOL_VERSION,
|
||||
NickSyncMessage.Action.FULL_SYNC,
|
||||
uuid, "ShadowWolf", "vip", "steve",
|
||||
"textureValue123", "textureSig456", "lobby"
|
||||
);
|
||||
|
||||
byte[] encoded = original.encode();
|
||||
var decoded = NickSyncMessage.decode(encoded);
|
||||
|
||||
assertEquals(original.protocolVersion(), decoded.protocolVersion());
|
||||
assertEquals(original.action(), decoded.action());
|
||||
assertEquals(original.playerUuid(), decoded.playerUuid());
|
||||
assertEquals(original.nickname(), decoded.nickname());
|
||||
assertEquals(original.rankId(), decoded.rankId());
|
||||
assertEquals(original.skinId(), decoded.skinId());
|
||||
assertEquals(original.skinValue(), decoded.skinValue());
|
||||
assertEquals(original.skinSignature(), decoded.skinSignature());
|
||||
assertEquals(original.sourceServer(), decoded.sourceServer());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null fields round-trip correctly")
|
||||
void nullFieldsRoundTrip() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
var original = NickSyncMessage.reset(uuid, "lobby");
|
||||
|
||||
byte[] encoded = original.encode();
|
||||
var decoded = NickSyncMessage.decode(encoded);
|
||||
|
||||
assertEquals(NickSyncMessage.Action.RESET, decoded.action());
|
||||
assertEquals(uuid, decoded.playerUuid());
|
||||
assertNull(decoded.nickname());
|
||||
assertNull(decoded.rankId());
|
||||
assertNull(decoded.skinId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Invalid protocol version throws exception")
|
||||
void invalidProtocolVersion() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
var msg = new NickSyncMessage(
|
||||
999, NickSyncMessage.Action.RESET, uuid,
|
||||
null, null, null, null, null, null
|
||||
);
|
||||
|
||||
byte[] encoded = msg.encode();
|
||||
assertThrows(IllegalArgumentException.class, () -> NickSyncMessage.decode(encoded));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Factory methods create correct actions")
|
||||
void factoryMethods() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
|
||||
assertEquals(NickSyncMessage.Action.NICK_UPDATE,
|
||||
NickSyncMessage.nickUpdate(uuid, "Test", "s1").action());
|
||||
assertEquals(NickSyncMessage.Action.RANK_UPDATE,
|
||||
NickSyncMessage.rankUpdate(uuid, "vip", "s1").action());
|
||||
assertEquals(NickSyncMessage.Action.RESET,
|
||||
NickSyncMessage.reset(uuid, "s1").action());
|
||||
assertEquals(NickSyncMessage.Action.CACHE_INVALIDATE,
|
||||
NickSyncMessage.cacheInvalidate(uuid, "s1").action());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.nickcore.common.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for NickProfile immutability and behavior.
|
||||
*/
|
||||
class NickProfileTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty profile has correct defaults")
|
||||
void emptyProfile() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
NickProfile profile = NickProfile.empty(uuid);
|
||||
|
||||
assertEquals(uuid, profile.uuid());
|
||||
assertNull(profile.nickname());
|
||||
assertNull(profile.rankId());
|
||||
assertNull(profile.skinId());
|
||||
assertFalse(profile.nicked());
|
||||
assertNotNull(profile.createdAt());
|
||||
assertNotNull(profile.updatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("withNickname creates new profile with nick")
|
||||
void withNickname() {
|
||||
NickProfile original = NickProfile.empty(UUID.randomUUID());
|
||||
NickProfile nicked = original.withNickname("TestNick");
|
||||
|
||||
assertEquals("TestNick", nicked.nickname());
|
||||
assertTrue(nicked.nicked());
|
||||
assertNull(original.nickname()); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("withRank creates new profile with rank")
|
||||
void withRank() {
|
||||
NickProfile original = NickProfile.empty(UUID.randomUUID());
|
||||
NickProfile ranked = original.withRank("vip");
|
||||
|
||||
assertEquals("vip", ranked.rankId());
|
||||
assertNull(original.rankId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("withSkin creates new profile with skin")
|
||||
void withSkin() {
|
||||
NickProfile original = NickProfile.empty(UUID.randomUUID());
|
||||
NickProfile skinned = original.withSkin("steve", "val", "sig");
|
||||
|
||||
assertEquals("steve", skinned.skinId());
|
||||
assertTrue(skinned.hasSkinOverride());
|
||||
assertFalse(original.hasSkinOverride());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reset clears all overrides")
|
||||
void reset() {
|
||||
NickProfile profile = NickProfile.empty(UUID.randomUUID())
|
||||
.withNickname("Test").withRank("vip").withSkin("steve", "v", "s");
|
||||
NickProfile resetProfile = profile.reset();
|
||||
|
||||
assertNull(resetProfile.nickname());
|
||||
assertNull(resetProfile.rankId());
|
||||
assertNull(resetProfile.skinId());
|
||||
assertFalse(resetProfile.nicked());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("effectiveDisplayName returns nick when nicked")
|
||||
void effectiveDisplayName() {
|
||||
NickProfile nicked = NickProfile.empty(UUID.randomUUID()).withNickname("TestNick");
|
||||
assertEquals("TestNick", nicked.effectiveDisplayName());
|
||||
|
||||
NickProfile notNicked = NickProfile.empty(UUID.randomUUID());
|
||||
assertNull(notNicked.effectiveDisplayName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.nickcore.common.validation;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for InputSanitizer.
|
||||
*/
|
||||
class InputSanitizerTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Null input returns empty string")
|
||||
void nullInput() {
|
||||
assertEquals("", InputSanitizer.sanitize(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Clean input passes through unchanged")
|
||||
void cleanInput() {
|
||||
assertEquals("HelloWorld", InputSanitizer.sanitize("HelloWorld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Control characters are stripped")
|
||||
void controlChars() {
|
||||
assertEquals("Hello", InputSanitizer.sanitize("He\u0000ll\u0001o"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MiniMessage tags are stripped")
|
||||
void miniMessageStrip() {
|
||||
assertEquals("Hello", InputSanitizer.sanitize("<red>Hello</red>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Legacy color codes are stripped")
|
||||
void legacyColorStrip() {
|
||||
assertEquals("Hello", InputSanitizer.sanitize("§cHello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Multiple whitespace is normalized")
|
||||
void whitespaceNormalization() {
|
||||
assertEquals("Hello World", InputSanitizer.sanitize("Hello World"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MiniMessage escaping works")
|
||||
void miniMessageEscape() {
|
||||
assertEquals("\\<red\\>test\\</red\\>",
|
||||
InputSanitizer.escapeMiniMessage("<red>test</red>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Strip formatting removes all codes")
|
||||
void stripFormatting() {
|
||||
assertEquals("Hello World", InputSanitizer.stripFormatting("<gold>Hello</gold> §aWorld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Safe identifier validation")
|
||||
void safeIdentifier() {
|
||||
assertTrue(InputSanitizer.isSafeIdentifier("hello-world_123"));
|
||||
assertFalse(InputSanitizer.isSafeIdentifier("hello world"));
|
||||
assertFalse(InputSanitizer.isSafeIdentifier(""));
|
||||
assertFalse(InputSanitizer.isSafeIdentifier(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.nickcore.common.validation;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for NicknameValidator.
|
||||
*/
|
||||
class NicknameValidatorTest {
|
||||
|
||||
private NicknameValidator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = NicknameValidator.builder()
|
||||
.minLength(3)
|
||||
.maxLength(16)
|
||||
.reservedNames(Set.of("Admin", "Server", "Console"))
|
||||
.protectedNames(Set.of("Notch", "jeb_"))
|
||||
.blockHomoglyphs(true)
|
||||
.blockInvisibleChars(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valid alphanumeric nickname should pass")
|
||||
void validNickname() {
|
||||
var result = validator.validate("ShadowWolf");
|
||||
assertTrue(result.valid());
|
||||
assertTrue(result.violations().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Nickname with underscore should pass")
|
||||
void nicknameWithUnderscore() {
|
||||
var result = validator.validate("Shadow_Wolf");
|
||||
assertTrue(result.valid());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null nickname should fail")
|
||||
void nullNickname() {
|
||||
var result = validator.validate(null);
|
||||
assertFalse(result.valid());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty nickname should fail")
|
||||
void emptyNickname() {
|
||||
var result = validator.validate("");
|
||||
assertFalse(result.valid());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Too short nickname should fail")
|
||||
void tooShort() {
|
||||
var result = validator.validate("AB");
|
||||
assertFalse(result.valid());
|
||||
assertTrue(result.violations().stream().anyMatch(v -> v.contains("at least")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Too long nickname should fail")
|
||||
void tooLong() {
|
||||
var result = validator.validate("ThisNameIsWayTooLongForMinecraft");
|
||||
assertFalse(result.valid());
|
||||
assertTrue(result.violations().stream().anyMatch(v -> v.contains("at most")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Nickname with special characters should fail")
|
||||
void specialCharacters() {
|
||||
var result = validator.validate("Shadow@Wolf!");
|
||||
assertFalse(result.valid());
|
||||
assertTrue(result.violations().stream().anyMatch(v -> v.contains("invalid characters")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reserved name should fail")
|
||||
void reservedName() {
|
||||
var result = validator.validate("Admin");
|
||||
assertFalse(result.valid());
|
||||
assertTrue(result.violations().stream().anyMatch(v -> v.contains("reserved")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reserved name case-insensitive should fail")
|
||||
void reservedNameCaseInsensitive() {
|
||||
var result = validator.validate("admin");
|
||||
assertFalse(result.valid());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Protected name should fail")
|
||||
void protectedName() {
|
||||
var result = validator.validate("Notch");
|
||||
assertFalse(result.valid());
|
||||
assertTrue(result.violations().stream().anyMatch(v -> v.contains("protected")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MiniMessage tags should be rejected")
|
||||
void miniMessageInjection() {
|
||||
var result = validator.validate("<red>Hacker</red>");
|
||||
assertFalse(result.valid());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Legacy color codes should be rejected")
|
||||
void legacyColorCodes() {
|
||||
var result = validator.validate("§cHacker");
|
||||
assertFalse(result.valid());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Levenshtein distance detection works")
|
||||
void impersonationDetection() {
|
||||
Set<String> onlineNames = Set.of("PlayerOne", "SomeGuy");
|
||||
var similar = validator.findSimilarNames("PlayerOme", onlineNames, 1);
|
||||
assertEquals(1, similar.size());
|
||||
assertEquals("PlayerOne", similar.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Exact match detected in impersonation check")
|
||||
void exactMatchImpersonation() {
|
||||
Set<String> onlineNames = Set.of("ExactName");
|
||||
var similar = validator.findSimilarNames("ExactName", onlineNames, 2);
|
||||
assertFalse(similar.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("No impersonation with dissimilar names")
|
||||
void noImpersonation() {
|
||||
Set<String> onlineNames = Set.of("CompletelyDifferent");
|
||||
var similar = validator.findSimilarNames("ShadowWolf", onlineNames, 2);
|
||||
assertTrue(similar.isEmpty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user