diff --git a/bukkit/loader/build.gradle b/bukkit/loader/build.gradle index c41f2a81c..d2e556cc9 100644 --- a/bukkit/loader/build.gradle +++ b/bukkit/loader/build.gradle @@ -2,6 +2,10 @@ plugins { alias(libs.plugins.shadow) } +configurations { + testJar +} + repositories { maven { url 'https://repo.papermc.io/repository/maven-public/' } } @@ -27,6 +31,18 @@ shadowJar { } } +tasks.register('copyTestJar', Copy) { + from tasks.shadowJar.archiveFile + into layout.buildDirectory.dir('testJars') + rename { String fileName -> + return 'luckperms-bukkit.testjar' + } +} + +artifacts.add('testJar', layout.buildDirectory.dir('testJars')) { + builtBy('copyTestJar') +} + artifacts { archives shadowJar } diff --git a/fabric/build.gradle b/fabric/build.gradle index 36e7c5f60..a15cdf18e 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -7,6 +7,10 @@ plugins { archivesBaseName = 'luckperms' +configurations { + testJar +} + repositories { maven { url 'https://maven.fabricmc.net/' } } @@ -81,6 +85,18 @@ task remappedShadowJar(type: RemapJarTask) { tasks.assemble.dependsOn tasks.remappedShadowJar +tasks.register('copyTestJar', Copy) { + from tasks.remappedShadowJar.archiveFile + into layout.buildDirectory.dir('testJars') + rename { String fileName -> + return 'luckperms-fabric.testjar' + } +} + +artifacts.add('testJar', layout.buildDirectory.dir('testJars')) { + builtBy('copyTestJar') +} + artifacts { archives remappedShadowJar shadow shadowJar diff --git a/forge/loader/build.gradle b/forge/loader/build.gradle index 1b6f0040d..35432ef47 100644 --- a/forge/loader/build.gradle +++ b/forge/loader/build.gradle @@ -7,6 +7,10 @@ plugins { sourceCompatibility = 1.8 targetCompatibility = 17 +configurations { + testJar +} + minecraft { mappings channel: 'official', version: minecraftVersion } @@ -60,6 +64,20 @@ shadowJar { } } +tasks.register('copyTestJar', Copy) { + from tasks.shadowJar.archiveFile + into layout.buildDirectory.dir('testJars') + dependsOn reobfShadowJar + rename { String fileName -> + return 'luckperms-forge.testjar' + } +} + +artifacts.add('testJar', layout.buildDirectory.dir('testJars')) { + builtBy('copyTestJar') +} + + artifacts { archives shadowJar } diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle new file mode 100644 index 000000000..93ec1d67f --- /dev/null +++ b/integration-tests/build.gradle @@ -0,0 +1,34 @@ +sourceCompatibility = 17 +targetCompatibility = 17 + +test { + useJUnitPlatform { + if (!project.hasProperty('dockerTests')) { + excludeTags 'docker' + } + } +} + +repositories { + maven { url 'https://repo.opencollab.dev/maven-releases/' } + maven { url 'https://jitpack.io' } +} + +dependencies { + testImplementation 'org.apache.logging.log4j:log4j-core:2.20.0' + testImplementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.20.0' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1' + testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.1' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.1' + testImplementation "org.testcontainers:junit-jupiter:1.18.3" + testImplementation 'org.mockito:mockito-core:4.11.0' + testImplementation 'org.mockito:mockito-junit-jupiter:4.11.0' + testImplementation 'org.awaitility:awaitility:4.2.0' + + testImplementation project(path: ':bukkit:loader', configuration: 'testJar') + testImplementation project(path: ':fabric', configuration: 'testJar') + testImplementation project(path: ':forge:loader', configuration: 'testJar') + + testImplementation 'com.github.steveice10:mcprotocollib:1.20-1' +} diff --git a/integration-tests/src/test/java/me/lucko/luckperms/integration/IntegrationTests.java b/integration-tests/src/test/java/me/lucko/luckperms/integration/IntegrationTests.java new file mode 100644 index 000000000..93a8c3495 --- /dev/null +++ b/integration-tests/src/test/java/me/lucko/luckperms/integration/IntegrationTests.java @@ -0,0 +1,257 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.integration; + +import com.github.steveice10.mc.protocol.MinecraftProtocol; +import com.github.steveice10.mc.protocol.packet.ingame.serverbound.ServerboundChatCommandPacket; +import com.github.steveice10.packetlib.tcp.TcpClientSession; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +public class IntegrationTests { + + private static final Logger LOGGER = LoggerFactory.getLogger(IntegrationTests.class); + + @Nested + class Bukkit extends Generic { + + @Container + private final GenericContainer container = new GenericContainer<>(DockerImageName.parse("itzg/minecraft-server")) + .withEnv("EULA", "TRUE") + .withEnv("TYPE", "PAPER") + .withEnv("ONLINE_MODE", "FALSE") + .withEnv("LUCKPERMS_DEBUG_LOGINS", "true") + .withExposedPorts(25565) + .withClasspathResourceMapping("luckperms-bukkit.testjar", "/plugins/LuckPerms.jar", BindMode.READ_ONLY) + .withLogConsumer(new Slf4jLogConsumer(LOGGER)) + .waitingFor(Wait.forLogMessage(".*Done.*", 1).withStartupTimeout(Duration.ofSeconds(120))); + + @Override + protected GenericContainer container() { + return this.container; + } + + @Test + public void testBukkit() throws IOException, InterruptedException { + // ensure the server has started + assertTrue(this.container.isRunning()); + + // ensure the LuckPerms plugin enabled successfully + assertLogsContain("[LuckPerms] Successfully enabled."); + + // create a Minecraft client and login to the server + loginWithClient(); + + // wait for the player to connect + awaitLogsContain("lucko joined the game"); + + // ensure the player login was handled by LuckPerms + assertLogsContain( + "[LuckPerms] Processing pre-login for 9a06e4db-8487-39b2-8751-1d107561d787 - lucko", + "[LuckPerms] Processing login for 9a06e4db-8487-39b2-8751-1d107561d787 - lucko" + ); + + // give player some permissions & enable verbose mode + executeServerCommand("lp user lucko permission set minecraft.command.ban"); + executeServerCommand("lp verbose on minecraft.command.ban"); + + // wait for verbose mode to be enabled + awaitLogsContain("[LP] Verbose logging enabled"); + + // get the client to execute a command + executeClientCommand("ban"); + + // wait for the verbose results to show up + awaitLogsContain("[LP] VB > lucko - minecraft.command.ban - true"); + + // disable verbose mode and disconnect the client + executeServerCommand("lp verbose off"); + disconnectClient(); + + // wait for verbose to be disabled and the client to leave + awaitLogsContain( + "[LP] Verbose logging disabled.", + "lucko left the game" + ); + + // stop the server + executeServerCommand("stop"); + + // ensure the server process stops gracefully + await().atMost(30, TimeUnit.SECONDS).until(() -> !this.container.isRunning()); + + // ensure the plugin disabled + assertLogsContain("[LuckPerms] Goodbye!"); + + // check for LuckPerms stack traces in the log output + assertFalse(logsContain("at me.lucko.luckperms"), "There seems to be stack traces from LuckPerms in the logs"); + } + } + + @Nested + class Fabric extends Generic { + + @Container + private final GenericContainer container = new GenericContainer<>(DockerImageName.parse("itzg/minecraft-server")) + .withEnv("EULA", "TRUE") + .withEnv("TYPE", "FABRIC") + .withEnv("ONLINE_MODE", "FALSE") + .withEnv("MODRINTH_PROJECTS", "fabric-api") + .withEnv("LUCKPERMS_DEBUG_LOGINS", "true") + .withExposedPorts(25565) + .withClasspathResourceMapping("luckperms-fabric.testjar", "/mods/LuckPerms.jar", BindMode.READ_ONLY) + .withLogConsumer(new Slf4jLogConsumer(LOGGER)) + .waitingFor(Wait.forLogMessage(".*Done.*", 1).withStartupTimeout(Duration.ofSeconds(120))); + + @Override + protected GenericContainer container() { + return this.container; + } + + @Test + public void testFabric() throws IOException, InterruptedException { + // ensure the server has started + assertTrue(this.container.isRunning()); + + // ensure the LuckPerms plugin enabled successfully + assertLogsContain("Successfully enabled.");// assertLogsContain("[LuckPerms] Successfully enabled."); + + // create a Minecraft client and login to the server + loginWithClient(); + + // wait for the player to connect + //awaitLogsContain("lucko joined the game"); + + // ensure the player login was handled by LuckPerms + awaitLogsContain( + "Processing pre-login for 9a06e4db-8487-39b2-8751-1d107561d787 - lucko", + "Processing login for 9a06e4db-8487-39b2-8751-1d107561d787 - lucko" + ); + + // give player some permissions & enable verbose mode + executeServerCommand("lp user lucko permission set minecraft.command.ban"); + executeServerCommand("lp verbose on minecraft.command.ban"); + + // wait for verbose mode to be enabled + awaitLogsContain("[LP] Verbose logging enabled"); + + // get the client to execute a command + executeClientCommand("ban"); + + // wait for the verbose results to show up + awaitLogsContain("[LP] VB > lucko - minecraft.command.ban - true"); + + // disable verbose mode and disconnect the client + executeServerCommand("lp verbose off"); + disconnectClient(); + + // wait for verbose to be disabled and the client to leave + awaitLogsContain( + "[LP] Verbose logging disabled.", + "lucko left the game" + ); + + // stop the server + executeServerCommand("stop"); + + // ensure the server process stops gracefully + await().atMost(30, TimeUnit.SECONDS).until(() -> !this.container.isRunning()); + + // ensure the plugin disabled + assertLogsContain("[LuckPerms] Goodbye!"); + + // check for LuckPerms stack traces in the log output + assertFalse(logsContain("at me.lucko.luckperms"), "There seems to be stack traces from LuckPerms in the logs"); + } + } + + static abstract class Generic { + private TcpClientSession client; + + protected abstract GenericContainer container(); + + protected boolean logsContain(String... strings) { + String logs = container().getLogs(); + return Arrays.stream(strings).allMatch(logs::contains); + } + + protected void assertLogsContain(String... strings) { + assertTrue(logsContain(strings), "container logs must contain: " + Arrays.stream(strings).collect(Collectors.joining(", ", "'", "'"))); + } + + protected void awaitLogsContain(String... strings) { + await().atMost(10, TimeUnit.SECONDS).until(() -> logsContain(strings)); + } + + protected void loginWithClient() { + this.client = new TcpClientSession( + container().getHost(), + container().getFirstMappedPort(), + new MinecraftProtocol("lucko") + ); + this.client.connect(); + } + + protected void disconnectClient() { + this.client.disconnect("Disconnecting"); + } + + protected void executeServerCommand(String command) throws IOException, InterruptedException { + assertEquals(0, container().execInContainer("mc-send-to-console", command).getExitCode()); + } + + protected void executeClientCommand(String command) { + // TODO: the other args here are a bit of a mystery but it seems to work + this.client.send(new ServerboundChatCommandPacket(command, Instant.now().toEpochMilli(), 0, new ArrayList<>(), 0, new BitSet())); + } + + } + +} diff --git a/integration-tests/src/test/resources/log4j2.xml b/integration-tests/src/test/resources/log4j2.xml new file mode 100644 index 000000000..9e883e960 --- /dev/null +++ b/integration-tests/src/test/resources/log4j2.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 91cdf3aa2..f4b4adf5e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -40,5 +40,6 @@ include ( 'velocity', 'standalone', 'standalone:loader', - 'standalone:app' + 'standalone:app', + 'integration-tests' )