feat: NickCore v1.0.0 - enterprise nickname plugin for Paper/Folia/Velocity

This commit is contained in:
2026-05-30 21:05:02 -05:00
parent 8a42740875
commit f0b35e89ac
54 changed files with 5393 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
dependencies {
val adventureVersion: String by project
compileOnly("net.kyori:adventure-api:$adventureVersion")
compileOnly("net.kyori:adventure-text-minimessage:$adventureVersion")
}

View File

@@ -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";
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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_-]+$");
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}

View File

@@ -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());
}
}