diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java index 14ab45b03c..e5502f55e4 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java @@ -6,6 +6,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; import org.bukkit.Bukkit; @@ -49,9 +50,12 @@ public class PermissionHandler { @Getter private final ScheduledExecutorService timer = Executors.newScheduledThreadPool(1); + private final AtomicBoolean acceptingExpirations = new AtomicBoolean(true); + private final Object[] stateLocks = new Object[64]; public PermissionHandler(AdvancedCorePlugin plugin) { this.plugin = plugin; + for (int i = 0; i < stateLocks.length; i++) stateLocks[i] = new Object(); // Restore timed permissions from previous shutdown (stored as expireAtMillis) if (plugin.getServerDataFile().getData() != null @@ -88,6 +92,63 @@ public PermissionHandler(AdvancedCorePlugin plugin) { } } + + void scheduleExpiration(PlayerPermissionHandler handle, String permission, long expectedExpireAt, long delayMillis) { + if (!acceptingExpirations.get()) return; + try { + timer.schedule(() -> dispatchExpiration(handle, permission, expectedExpireAt), + Math.max(0L, delayMillis), java.util.concurrent.TimeUnit.MILLISECONDS); + } catch (RuntimeException failure) { + plugin.debug(failure); + throw failure; + } + } + + void dispatchExpiration(PlayerPermissionHandler handle, String permission, long expectedExpireAt) { + if (!acceptingExpirations.get() || !handle.isExpirationCurrent(permission, expectedExpireAt)) return; + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!acceptingExpirations.get() || !handle.isExpirationCurrent(permission, expectedExpireAt)) return; + Player player = Bukkit.getPlayer(handle.getUuid()); + if (player == null) { + expireOfflineOrRetry(handle, permission, expectedExpireAt); + return; + } + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!acceptingExpirations.get()) return; + handle.expirePermission(permission, expectedExpireAt, true); + }, player); + } catch (RuntimeException failure) { + plugin.debug(failure); + retryExpiration(handle, permission, expectedExpireAt); + } + }); + } catch (RuntimeException failure) { + plugin.debug(failure); + retryExpiration(handle, permission, expectedExpireAt); + } + } + + private void expireOfflineOrRetry(PlayerPermissionHandler handle, String permission, long expectedExpireAt) { + boolean active; + UUID uuid = handle.getUuid(); + synchronized (stateLock(uuid)) { + if (permsToAdd.get(uuid) == handle) { + handle.expirePermission(permission, expectedExpireAt, false); + return; + } + active = perms.get(uuid) == handle; + } + if (active) retryExpiration(handle, permission, expectedExpireAt); + } + + private void retryExpiration(PlayerPermissionHandler handle, String permission, long expectedExpireAt) { + if (!acceptingExpirations.get() || !handle.isExpirationCurrent(permission, expectedExpireAt)) return; + try { scheduleExpiration(handle, permission, expectedExpireAt, 1_000L); } + catch (RuntimeException ignored) { /* scheduleExpiration already reported the rejection */ } + } + public void addPermission(Player player, String permission) { addPermission(player.getUniqueId(), permission); } @@ -115,22 +176,32 @@ public void addPermission(UUID uuid, String permission) { return; } - for (String perm : permission.split(Pattern.quote("|"))) { - PlayerPermissionHandler handle = perms.get(uuid); + synchronized (stateLock(uuid)) { + for (String perm : permission.split(Pattern.quote("|"))) { + PlayerPermissionHandler handle = perms.get(uuid); - if (handle != null) { - handle.addPerm(perm); - continue; - } + if (handle != null) { + handle.addPerm(perm); + continue; + } + PlayerPermissionHandler pending = permsToAdd.get(uuid); + if (pending != null) { + pending.addOfflinePerm(perm, ParsedDuration.empty()); + continue; + } - Player p = Bukkit.getPlayer(uuid); - if (p != null) { - PermissionAttachment attachment = p.addAttachment(plugin); - PlayerPermissionHandler newHandle = new PlayerPermissionHandler(uuid, attachment, this).addPerm(perm); - perms.put(uuid, newHandle); - } else { - permsToAdd.put(uuid, - new PlayerPermissionHandler(uuid, null, this).addOfflinePerm(perm, ParsedDuration.empty())); + Player p = Bukkit.getPlayer(uuid); + if (p != null) { + PermissionAttachment attachment = p.addAttachment(plugin); + PlayerPermissionHandler newHandle = new PlayerPermissionHandler(uuid, attachment, this).addPerm(perm); + perms.put(uuid, newHandle); + } else { + permsToAdd.compute(uuid, (ignored, existing) -> { + PlayerPermissionHandler target = existing == null + ? new PlayerPermissionHandler(uuid, null, this) : existing; + return target.addOfflinePerm(perm, ParsedDuration.empty()); + }); + } } } } @@ -152,22 +223,33 @@ public void addPermission(UUID uuid, String permission, ParsedDuration duration) return; } - for (String perm : permission.split(Pattern.quote("|"))) { - PlayerPermissionHandler handle = perms.get(uuid); + synchronized (stateLock(uuid)) { + for (String perm : permission.split(Pattern.quote("|"))) { + PlayerPermissionHandler handle = perms.get(uuid); - if (handle != null) { - handle.addExpiration(perm, duration); - continue; - } + if (handle != null) { + handle.addExpiration(perm, duration); + continue; + } + PlayerPermissionHandler pending = permsToAdd.get(uuid); + if (pending != null) { + pending.addOfflinePerm(perm, duration); + continue; + } - Player p = Bukkit.getPlayer(uuid); - if (p != null) { - PermissionAttachment attachment = p.addAttachment(plugin); - PlayerPermissionHandler newHandle = new PlayerPermissionHandler(uuid, attachment, this) - .addExpiration(perm, duration); - perms.put(uuid, newHandle); - } else { - permsToAdd.put(uuid, new PlayerPermissionHandler(uuid, null, this).addOfflinePerm(perm, duration)); + Player p = Bukkit.getPlayer(uuid); + if (p != null) { + PermissionAttachment attachment = p.addAttachment(plugin); + PlayerPermissionHandler newHandle = new PlayerPermissionHandler(uuid, attachment, this) + .addExpiration(perm, duration); + perms.put(uuid, newHandle); + } else { + permsToAdd.compute(uuid, (ignored, existing) -> { + PlayerPermissionHandler target = existing == null + ? new PlayerPermissionHandler(uuid, null, this) : existing; + return target.addOfflinePerm(perm, duration); + }); + } } } } @@ -188,19 +270,20 @@ public void addPermission(UUID uuid, String permission, long seconds) { */ public void login(Player player) { UUID uuid = player.getUniqueId(); + synchronized (stateLock(uuid)) { + PlayerPermissionHandler handle = perms.get(uuid); + if (handle != null) { + handle.setAttachment(player.addAttachment(plugin)); + handle.onLogin(player); + return; + } - PlayerPermissionHandler handle = perms.get(uuid); - if (handle != null) { - handle.setAttachment(player.addAttachment(plugin)); - handle.onLogin(player); - return; - } - - PlayerPermissionHandler pending = permsToAdd.remove(uuid); - if (pending != null) { - pending.setAttachment(player.addAttachment(plugin)); - pending.onLogin(player); - perms.put(uuid, pending); + PlayerPermissionHandler pending = permsToAdd.remove(uuid); + if (pending != null) { + pending.setAttachment(player.addAttachment(plugin)); + pending.onLogin(player); + perms.put(uuid, pending); + } } } @@ -213,26 +296,46 @@ public void login(Player player) { *

*/ public void logout(Player player) { - PlayerPermissionHandler handle = perms.remove(player.getUniqueId()); - if (handle == null) { - return; - } + UUID uuid = player.getUniqueId(); + synchronized (stateLock(uuid)) { + PlayerPermissionHandler handle = perms.remove(uuid); + if (handle == null) return; - try { - if (handle.getAttachment() != null) { - player.removeAttachment(handle.getAttachment()); + try { + if (handle.getAttachment() != null) player.removeAttachment(handle.getAttachment()); + } catch (Throwable ignored) { } - } catch (Throwable ignored) { - } - handle.setAttachment(null); - handle.onLogout(player); - permsToAdd.put(player.getUniqueId(), handle); + handle.setAttachment(null); + handle.onLogout(player); + permsToAdd.merge(uuid, handle, (pending, moved) -> { + java.util.Map queued = pending.offlinePermissionSnapshot(); + moved.mergeOfflinePermissions(queued); + return moved; + }); + } } public void removePermission(UUID uuid) { - perms.remove(uuid); - permsToAdd.remove(uuid); + synchronized (stateLock(uuid)) { + perms.remove(uuid); + permsToAdd.remove(uuid); + } + } + + void removePermission(UUID uuid, PlayerPermissionHandler expected) { + synchronized (stateLock(uuid)) { + perms.remove(uuid, expected); + permsToAdd.remove(uuid, expected); + } + } + + void removePermissionIfEmpty(UUID uuid, PlayerPermissionHandler expected, boolean attachmentIsOffline) { + synchronized (stateLock(uuid)) { + if (!expected.isHandlerEmpty(attachmentIsOffline)) return; + perms.remove(uuid, expected); + permsToAdd.remove(uuid, expected); + } } /** @@ -251,28 +354,34 @@ public void removePermission(UUID uuid, String playerName, String permission) { return; } - PlayerPermissionHandler handle = perms.get(uuid); - if (handle == null) { - handle = permsToAdd.get(uuid); - } - if (handle == null) { - return; - } - - for (String perm : permission.split(Pattern.quote("|"))) { - handle.removePermission(perm); - if (playerName != null && !playerName.isEmpty()) { - plugin.debug("Removing temp permission " + perm + " from " + playerName); - } else { - plugin.debug("Removing temp permission " + perm + " from " + uuid); + synchronized (stateLock(uuid)) { + PlayerPermissionHandler handle = perms.get(uuid); + if (handle == null) handle = permsToAdd.get(uuid); + if (handle == null) return; + + for (String perm : permission.split(Pattern.quote("|"))) { + handle.removePermission(perm); + if (playerName != null && !playerName.isEmpty()) { + plugin.debug("Removing temp permission " + perm + " from " + playerName); + } else { + plugin.debug("Removing temp permission " + perm + " from " + uuid); + } } } } + private Object stateLock(UUID uuid) { + return stateLocks[(uuid.hashCode() & Integer.MAX_VALUE) % stateLocks.length]; + } + /** * Persists timed permissions for both online + offline handlers. */ public void shutDown() { + // Fence every timer/global/entity callback before taking persistence snapshots. + // A callback already inside a handler monitor finishes before timedPermissionSnapshot(). + acceptingExpirations.set(false); + timer.shutdownNow(); saveTimedPerms(perms); saveTimedPerms(permsToAdd); plugin.getServerDataFile().saveData(); @@ -280,16 +389,13 @@ public void shutDown() { private void saveTimedPerms(ConcurrentHashMap map) { for (PlayerPermissionHandler handle : map.values()) { - if (handle.getTimedPermissions() == null || handle.getTimedPermissions().isEmpty()) { - continue; - } + java.util.Map snapshot = handle.timedPermissionSnapshot(); + if (snapshot.isEmpty()) continue; ArrayList list = new ArrayList<>(); - for (Entry entry : handle.getTimedPermissions().entrySet()) { - // Store absolute expireAtMillis + for (Entry entry : snapshot.entrySet()) { list.add(entry.getKey() + "%line%" + entry.getValue()); } - plugin.getServerDataFile().getData().set("TimedPermissions." + handle.getUuid(), list); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandler.java index b1c5a52dea..658d7a71b3 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandler.java @@ -5,7 +5,6 @@ import java.util.Map.Entry; import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeUnit; import org.bukkit.entity.Player; import org.bukkit.permissions.PermissionAttachment; @@ -70,7 +69,7 @@ public PlayerPermissionHandler(UUID uuid, PermissionAttachment attachment, Permi * @param duration How long the permission should last * @return this */ - public PlayerPermissionHandler addExpiration(String perm, ParsedDuration duration) { + public synchronized PlayerPermissionHandler addExpiration(String perm, ParsedDuration duration) { if (duration == null || duration.isEmpty()) { return addPerm(perm); } @@ -87,7 +86,7 @@ public PlayerPermissionHandler addExpiration(String perm, ParsedDuration duratio } long delayMillis = duration.delayMillisFromNow(); - handler.getTimer().schedule(() -> removePermission(perm), delayMillis, TimeUnit.MILLISECONDS); + handler.scheduleExpiration(this, perm, expireAt, delayMillis); return this; } @@ -106,7 +105,7 @@ public PlayerPermissionHandler addExpiration(String perm, long seconds) { * @param duration Duration; empty means permanent * @return this */ - public PlayerPermissionHandler addOfflinePerm(String perm, ParsedDuration duration) { + public synchronized PlayerPermissionHandler addOfflinePerm(String perm, ParsedDuration duration) { if (permsToAdd == null) { permsToAdd = new HashMap<>(); } @@ -115,6 +114,16 @@ public PlayerPermissionHandler addOfflinePerm(String perm, ParsedDuration durati return this; } + synchronized void mergeOfflinePermissions(java.util.Map queued) { + if (queued.isEmpty()) return; + if (permsToAdd == null) permsToAdd = new HashMap<>(); + permsToAdd.putAll(queued); + } + + synchronized java.util.Map offlinePermissionSnapshot() { + return permsToAdd == null ? java.util.Map.of() : new HashMap<>(permsToAdd); + } + /** * Backwards-compatible offline queue. */ @@ -128,7 +137,7 @@ public PlayerPermissionHandler addOfflinePerm(String perm, long seconds) { * @param perm Permission node * @return this */ - public PlayerPermissionHandler addPerm(String perm) { + public synchronized PlayerPermissionHandler addPerm(String perm) { persistentPermissions.add(perm); if (attachment != null) { @@ -141,7 +150,7 @@ public PlayerPermissionHandler addPerm(String perm) { /** * Re-applies stored permissions after an attachment is created on login. */ - public void onLogin(Player player) { + public synchronized void onLogin(Player player) { if (attachment == null) { return; } @@ -184,30 +193,67 @@ public void onLogout(Player player) { // intentionally empty } + + /** True only while this exact scheduled expiration still owns the timed grant. */ + synchronized java.util.Map timedPermissionSnapshot() { + return timedPermissions == null ? java.util.Map.of() : new java.util.HashMap<>(timedPermissions); + } + + synchronized boolean isExpirationCurrent(String perm, long expectedExpireAt) { + if (timedPermissions == null) return false; + Long current = timedPermissions.get(perm); + return current != null && current.longValue() == expectedExpireAt; + } + /** - * Removes a permission from both internal tracking and the live attachment (if online). + * Expire only the scheduled grant that still owns this permission. The + * attachment flag is true only from the player's owning platform scheduler. */ - public void removePermission(String perm) { - persistentPermissions.remove(perm); - if (timedPermissions != null) { - timedPermissions.remove(perm); - } - if (permsToAdd != null) { - permsToAdd.remove(perm); - } - - if (attachment != null) { - attachment.setPermission(perm, false); - attachment.getPermissions().remove(perm); + void expirePermission(String perm, long expectedExpireAt, boolean updateAttachment) { + long remaining; + boolean removeHandler; + synchronized (this) { + if (timedPermissions == null) return; + Long current = timedPermissions.get(perm); + if (current == null || current.longValue() != expectedExpireAt) return; + remaining = current.longValue() - System.currentTimeMillis(); + if (remaining > 0L) { + removeHandler = false; + } else { + if (!timedPermissions.remove(perm, current)) return; + persistentPermissions.remove(perm); + if (updateAttachment && attachment != null) attachment.unsetPermission(perm); + removeHandler = isHandlerEmpty(!updateAttachment); + } } + if (remaining > 0L) handler.scheduleExpiration(this, perm, expectedExpireAt, remaining); + else if (removeHandler) handler.removePermissionIfEmpty(uuid, this, !updateAttachment); + } - // If nothing tracked and attachment has nothing, drop handler state entirely + synchronized boolean isHandlerEmpty(boolean attachmentIsOffline) { boolean noTracked = persistentPermissions.isEmpty() && (timedPermissions == null || timedPermissions.isEmpty()) && (permsToAdd == null || permsToAdd.isEmpty()); + return noTracked && (attachmentIsOffline || attachment == null || attachment.getPermissions().isEmpty()); + } + + /** + * Removes a permission from both internal tracking and the live attachment (if online). + */ + public void removePermission(String perm) { + boolean removeHandler; + synchronized (this) { + persistentPermissions.remove(perm); + if (timedPermissions != null) timedPermissions.remove(perm); + if (permsToAdd != null) permsToAdd.remove(perm); + + if (attachment != null) { + attachment.setPermission(perm, false); + attachment.getPermissions().remove(perm); + } - if (noTracked && attachment != null && attachment.getPermissions().isEmpty()) { - handler.removePermission(uuid); + removeHandler = isHandlerEmpty(false); } + if (removeHandler) handler.removePermissionIfEmpty(uuid, this, false); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java index 0f5c094fa5..6e102c45fa 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java @@ -1,6 +1,7 @@ package com.bencodez.advancedcore.listeners; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.ConcurrentHashMap; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -17,7 +18,14 @@ public class PlayerJoinEvent implements Listener { - private final AdvancedCorePlugin plugin; + private final AdvancedCorePlugin plugin; + private final ConcurrentHashMap pendingLoginSessions = new ConcurrentHashMap<>(); + private final Object[] loginSessionLocks = java.util.stream.IntStream.range(0, 64) + .mapToObj(ignored -> new Object()).toArray(); + + private Object loginSessionLock(java.util.UUID uuid) { + return loginSessionLocks[(uuid.hashCode() & Integer.MAX_VALUE) % loginSessionLocks.length]; + } public PlayerJoinEvent(AdvancedCorePlugin plugin) { this.plugin = plugin; @@ -58,7 +66,7 @@ public void onJoin(AdvancedCoreLoginEvent event) { } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) - public void onPlayerLogin(final org.bukkit.event.player.PlayerJoinEvent event) { + public void onPlayerLogin(final org.bukkit.event.player.PlayerJoinEvent event) { if (plugin == null || !plugin.isEnabled() || !plugin.isLoadUserData()) { return; } @@ -70,7 +78,11 @@ public void onPlayerLogin(final org.bukkit.event.player.PlayerJoinEvent event) { plugin.debug("Login: " + event.getPlayer().getName() + " (" + event.getPlayer().getUniqueId() + ")"); } - plugin.getLoginTimer().schedule(new Runnable() { + Player joiningPlayer = event.getPlayer(); + synchronized (loginSessionLock(joiningPlayer.getUniqueId())) { + pendingLoginSessions.put(joiningPlayer.getUniqueId(), joiningPlayer); + } + plugin.getLoginTimer().schedule(new Runnable() { @Override public void run() { @@ -86,10 +98,10 @@ public void run() { return; } - Player player = event.getPlayer(); - if (player == null) { - return; - } + Player player = event.getPlayer(); + if (player == null || pendingLoginSessions.get(player.getUniqueId()) != player) { + return; + } // Vanish metadata support for (MetadataValue meta : player.getMetadata("vanished")) { @@ -115,9 +127,10 @@ public void run() { plugin.debug("Login: " + player.getName() + " (" + player.getUniqueId() + ")"); - if (plugin.getPermissionHandler() != null) { - plugin.getPermissionHandler().login(player); - } + synchronized (loginSessionLock(player.getUniqueId())) { + if (pendingLoginSessions.get(player.getUniqueId()) != player) return; + if (plugin.getPermissionHandler() != null) plugin.getPermissionHandler().login(player); + } // Resolve UUID BEFORE constructing/getting the user final String resolvedUuid = plugin.getOptions().isOnlineMode() ? player.getUniqueId().toString() @@ -142,9 +155,9 @@ public void run() { return; } - } catch (Exception e) { - e.printStackTrace(); - } + } catch (Exception e) { + e.printStackTrace(); + } finally { pendingLoginSessions.remove(joiningPlayer.getUniqueId(), joiningPlayer); } } }, 1500 + plugin.getOptions().getDelayLoginEventMs(), TimeUnit.MILLISECONDS); } @@ -155,12 +168,13 @@ public void onPlayerQuit(PlayerQuitEvent event) { return; } - Player player = event.getPlayer(); - plugin.debug("Logout: " + player.getName() + " (" + player.getUniqueId() + ")"); - - if (plugin.getPermissionHandler() != null) { - plugin.getPermissionHandler().login(player); - } + Player player = event.getPlayer(); + plugin.debug("Logout: " + player.getName() + " (" + player.getUniqueId() + ")"); + + synchronized (loginSessionLock(player.getUniqueId())) { + pendingLoginSessions.remove(player.getUniqueId(), player); + if (plugin.getPermissionHandler() != null) plugin.getPermissionHandler().logout(player); + } plugin.getLoginTimer().execute(new Runnable() { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandlerThreadingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandlerThreadingTest.java new file mode 100644 index 0000000000..fdd035cf48 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/permissions/PlayerPermissionHandlerThreadingTest.java @@ -0,0 +1,256 @@ +package com.bencodez.advancedcore.api.permissions; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.permissions.PermissionAttachment; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.time.ParsedDuration; + +class PlayerPermissionHandlerThreadingTest { + @Test + void offlineGrantsReuseTheHandlerPreservedByLogout() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getServerDataFile().getData()).thenReturn(null); + PermissionHandler manager = new PermissionHandler(plugin); + UUID uuid = UUID.randomUUID(); + PlayerPermissionHandler preserved = new PlayerPermissionHandler(uuid, null, manager) + .addOfflinePerm("existing.use", ParsedDuration.empty()); + manager.getPermsToAdd().put(uuid, preserved); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + manager.addPermission(uuid, "new.use"); + assertSame(preserved, manager.getPermsToAdd().get(uuid)); + PermissionAttachment attachment = mock(PermissionAttachment.class); + preserved.setAttachment(attachment); + preserved.onLogin(mock(Player.class)); + verify(attachment).setPermission("existing.use", true); + verify(attachment).setPermission("new.use", true); + } finally { manager.getTimer().shutdownNow(); } + } + + @Test + void entitySchedulerRejectionRetriesCurrentExpiration() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getServerDataFile().getData()).thenReturn(null); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + PermissionHandler manager = spy(new PermissionHandler(plugin)); + PlayerPermissionHandler handle = mock(PlayerPermissionHandler.class); + UUID uuid = UUID.randomUUID(); + long expiry = System.currentTimeMillis(); + when(handle.getUuid()).thenReturn(uuid); + when(handle.isExpirationCurrent("example.use", expiry)).thenReturn(true); + Player player = mock(Player.class); + doAnswer(call -> { ((Runnable) call.getArgument(1)).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doThrow(new IllegalStateException("entity retired")) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + doNothing().when(manager).scheduleExpiration(handle, "example.use", expiry, 1_000L); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + manager.dispatchExpiration(handle, "example.use", expiry); + verify(manager).scheduleExpiration(handle, "example.use", expiry, 1_000L); + verify(handle, never()).expirePermission(anyString(), anyLong(), anyBoolean()); + } finally { manager.getTimer().shutdownNow(); } + } + + @Test + void staleExpirationCannotRevokeExtendedPermission() { + PermissionHandler manager = mock(PermissionHandler.class); + PermissionAttachment attachment = mock(PermissionAttachment.class); + when(attachment.getPermissions()).thenReturn(new java.util.LinkedHashMap<>()); + PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), attachment, manager); + + handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000)); + long firstExpiry = handler.getTimedPermissions().get("example.use"); + handler.addExpiration("example.use", ParsedDuration.ofMillis(120_000)); + long extendedExpiry = handler.getTimedPermissions().get("example.use"); + assertTrue(extendedExpiry >= firstExpiry); + clearInvocations(attachment); + + handler.expirePermission("example.use", firstExpiry, true); + + assertEquals(extendedExpiry, handler.getTimedPermissions().get("example.use")); + verify(attachment, never()).unsetPermission("example.use"); + } + + @Test + void offOwnerExpirationUpdatesStateWithoutTouchingAttachment() { + PermissionHandler manager = mock(PermissionHandler.class); + PermissionAttachment attachment = mock(PermissionAttachment.class); + when(attachment.getPermissions()).thenReturn(new java.util.LinkedHashMap<>()); + PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), attachment, manager); + + handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000)); + long expiry = System.currentTimeMillis() - 1L; + handler.getTimedPermissions().put("example.use", expiry); + clearInvocations(attachment); + handler.expirePermission("example.use", expiry, false); + + assertFalse(handler.getTimedPermissions().containsKey("example.use")); + verify(attachment, never()).unsetPermission(anyString()); + verify(attachment, never()).setPermission(anyString(), anyBoolean()); + } + + @Test + void offlineExpirationDropsEmptyHandlerDespiteStaleAttachmentState() { + PermissionHandler manager = mock(PermissionHandler.class); + PermissionAttachment attachment = mock(PermissionAttachment.class); + when(attachment.getPermissions()).thenReturn(java.util.Map.of("example.use", true)); + UUID uuid = UUID.randomUUID(); + PlayerPermissionHandler handler = new PlayerPermissionHandler(uuid, attachment, manager); + handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000)); + long expiry = System.currentTimeMillis() - 1L; + handler.getTimedPermissions().put("example.use", expiry); + clearInvocations(attachment); + + handler.expirePermission("example.use", expiry, false); + + verify(manager).removePermissionIfEmpty(uuid, handler, true); + verify(attachment, never()).unsetPermission(anyString()); + verify(attachment, never()).setPermission(anyString(), anyBoolean()); + } + + @Test + void expirationClearsAttachmentEntryInsteadOfInstallingDenial() { + PermissionHandler manager = mock(PermissionHandler.class); + PermissionAttachment attachment = mock(PermissionAttachment.class); + when(attachment.getPermissions()).thenReturn(new java.util.LinkedHashMap<>()); + PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), attachment, manager); + handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000)); + long expiry = System.currentTimeMillis() - 1L; + handler.getTimedPermissions().put("example.use", expiry); + clearInvocations(attachment); + handler.expirePermission("example.use", expiry, true); + verify(attachment).unsetPermission("example.use"); + verify(attachment, never()).setPermission("example.use", false); + } + + @Test + void earlyTimerReschedulesInsteadOfRevokingPermission() { + PermissionHandler manager = mock(PermissionHandler.class); + PermissionAttachment attachment = mock(PermissionAttachment.class); + PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), attachment, manager); + handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000)); + long expiry = handler.getTimedPermissions().get("example.use"); + clearInvocations(manager, attachment); + + handler.expirePermission("example.use", expiry, true); + + assertTrue(handler.isExpirationCurrent("example.use", expiry)); + verify(manager).scheduleExpiration(eq(handler), eq("example.use"), eq(expiry), longThat(delay -> delay > 0)); + verify(attachment, never()).unsetPermission(anyString()); + } + + @Test + void oldTimedExpirationPreservesQueuedRegrant() { + PermissionHandler manager = mock(PermissionHandler.class); + PermissionAttachment attachment = mock(PermissionAttachment.class); + PlayerPermissionHandler handler = new PlayerPermissionHandler(UUID.randomUUID(), null, manager); + handler.addExpiration("example.use", ParsedDuration.ofMillis(60_000)); + long expiry = System.currentTimeMillis() - 1L; + handler.getTimedPermissions().put("example.use", expiry); + handler.addOfflinePerm("example.use", ParsedDuration.empty()); + + handler.expirePermission("example.use", expiry, false); + handler.setAttachment(attachment); + handler.onLogin(mock(Player.class)); + + verify(attachment).setPermission("example.use", true); + } + + @Test + void logoutHandoffIsAtomicWithOfflineGrant() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getServerDataFile().getData()).thenReturn(null); + PermissionHandler manager = new PermissionHandler(plugin); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PermissionAttachment oldAttachment = mock(PermissionAttachment.class); + when(player.getUniqueId()).thenReturn(uuid); + PlayerPermissionHandler active = new PlayerPermissionHandler(uuid, oldAttachment, manager).addPerm("existing.use"); + manager.getPerms().put(uuid, active); + java.util.concurrent.CountDownLatch logoutEntered = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch releaseLogout = new java.util.concurrent.CountDownLatch(1); + doAnswer(call -> { + logoutEntered.countDown(); + releaseLogout.await(); + return null; + }).when(player).removeAttachment(oldAttachment); + java.util.concurrent.atomic.AtomicBoolean grantFinished = new java.util.concurrent.atomic.AtomicBoolean(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + Thread logout = new Thread(() -> manager.logout(player)); + Thread grant = new Thread(() -> { + manager.addPermission(uuid, "new.use"); + grantFinished.set(true); + }); + logout.start(); + assertTrue(logoutEntered.await(1, java.util.concurrent.TimeUnit.SECONDS)); + grant.start(); + Thread.sleep(50L); + assertFalse(grantFinished.get(), "grant must wait for the map handoff"); + releaseLogout.countDown(); + logout.join(1_000L); + grant.join(1_000L); + assertFalse(logout.isAlive()); + assertFalse(grant.isAlive()); + assertSame(active, manager.getPermsToAdd().get(uuid)); + PermissionAttachment newAttachment = mock(PermissionAttachment.class); + active.setAttachment(newAttachment); + active.onLogin(player); + verify(newAttachment).setPermission("existing.use", true); + verify(newAttachment).setPermission("new.use", true); + } finally { + releaseLogout.countDown(); + manager.getTimer().shutdownNow(); + } + } + + @Test + void offlineExpirationCannotRaceACompletedLogin() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getServerDataFile().getData()).thenReturn(null); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + PermissionHandler manager = spy(new PermissionHandler(plugin)); + UUID uuid = UUID.randomUUID(); + PlayerPermissionHandler handle = mock(PlayerPermissionHandler.class); + when(handle.getUuid()).thenReturn(uuid); + long expiry = System.currentTimeMillis() - 1L; + when(handle.isExpirationCurrent("example.use", expiry)).thenReturn(true); + manager.getPermsToAdd().put(uuid, handle); + java.util.concurrent.atomic.AtomicReference global = new java.util.concurrent.atomic.AtomicReference<>(); + doAnswer(call -> { global.set(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doNothing().when(manager).scheduleExpiration(handle, "example.use", expiry, 1_000L); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.addAttachment(plugin)).thenReturn(mock(PermissionAttachment.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + manager.dispatchExpiration(handle, "example.use", expiry); + manager.login(player); + global.get().run(); + verify(handle, never()).expirePermission("example.use", expiry, false); + verify(manager).scheduleExpiration(handle, "example.use", expiry, 1_000L); + } finally { manager.getTimer().shutdownNow(); } + } + +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java new file mode 100644 index 0000000000..a019453273 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java @@ -0,0 +1,46 @@ +package com.bencodez.advancedcore.listeners; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.permissions.PermissionHandler; + +class PlayerJoinEventSessionTest { + @Test + void delayedLoginDoesNotRestorePermissionsAfterThePlayerQuit() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + ScheduledExecutorService loginTimer = mock(ScheduledExecutorService.class); + PermissionHandler permissions = mock(PermissionHandler.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions().isHideLoginMessage()).thenReturn(true); + when(plugin.getLoginTimer()).thenReturn(loginTimer); + when(plugin.getPermissionHandler()).thenReturn(permissions); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getName()).thenReturn("Player"); + org.bukkit.event.player.PlayerJoinEvent join = mock(org.bukkit.event.player.PlayerJoinEvent.class); + when(join.getPlayer()).thenReturn(player); + org.bukkit.event.player.PlayerQuitEvent quit = mock(org.bukkit.event.player.PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(player); + ArgumentCaptor delayed = ArgumentCaptor.forClass(Runnable.class); + + PlayerJoinEvent listener = new PlayerJoinEvent(plugin); + listener.onPlayerLogin(join); + verify(loginTimer).schedule(delayed.capture(), anyLong(), eq(TimeUnit.MILLISECONDS)); + listener.onPlayerQuit(quit); + delayed.getValue().run(); + + verify(permissions).logout(player); + verify(permissions, never()).login(any()); + } +}