Add test coverage for core server components: annotation scanning, routing, rate limiting, CORS, and JSON handling
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
name: Run Tests on Push and Pull Request
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
runs-on: java26
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
run: |
|
||||
SERVER_DOMAIN=$(echo "${{ github.server_url }}" | sed 's/https:\/\///')
|
||||
rm -rf "$GITHUB_WORKSPACE"/*
|
||||
git clone "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@${SERVER_DOMAIN}/${{ github.repository }}.git" "$GITHUB_WORKSPACE"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
chmod +x ./gradlew
|
||||
|
||||
- name: Run JUnit tests
|
||||
id: run_tests
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
./gradlew test --no-daemon --stacktrace
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
if [ -d "build/reports/tests/test" ]; then
|
||||
echo "Test reports available in build/reports/tests/test"
|
||||
ls -la build/reports/tests/test || true
|
||||
fi
|
||||
if [ -d "build/test-results/test" ]; then
|
||||
echo "Test result XMLs:"
|
||||
ls -la build/test-results/test || true
|
||||
fi
|
||||
@@ -13,6 +13,10 @@ repositories {
|
||||
dependencies {
|
||||
implementation 'io.netty:netty-all:4.2.14.Final'
|
||||
implementation 'tools.jackson.core:jackson-databind:3.1.3'
|
||||
|
||||
testImplementation platform('org.junit:junit-bom:5.11.4')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
java {
|
||||
@@ -23,6 +27,14 @@ java {
|
||||
targetCompatibility = JavaVersion.VERSION_26
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed"
|
||||
showStandardStreams = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.coph.nextusweb.server;
|
||||
|
||||
import dev.coph.nextusweb.server.cores.CorsConfig;
|
||||
import dev.coph.nextusweb.server.cores.CorsHandler;
|
||||
import dev.coph.nextusweb.server.ratelimit.RateLimitConfig;
|
||||
import dev.coph.nextusweb.server.ratelimit.RateLimitGate;
|
||||
import dev.coph.nextusweb.server.router.Router;
|
||||
import dev.coph.nextusweb.server.websocket.WebSocketConfig;
|
||||
import dev.coph.nextusweb.server.websocket.WebSocketRouter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HttpServerTest {
|
||||
|
||||
@Test
|
||||
void builderReturnsConfiguredServer() {
|
||||
Router router = new Router();
|
||||
HttpServer server = HttpServer.builder(0, router);
|
||||
assertNotNull(server);
|
||||
assertSame(server, server.withCorsHandler(new CorsHandler(CorsConfig.permissive())));
|
||||
RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder().build());
|
||||
try {
|
||||
assertSame(server, server.withRateLimitGate(gate));
|
||||
assertSame(server, server.withWebSockets(new WebSocketRouter()));
|
||||
assertSame(server, server.withWebSockets(new WebSocketRouter(), WebSocketConfig.defaults()));
|
||||
} finally {
|
||||
gate.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package dev.coph.nextusweb.server.annotation;
|
||||
|
||||
import dev.coph.nextusweb.server.router.Request;
|
||||
import dev.coph.nextusweb.server.router.Response;
|
||||
import dev.coph.nextusweb.server.router.Router;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AnnotationScannerTest {
|
||||
|
||||
@Controller("/api")
|
||||
static class GoodController {
|
||||
AtomicBoolean called = new AtomicBoolean(false);
|
||||
|
||||
@GET("/hello")
|
||||
public void hello(Request req, Response res) {
|
||||
called.set(true);
|
||||
res.text("hi");
|
||||
}
|
||||
|
||||
@POST("post")
|
||||
public void post(Request req, Response res) {}
|
||||
|
||||
@Route(method = "PUT", path = "/put")
|
||||
public void put(Request req, Response res) {}
|
||||
|
||||
@CUSTOM(method = "OPTIONS", value = "/opt")
|
||||
public void opt(Request req, Response res) {}
|
||||
|
||||
public void notAnnotated(Request req, Response res) {}
|
||||
}
|
||||
|
||||
static class NoControllerAnnotation {
|
||||
@GET("/x")
|
||||
public void x(Request req, Response res) {}
|
||||
}
|
||||
|
||||
@Controller("nopref")
|
||||
static class PrefixNoSlash {
|
||||
@GET("/a")
|
||||
public void a(Request req, Response res) {}
|
||||
}
|
||||
|
||||
static class BadSignature {
|
||||
@GET("/bad")
|
||||
public String bad(String s) { return s; }
|
||||
}
|
||||
|
||||
static class WrongReturnType {
|
||||
@GET("/bad")
|
||||
public String bad(Request req, Response res) { return "no"; }
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersAllAnnotatedRoutesWithPrefix() {
|
||||
Router router = new Router();
|
||||
GoodController ctrl = new GoodController();
|
||||
AnnotationScanner.register(router, ctrl);
|
||||
|
||||
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/api/hello"));
|
||||
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.POST, "/api/post"));
|
||||
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.PUT, "/api/put"));
|
||||
assertInstanceOf(Router.Resolution.Match.class,
|
||||
router.resolve(HttpMethod.valueOf("OPTIONS"), "/api/opt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationWithoutControllerAnnotationUsesEmptyPrefix() {
|
||||
Router router = new Router();
|
||||
AnnotationScanner.register(router, new NoControllerAnnotation());
|
||||
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefixWithoutLeadingSlashIsNormalized() {
|
||||
Router router = new Router();
|
||||
AnnotationScanner.register(router, new PrefixNoSlash());
|
||||
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/nopref/a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invokingRegisteredHandlerCallsTheControllerMethod() throws Exception {
|
||||
Router router = new Router();
|
||||
GoodController ctrl = new GoodController();
|
||||
AnnotationScanner.register(router, ctrl);
|
||||
|
||||
Router.Resolution res = router.resolve(HttpMethod.GET, "/api/hello");
|
||||
Router.Resolution.Match m = assertInstanceOf(Router.Resolution.Match.class, res);
|
||||
|
||||
Response resp = new Response();
|
||||
m.handler().handle(null, resp);
|
||||
assertTrue(ctrl.called.get());
|
||||
assertEquals(200, resp.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSignatureThrows() {
|
||||
Router router = new Router();
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> AnnotationScanner.register(router, new BadSignature()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongReturnTypeThrows() {
|
||||
Router router = new Router();
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> AnnotationScanner.register(router, new WrongReturnType()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dev.coph.nextusweb.server.annotation;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AnnotationsTest {
|
||||
|
||||
@Controller
|
||||
static class CtrlDefault {}
|
||||
|
||||
@Controller("/api")
|
||||
static class CtrlWithValue {}
|
||||
|
||||
static class Routes {
|
||||
@GET("/g") public void g() {}
|
||||
@POST("/p") public void p() {}
|
||||
@PUT("/u") public void u() {}
|
||||
@PATCH("/pa") public void pa() {}
|
||||
@DELETE("/d") public void d() {}
|
||||
@CUSTOM(method = "OPTIONS", value = "/o") public void o() {}
|
||||
@Route(method = "TRACE", path = "/t") public void t() {}
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerDefaultValueIsEmpty() {
|
||||
Controller c = CtrlDefault.class.getAnnotation(Controller.class);
|
||||
assertNotNull(c);
|
||||
assertEquals("", c.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerValueIsCarried() {
|
||||
Controller c = CtrlWithValue.class.getAnnotation(Controller.class);
|
||||
assertNotNull(c);
|
||||
assertEquals("/api", c.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerHasRuntimeRetentionAndTypeTarget() {
|
||||
Retention r = Controller.class.getAnnotation(Retention.class);
|
||||
Target t = Controller.class.getAnnotation(Target.class);
|
||||
assertEquals(RetentionPolicy.RUNTIME, r.value());
|
||||
assertArrayEquals(new ElementType[]{ElementType.TYPE}, t.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeMethodAnnotationsCarryValues() throws Exception {
|
||||
assertEquals("/g", Routes.class.getDeclaredMethod("g").getAnnotation(GET.class).value());
|
||||
assertEquals("/p", Routes.class.getDeclaredMethod("p").getAnnotation(POST.class).value());
|
||||
assertEquals("/u", Routes.class.getDeclaredMethod("u").getAnnotation(PUT.class).value());
|
||||
assertEquals("/pa", Routes.class.getDeclaredMethod("pa").getAnnotation(PATCH.class).value());
|
||||
assertEquals("/d", Routes.class.getDeclaredMethod("d").getAnnotation(DELETE.class).value());
|
||||
|
||||
CUSTOM custom = Routes.class.getDeclaredMethod("o").getAnnotation(CUSTOM.class);
|
||||
assertEquals("OPTIONS", custom.method());
|
||||
assertEquals("/o", custom.value());
|
||||
|
||||
Route route = Routes.class.getDeclaredMethod("t").getAnnotation(Route.class);
|
||||
assertEquals("TRACE", route.method());
|
||||
assertEquals("/t", route.path());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allMethodAnnotationsTargetMethods() {
|
||||
Class<?>[] anns = {GET.class, POST.class, PUT.class, PATCH.class, DELETE.class, CUSTOM.class, Route.class};
|
||||
for (Class<?> a : anns) {
|
||||
Target t = a.getAnnotation(Target.class);
|
||||
Retention r = a.getAnnotation(Retention.class);
|
||||
assertNotNull(t, a.getSimpleName() + " missing @Target");
|
||||
assertNotNull(r, a.getSimpleName() + " missing @Retention");
|
||||
assertEquals(RetentionPolicy.RUNTIME, r.value());
|
||||
assertArrayEquals(new ElementType[]{ElementType.METHOD}, t.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dev.coph.nextusweb.server.cores;
|
||||
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CorsConfigTest {
|
||||
|
||||
@Test
|
||||
void permissiveBuildsWithExpectedDefaults() {
|
||||
CorsConfig c = CorsConfig.permissive();
|
||||
assertTrue(c.allowAnyOrigin());
|
||||
assertFalse(c.allowCredentials());
|
||||
assertEquals(3600, c.maxAgeSeconds());
|
||||
assertTrue(c.allowedMethods().contains(HttpMethod.GET));
|
||||
assertTrue(c.allowedMethods().contains(HttpMethod.OPTIONS));
|
||||
assertTrue(c.allowedHeaders().contains("Authorization"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOriginAllowedHandlesNullAndWildcard() {
|
||||
assertFalse(CorsConfig.permissive().isOriginAllowed(null));
|
||||
assertTrue(CorsConfig.permissive().isOriginAllowed("https://anything"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactOriginMatchOnly() {
|
||||
CorsConfig c = CorsConfig.builder()
|
||||
.allowedOrigins("https://a.com")
|
||||
.build();
|
||||
assertTrue(c.isOriginAllowed("https://a.com"));
|
||||
assertFalse(c.isOriginAllowed("https://b.com"));
|
||||
assertFalse(c.isOriginAllowed(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wildcardWithCredentialsIsRejected() {
|
||||
assertThrows(IllegalStateException.class, () -> CorsConfig.builder()
|
||||
.anyOrigin()
|
||||
.allowCredentials(true)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposedAndAllowedHeadersAreCopied() {
|
||||
CorsConfig c = CorsConfig.builder()
|
||||
.allowedHeaders("A", "B")
|
||||
.exposedHeaders("X")
|
||||
.allowedMethods(HttpMethod.GET)
|
||||
.allowCredentials(true)
|
||||
.maxAgeSeconds(60)
|
||||
.allowedOrigins("http://a")
|
||||
.build();
|
||||
assertEquals(2, c.allowedHeaders().size());
|
||||
assertTrue(c.exposedHeaders().contains("X"));
|
||||
assertTrue(c.allowCredentials());
|
||||
assertEquals(60, c.maxAgeSeconds());
|
||||
assertFalse(c.allowAnyOrigin());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package dev.coph.nextusweb.server.cores;
|
||||
|
||||
import dev.coph.nextusweb.server.router.Response;
|
||||
import io.netty.handler.codec.http.DefaultHttpHeaders;
|
||||
import io.netty.handler.codec.http.HttpHeaders;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CorsHandlerTest {
|
||||
|
||||
private CorsHandler permissiveHandler() {
|
||||
return new CorsHandler(CorsConfig.permissive());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyHeadersDoesNothingForNullOrigin() {
|
||||
Response res = new Response();
|
||||
permissiveHandler().applyHeaders(null, res);
|
||||
assertNull(res.headers().get("Access-Control-Allow-Origin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyHeadersDoesNothingForDisallowedOrigin() {
|
||||
CorsHandler h = new CorsHandler(CorsConfig.builder()
|
||||
.allowedOrigins("https://allow")
|
||||
.allowedMethods(HttpMethod.GET).build());
|
||||
Response res = new Response();
|
||||
h.applyHeaders("https://other", res);
|
||||
assertNull(res.headers().get("Access-Control-Allow-Origin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyHeadersWritesWildcardWhenAnyOriginAndNoCreds() {
|
||||
Response res = new Response();
|
||||
permissiveHandler().applyHeaders("https://x.com", res);
|
||||
assertEquals("*", res.headers().get("Access-Control-Allow-Origin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyHeadersWritesOriginAndVaryWhenSpecific() {
|
||||
CorsHandler h = new CorsHandler(CorsConfig.builder()
|
||||
.allowedOrigins("https://a")
|
||||
.allowedMethods(HttpMethod.GET)
|
||||
.allowCredentials(true)
|
||||
.exposedHeaders("X-Custom").build());
|
||||
Response res = new Response();
|
||||
h.applyHeaders("https://a", res);
|
||||
assertEquals("https://a", res.headers().get("Access-Control-Allow-Origin"));
|
||||
assertEquals("Origin", res.headers().get("Vary"));
|
||||
assertEquals("true", res.headers().get("Access-Control-Allow-Credentials"));
|
||||
assertEquals("X-Custom", res.headers().get("Access-Control-Expose-Headers"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPreflightTrueOnlyForOptionsWithRequestMethod() {
|
||||
HttpHeaders hs = new DefaultHttpHeaders();
|
||||
hs.set("Access-Control-Request-Method", "GET");
|
||||
assertTrue(permissiveHandler().isPreflight(HttpMethod.OPTIONS, hs));
|
||||
assertFalse(permissiveHandler().isPreflight(HttpMethod.GET, hs));
|
||||
HttpHeaders empty = new DefaultHttpHeaders();
|
||||
assertFalse(permissiveHandler().isPreflight(HttpMethod.OPTIONS, empty));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlePreflightReturns403ForDisallowedOrigin() {
|
||||
CorsHandler h = new CorsHandler(CorsConfig.builder()
|
||||
.allowedOrigins("https://allow")
|
||||
.allowedMethods(HttpMethod.GET).build());
|
||||
Response res = h.handlePreflight("https://other", new DefaultHttpHeaders());
|
||||
assertEquals(403, res.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlePreflightWritesAllowAndMaxAge() {
|
||||
Response res = permissiveHandler().handlePreflight("https://x.com", new DefaultHttpHeaders());
|
||||
assertEquals(204, res.status());
|
||||
assertNotNull(res.headers().get("Access-Control-Allow-Methods"));
|
||||
assertEquals("3600", res.headers().get("Access-Control-Max-Age"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlePreflightEchoesRequestedHeadersIfNoneConfigured() {
|
||||
CorsConfig cfg = CorsConfig.builder()
|
||||
.anyOrigin()
|
||||
.allowedMethods(HttpMethod.GET)
|
||||
.build();
|
||||
CorsHandler h = new CorsHandler(cfg);
|
||||
HttpHeaders req = new DefaultHttpHeaders();
|
||||
req.set("Access-Control-Request-Headers", "x-foo");
|
||||
Response res = h.handlePreflight("https://x.com", req);
|
||||
assertEquals("x-foo", res.headers().get("Access-Control-Allow-Headers"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.coph.nextusweb.server.json;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JsonMapperTest {
|
||||
|
||||
@Test
|
||||
void mapperIsAvailable() {
|
||||
assertNotNull(JsonMapper.MAPPER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapperRoundTripsSimpleValues() {
|
||||
var node = JsonMapper.MAPPER.valueToTree(java.util.Map.of("a", 1));
|
||||
assertTrue(node.isObject());
|
||||
assertEquals(1, node.get("a").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapperReadsTree() {
|
||||
JsonNode n = JsonMapper.MAPPER.readTree("{\"k\":\"v\"}");
|
||||
assertTrue(n.has("k"));
|
||||
assertNotNull(n.get("k"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapperSerializesToBytes() {
|
||||
byte[] bytes = JsonMapper.MAPPER.writeValueAsBytes(java.util.Map.of("a", "b"));
|
||||
String s = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
assertTrue(s.contains("\"a\""));
|
||||
assertTrue(s.contains("\"b\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FixedWindowLimiterTest {
|
||||
|
||||
@Test
|
||||
void allowsUpToLimitThenDenies() {
|
||||
FixedWindowLimiter lim = new FixedWindowLimiter(3, 1000);
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
RateLimiter.Result r = lim.tryAcquire("k", 0);
|
||||
assertFalse(r.allowed());
|
||||
assertEquals(3, r.limit());
|
||||
assertTrue(r.retryAfterMillis() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void newWindowResetsCount() {
|
||||
FixedWindowLimiter lim = new FixedWindowLimiter(1, 100);
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
assertFalse(lim.tryAcquire("k", 0).allowed());
|
||||
long windowNs = 100L * 1_000_000L;
|
||||
assertTrue(lim.tryAcquire("k", windowNs).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentKeysAreIndependent() {
|
||||
FixedWindowLimiter lim = new FixedWindowLimiter(1, 1000);
|
||||
assertTrue(lim.tryAcquire("a", 0).allowed());
|
||||
assertTrue(lim.tryAcquire("b", 0).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDoesNotThrow() {
|
||||
FixedWindowLimiter lim = new FixedWindowLimiter(1, 1000);
|
||||
lim.tryAcquire("k", System.nanoTime());
|
||||
assertDoesNotThrow(() -> lim.cleanup(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import io.netty.handler.codec.http.DefaultHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import io.netty.handler.codec.http.HttpRequest;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class KeyResolverTest {
|
||||
|
||||
private HttpRequest req(String header, String value) {
|
||||
HttpRequest r = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/");
|
||||
if (header != null) r.headers().set(header, value);
|
||||
return r;
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientIpUsesRemoteWhenNoForwardedHeader() {
|
||||
assertEquals("10.0.0.1", KeyResolver.clientIp().resolve(req(null, null), "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientIpUsesForwardedHeaderFirstValue() {
|
||||
HttpRequest r = req("X-Forwarded-For", "1.1.1.1, 2.2.2.2");
|
||||
assertEquals("1.1.1.1", KeyResolver.clientIp().resolve(r, "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientIpHandlesSingleForwardedValue() {
|
||||
HttpRequest r = req("X-Forwarded-For", "3.3.3.3");
|
||||
assertEquals("3.3.3.3", KeyResolver.clientIp().resolve(r, "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userOrIpReturnsBearerToken() {
|
||||
HttpRequest r = req("Authorization", "Bearer abc123");
|
||||
assertEquals("u:abc123", KeyResolver.userOrIp().resolve(r, "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userOrIpFallsBackToClientIp() {
|
||||
HttpRequest r = req(null, null);
|
||||
assertEquals("ip:10.0.0.1", KeyResolver.userOrIp().resolve(r, "10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userOrIpIgnoresNonBearerAuth() {
|
||||
HttpRequest r = req("Authorization", "Basic xyz");
|
||||
assertEquals("ip:10.0.0.1", KeyResolver.userOrIp().resolve(r, "10.0.0.1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LeakyBucketLimiterTest {
|
||||
|
||||
@Test
|
||||
void fillsUpToCapacityThenDenies() {
|
||||
LeakyBucketLimiter lim = new LeakyBucketLimiter(1, 2);
|
||||
long now = 0;
|
||||
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||
RateLimiter.Result r = lim.tryAcquire("k", now);
|
||||
assertFalse(r.allowed());
|
||||
assertEquals(2, r.limit());
|
||||
assertTrue(r.retryAfterMillis() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leakReducesLevelAndAllowsAgain() {
|
||||
LeakyBucketLimiter lim = new LeakyBucketLimiter(10, 1);
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
assertFalse(lim.tryAcquire("k", 0).allowed());
|
||||
long oneSec = 1_000_000_000L;
|
||||
assertTrue(lim.tryAcquire("k", oneSec).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentKeysAreIndependent() {
|
||||
LeakyBucketLimiter lim = new LeakyBucketLimiter(1, 1);
|
||||
assertTrue(lim.tryAcquire("a", 0).allowed());
|
||||
assertTrue(lim.tryAcquire("b", 0).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDoesNotThrow() {
|
||||
LeakyBucketLimiter lim = new LeakyBucketLimiter(1, 1);
|
||||
lim.tryAcquire("k", System.nanoTime());
|
||||
assertDoesNotThrow(() -> lim.cleanup(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RateLimitConfigTest {
|
||||
|
||||
private RateLimiter alwaysAllow() {
|
||||
return (k, now) -> RateLimiter.Result.allow(1, 1);
|
||||
}
|
||||
|
||||
private KeyResolver keyer() {
|
||||
return (req, remote) -> "x";
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyConfigReturnsEmptyList() {
|
||||
RateLimitConfig cfg = RateLimitConfig.builder().build();
|
||||
assertTrue(cfg.rulesFor("/anything").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalOnlyReturnsOneRule() {
|
||||
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||
.global(alwaysAllow(), keyer())
|
||||
.build();
|
||||
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/x");
|
||||
assertEquals(1, rules.size());
|
||||
assertEquals("global", rules.getFirst().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactPathTrumpsPrefixRule() {
|
||||
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||
.forPath("/a/b", alwaysAllow(), keyer())
|
||||
.forPrefix("/a/", alwaysAllow(), keyer())
|
||||
.build();
|
||||
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/a/b");
|
||||
assertEquals(1, rules.size());
|
||||
assertEquals("/a/b", rules.getFirst().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefixRuleMatchesWhenNoExact() {
|
||||
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||
.forPrefix("/api/", alwaysAllow(), keyer())
|
||||
.build();
|
||||
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/api/users");
|
||||
assertEquals(1, rules.size());
|
||||
assertEquals("/api/*", rules.getFirst().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void longerPrefixWinsOverShorter() {
|
||||
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||
.forPrefix("/api/", alwaysAllow(), keyer())
|
||||
.forPrefix("/api/v2/", alwaysAllow(), keyer())
|
||||
.build();
|
||||
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/api/v2/users");
|
||||
assertEquals(1, rules.size());
|
||||
assertEquals("/api/v2/*", rules.getFirst().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalIsAlwaysIncludedAlongsideMatchedRule() {
|
||||
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||
.global(alwaysAllow(), keyer())
|
||||
.forPath("/x", alwaysAllow(), keyer())
|
||||
.build();
|
||||
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/x");
|
||||
assertEquals(2, rules.size());
|
||||
assertEquals("global", rules.get(0).name());
|
||||
assertEquals("/x", rules.get(1).name());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import dev.coph.nextusweb.server.router.Response;
|
||||
import io.netty.handler.codec.http.DefaultHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import io.netty.handler.codec.http.HttpRequest;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RateLimitGateTest {
|
||||
|
||||
private HttpRequest req() {
|
||||
return new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/");
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkReturnsNullWhenNoRulesMatch() {
|
||||
RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder().build());
|
||||
assertNull(gate.check(req(), "/anything", "1.1.1.1"));
|
||||
gate.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAllowsWhenWithinLimit() {
|
||||
RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder()
|
||||
.global(new FixedWindowLimiter(2, 1000), KeyResolver.clientIp())
|
||||
.build());
|
||||
RateLimiter.Result r = gate.check(req(), "/x", "1.1.1.1");
|
||||
assertNotNull(r);
|
||||
assertTrue(r.allowed());
|
||||
gate.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkDeniesWhenAnyRuleDenies() {
|
||||
RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder()
|
||||
.global(new FixedWindowLimiter(1, 1000), KeyResolver.clientIp())
|
||||
.build());
|
||||
gate.check(req(), "/x", "1.1.1.1");
|
||||
RateLimiter.Result r = gate.check(req(), "/x", "1.1.1.1");
|
||||
assertNotNull(r);
|
||||
assertFalse(r.allowed());
|
||||
gate.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyHeadersIsNoOpForNull() {
|
||||
Response res = new Response();
|
||||
RateLimitGate.applyHeaders(null, res);
|
||||
assertNull(res.headers().get("X-RateLimit-Limit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyHeadersWritesLimitAndRemaining() {
|
||||
Response res = new Response();
|
||||
RateLimitGate.applyHeaders(RateLimiter.Result.allow(5, 10), res);
|
||||
assertEquals("10", res.headers().get("X-RateLimit-Limit"));
|
||||
assertEquals("5", res.headers().get("X-RateLimit-Remaining"));
|
||||
assertNull(res.headers().get("Retry-After"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyHeadersWritesRetryAfterWhenDenied() {
|
||||
Response res = new Response();
|
||||
RateLimitGate.applyHeaders(RateLimiter.Result.deny(10, 2500), res);
|
||||
assertEquals("3", res.headers().get("Retry-After"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyHeadersClampsNegativeRemainingToZero() {
|
||||
Response res = new Response();
|
||||
RateLimitGate.applyHeaders(new RateLimiter.Result(true, -5, 10, 0), res);
|
||||
assertEquals("0", res.headers().get("X-RateLimit-Remaining"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RateLimiterResultTest {
|
||||
|
||||
@Test
|
||||
void allowFactoryProducesAllowed() {
|
||||
RateLimiter.Result r = RateLimiter.Result.allow(5, 10);
|
||||
assertTrue(r.allowed());
|
||||
assertEquals(5, r.remaining());
|
||||
assertEquals(10, r.limit());
|
||||
assertEquals(0, r.retryAfterMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void denyFactoryProducesDenied() {
|
||||
RateLimiter.Result r = RateLimiter.Result.deny(10, 250);
|
||||
assertFalse(r.allowed());
|
||||
assertEquals(0, r.remaining());
|
||||
assertEquals(10, r.limit());
|
||||
assertEquals(250, r.retryAfterMillis());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SlidingWindowLimiterTest {
|
||||
|
||||
@Test
|
||||
void allowsUpToLimitThenDenies() {
|
||||
SlidingWindowLimiter lim = new SlidingWindowLimiter(2, 1000);
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
assertFalse(lim.tryAcquire("k", 0).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterFullWindowAllowsAgain() {
|
||||
SlidingWindowLimiter lim = new SlidingWindowLimiter(1, 100);
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
long twoWindows = 200L * 1_000_000L;
|
||||
assertTrue(lim.tryAcquire("k", twoWindows).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentKeysAreIndependent() {
|
||||
SlidingWindowLimiter lim = new SlidingWindowLimiter(1, 1000);
|
||||
assertTrue(lim.tryAcquire("a", 0).allowed());
|
||||
assertTrue(lim.tryAcquire("b", 0).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDoesNotThrow() {
|
||||
SlidingWindowLimiter lim = new SlidingWindowLimiter(1, 1000);
|
||||
lim.tryAcquire("k", System.nanoTime());
|
||||
assertDoesNotThrow(() -> lim.cleanup(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class TokenBucketLimiterTest {
|
||||
|
||||
@Test
|
||||
void burstUpToCapacityIsAllowed() {
|
||||
TokenBucketLimiter lim = new TokenBucketLimiter(1, 3);
|
||||
long now = 0;
|
||||
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyBucketIsDeniedAndRetryAfterIsPositive() {
|
||||
TokenBucketLimiter lim = new TokenBucketLimiter(1, 1);
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
RateLimiter.Result r = lim.tryAcquire("k", 0);
|
||||
assertFalse(r.allowed());
|
||||
assertTrue(r.retryAfterMillis() > 0);
|
||||
assertEquals(1, r.limit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refillAllowsAcquireAfterTime() {
|
||||
TokenBucketLimiter lim = new TokenBucketLimiter(10, 1);
|
||||
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||
assertFalse(lim.tryAcquire("k", 0).allowed());
|
||||
long oneSecLater = 1_000_000_000L;
|
||||
assertTrue(lim.tryAcquire("k", oneSecLater).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentKeysAreIndependent() {
|
||||
TokenBucketLimiter lim = new TokenBucketLimiter(1, 1);
|
||||
assertTrue(lim.tryAcquire("a", 0).allowed());
|
||||
assertTrue(lim.tryAcquire("b", 0).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDoesNotThrow() {
|
||||
TokenBucketLimiter lim = new TokenBucketLimiter(1, 1);
|
||||
lim.tryAcquire("k", System.nanoTime());
|
||||
assertDoesNotThrow(() -> lim.cleanup(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dev.coph.nextusweb.server.router;
|
||||
|
||||
import dev.coph.nextusweb.server.router.exception.BadRequestException;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import io.netty.util.CharsetUtil;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RequestTest {
|
||||
|
||||
private FullHttpRequest build(HttpMethod method, String uri, String body) {
|
||||
var content = body == null
|
||||
? Unpooled.EMPTY_BUFFER
|
||||
: Unpooled.copiedBuffer(body, CharsetUtil.UTF_8);
|
||||
return new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri, content);
|
||||
}
|
||||
|
||||
record Payload(String name, int age) {}
|
||||
|
||||
@Test
|
||||
void pathParamReturnsFromMap() {
|
||||
Request req = new Request(build(HttpMethod.GET, "/u/1", null), Map.of("id", "1"));
|
||||
assertEquals("1", req.pathParam("id"));
|
||||
assertNull(req.pathParam("missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryParamReturnsFirstValue() {
|
||||
Request req = new Request(build(HttpMethod.GET, "/?a=1&a=2&b=foo", null), Map.of());
|
||||
assertEquals("1", req.queryParam("a"));
|
||||
assertEquals("foo", req.queryParam("b"));
|
||||
assertNull(req.queryParam("nope"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryParamsReturnsAllValues() {
|
||||
Request req = new Request(build(HttpMethod.GET, "/?a=1&a=2", null), Map.of());
|
||||
assertEquals(java.util.List.of("1", "2"), req.queryParams("a"));
|
||||
assertTrue(req.queryParams("missing").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void headerReturnsValue() {
|
||||
FullHttpRequest raw = build(HttpMethod.GET, "/", null);
|
||||
raw.headers().set("X-Foo", "bar");
|
||||
Request req = new Request(raw, Map.of());
|
||||
assertEquals("bar", req.header("X-Foo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bodyReturnsContentAsString() {
|
||||
Request req = new Request(build(HttpMethod.POST, "/", "hello"), Map.of());
|
||||
assertEquals("hello", req.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void methodAndPathExpose() {
|
||||
Request req = new Request(build(HttpMethod.POST, "/a/b?q=1", null), Map.of());
|
||||
assertEquals(HttpMethod.POST, req.method());
|
||||
assertEquals("/a/b", req.path());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonReturnsNullNodeForEmptyBody() {
|
||||
Request req = new Request(build(HttpMethod.POST, "/", null), Map.of());
|
||||
var node = req.json();
|
||||
assertNotNull(node);
|
||||
assertTrue(node.isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonParsesObject() {
|
||||
Request req = new Request(build(HttpMethod.POST, "/", "{\"a\":1}"), Map.of());
|
||||
var node = req.json();
|
||||
assertTrue(node.has("a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonThrowsBadRequestOnInvalidJson() {
|
||||
Request req = new Request(build(HttpMethod.POST, "/", "not-json"), Map.of());
|
||||
assertThrows(BadRequestException.class, req::json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonAsDeserializes() {
|
||||
Request req = new Request(build(HttpMethod.POST, "/", "{\"name\":\"x\",\"age\":42}"), Map.of());
|
||||
Payload p = req.jsonAs(Payload.class);
|
||||
assertEquals("x", p.name());
|
||||
assertEquals(42, p.age());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonAsThrowsBadRequestOnInvalid() {
|
||||
Request req = new Request(build(HttpMethod.POST, "/", "not-json"), Map.of());
|
||||
assertThrows(BadRequestException.class, () -> req.jsonAs(Payload.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package dev.coph.nextusweb.server.router;
|
||||
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ResponseTest {
|
||||
|
||||
@Test
|
||||
void defaultStatusIs200AndEmptyBody() {
|
||||
Response res = new Response();
|
||||
assertEquals(200, res.status());
|
||||
assertArrayEquals(new byte[0], res.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusIsFluent() {
|
||||
Response res = new Response().status(404);
|
||||
assertEquals(404, res.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void headerSetsValue() {
|
||||
Response res = new Response().header("X-Foo", "bar");
|
||||
assertEquals("bar", res.headers().get("X-Foo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void textSetsBodyAndContentType() {
|
||||
Response res = new Response().text("hello");
|
||||
assertEquals("hello", new String(res.body(), StandardCharsets.UTF_8));
|
||||
assertTrue(res.headers().get(HttpHeaderNames.CONTENT_TYPE).startsWith("text/plain"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonStringSetsBodyAndContentType() {
|
||||
Response res = new Response().json("{\"a\":1}");
|
||||
assertEquals("{\"a\":1}", new String(res.body(), StandardCharsets.UTF_8));
|
||||
assertTrue(res.headers().get(HttpHeaderNames.CONTENT_TYPE).startsWith("application/json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonObjectSerializesValue() {
|
||||
Response res = new Response().json(Map.of("k", "v"));
|
||||
String s = new String(res.body(), StandardCharsets.UTF_8);
|
||||
assertTrue(s.contains("\"k\""));
|
||||
assertTrue(s.contains("\"v\""));
|
||||
assertTrue(res.headers().get(HttpHeaderNames.CONTENT_TYPE).startsWith("application/json"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package dev.coph.nextusweb.server.router;
|
||||
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RouterTest {
|
||||
|
||||
private final Router.Handler noop = (req, res) -> {};
|
||||
|
||||
@Test
|
||||
void getRegistersAndResolvesExactPath() {
|
||||
Router r = new Router().get("/hello", noop);
|
||||
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.GET, "/hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postPutDeleteRegister() {
|
||||
Router r = new Router()
|
||||
.post("/p", noop)
|
||||
.put("/u", noop)
|
||||
.delete("/d", noop);
|
||||
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.POST, "/p"));
|
||||
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.PUT, "/u"));
|
||||
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.DELETE, "/d"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notFoundForUnknownPath() {
|
||||
Router r = new Router().get("/a", noop);
|
||||
assertInstanceOf(Router.Resolution.NotFound.class, r.resolve(HttpMethod.GET, "/x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void methodNotAllowedWhenPathMatchesDifferentMethod() {
|
||||
Router r = new Router().get("/a", noop);
|
||||
Router.Resolution res = r.resolve(HttpMethod.POST, "/a");
|
||||
Router.Resolution.MethodNotAllowed mna = assertInstanceOf(Router.Resolution.MethodNotAllowed.class, res);
|
||||
assertTrue(mna.allowedMethods().contains(HttpMethod.GET));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathParamsAreExtracted() {
|
||||
Router r = new Router().get("/u/{id}", noop);
|
||||
Router.Resolution res = r.resolve(HttpMethod.GET, "/u/42");
|
||||
Router.Resolution.Match m = assertInstanceOf(Router.Resolution.Match.class, res);
|
||||
assertEquals("42", m.pathParams().get("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wildcardMatches() {
|
||||
Router r = new Router().get("/files/*", noop);
|
||||
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.GET, "/files/anything"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void useAddsMiddlewareReturned() {
|
||||
AtomicInteger count = new AtomicInteger();
|
||||
Router r = new Router().use((req, res) -> count.incrementAndGet());
|
||||
assertEquals(1, r.middlewares().size());
|
||||
r.middlewares().getFirst().accept(null, null);
|
||||
assertEquals(1, count.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerWorksWithCustomMethod() {
|
||||
Router r = new Router().register(HttpMethod.valueOf("OPTIONS"), "/x", noop);
|
||||
assertInstanceOf(Router.Resolution.Match.class,
|
||||
r.resolve(HttpMethod.valueOf("OPTIONS"), "/x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlerInvocationWorks() throws Exception {
|
||||
AtomicInteger called = new AtomicInteger();
|
||||
Router r = new Router().get("/x", (req, res) -> called.incrementAndGet());
|
||||
var match = (Router.Resolution.Match) r.resolve(HttpMethod.GET, "/x");
|
||||
match.handler().handle(null, null);
|
||||
assertEquals(1, called.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.coph.nextusweb.server.router.exception;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BadRequestExceptionTest {
|
||||
|
||||
@Test
|
||||
void messageIsCarried() {
|
||||
BadRequestException e = new BadRequestException("oops");
|
||||
assertEquals("oops", e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRuntimeException() {
|
||||
assertInstanceOf(RuntimeException.class, new BadRequestException("x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package dev.coph.nextusweb.server.websocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketConfigTest {
|
||||
|
||||
@Test
|
||||
void defaultsHasExpectedValues() {
|
||||
WebSocketConfig c = WebSocketConfig.defaults();
|
||||
assertEquals(65_536, c.maxFramePayloadLength());
|
||||
assertEquals(1_048_576, c.maxAggregatedMessageSize());
|
||||
assertEquals(Duration.ofSeconds(60), c.idleTimeout());
|
||||
assertFalse(c.allowAnyOrigin());
|
||||
assertTrue(c.allowedOrigins().isEmpty());
|
||||
assertNull(c.subprotocolsCsv());
|
||||
assertTrue(c.compression());
|
||||
assertFalse(c.checkStartsWith());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOriginAllowedRespectsList() {
|
||||
WebSocketConfig c = WebSocketConfig.builder()
|
||||
.allowedOrigins("https://a", "https://b")
|
||||
.build();
|
||||
assertTrue(c.isOriginAllowed("https://a"));
|
||||
assertTrue(c.isOriginAllowed("https://b"));
|
||||
assertFalse(c.isOriginAllowed("https://c"));
|
||||
assertFalse(c.isOriginAllowed(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anyOriginAllowsEverythingExceptNullCheck() {
|
||||
WebSocketConfig c = WebSocketConfig.builder().anyOrigin().build();
|
||||
assertTrue(c.allowAnyOrigin());
|
||||
assertTrue(c.isOriginAllowed("https://anything"));
|
||||
assertTrue(c.isOriginAllowed(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidFramePayloadLengthRejected() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> WebSocketConfig.builder().maxFramePayloadLength(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidAggregatedMessageSizeRejected() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> WebSocketConfig.builder().maxAggregatedMessageSize(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noIdleTimeoutSetsNull() {
|
||||
WebSocketConfig c = WebSocketConfig.builder().noIdleTimeout().build();
|
||||
assertNull(c.idleTimeout());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subprotocolsCsvJoins() {
|
||||
WebSocketConfig c = WebSocketConfig.builder().subprotocols("a", "b").build();
|
||||
String csv = c.subprotocolsCsv();
|
||||
assertNotNull(csv);
|
||||
assertTrue(csv.contains("a"));
|
||||
assertTrue(csv.contains("b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressionAndCheckStartsWithFlags() {
|
||||
WebSocketConfig c = WebSocketConfig.builder()
|
||||
.compression(false)
|
||||
.checkStartsWith(true)
|
||||
.build();
|
||||
assertFalse(c.compression());
|
||||
assertTrue(c.checkStartsWith());
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.coph.nextusweb.server.websocket;
|
||||
|
||||
import io.netty.channel.ChannelHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketFrameHandlerFactoryTest {
|
||||
|
||||
@Test
|
||||
void createReturnsChannelHandler() {
|
||||
ChannelHandler h = WebSocketFrameHandlerFactory.create(
|
||||
new WebSocketHandler() {},
|
||||
"/ws",
|
||||
Map.of("a", "b"));
|
||||
assertNotNull(h);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package dev.coph.nextusweb.server.websocket;
|
||||
|
||||
import io.netty.channel.DefaultChannelId;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketGroupTest {
|
||||
|
||||
private EmbeddedChannel uniqueChannel() {
|
||||
return new EmbeddedChannel(DefaultChannelId.newInstance());
|
||||
}
|
||||
|
||||
private WebSocketSession session(EmbeddedChannel ch) {
|
||||
return new WebSocketSession(ch, "/ws", Map.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultConstructorHasAnonymousName() {
|
||||
assertEquals("anonymous", new WebSocketGroup().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namedConstructorRetainsName() {
|
||||
assertEquals("chat", new WebSocketGroup("chat").name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAndRemoveAdjustSize() {
|
||||
WebSocketGroup g = new WebSocketGroup("g");
|
||||
EmbeddedChannel ch = uniqueChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
g.add(s);
|
||||
assertEquals(1, g.size());
|
||||
g.remove(s);
|
||||
assertEquals(0, g.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void broadcastSendsTextToAll() {
|
||||
WebSocketGroup g = new WebSocketGroup("g");
|
||||
EmbeddedChannel a = uniqueChannel();
|
||||
EmbeddedChannel b = uniqueChannel();
|
||||
g.add(session(a)).add(session(b));
|
||||
|
||||
g.broadcast("hi");
|
||||
a.runPendingTasks();
|
||||
b.runPendingTasks();
|
||||
Object fa = a.readOutbound();
|
||||
Object fb = b.readOutbound();
|
||||
TextWebSocketFrame ta = assertInstanceOf(TextWebSocketFrame.class, fa);
|
||||
TextWebSocketFrame tb = assertInstanceOf(TextWebSocketFrame.class, fb);
|
||||
assertEquals("hi", ta.text());
|
||||
assertEquals("hi", tb.text());
|
||||
ta.release();
|
||||
tb.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void broadcastJsonSendsTextFrames() {
|
||||
WebSocketGroup g = new WebSocketGroup("g");
|
||||
EmbeddedChannel a = uniqueChannel();
|
||||
g.add(session(a));
|
||||
|
||||
g.broadcastJson(Map.of("k", "v"));
|
||||
a.runPendingTasks();
|
||||
Object out = a.readOutbound();
|
||||
TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out);
|
||||
assertTrue(frame.text().contains("\"k\""));
|
||||
frame.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void broadcastBinarySendsBinaryFrames() {
|
||||
WebSocketGroup g = new WebSocketGroup("g");
|
||||
EmbeddedChannel a = uniqueChannel();
|
||||
g.add(session(a));
|
||||
|
||||
g.broadcastBinary(new byte[]{1, 2, 3});
|
||||
a.runPendingTasks();
|
||||
Object out = a.readOutbound();
|
||||
BinaryWebSocketFrame frame = assertInstanceOf(BinaryWebSocketFrame.class, out);
|
||||
assertEquals(3, frame.content().readableBytes());
|
||||
frame.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void broadcastExceptSkipsExcludedSession() {
|
||||
WebSocketGroup g = new WebSocketGroup("g");
|
||||
EmbeddedChannel a = uniqueChannel();
|
||||
EmbeddedChannel b = uniqueChannel();
|
||||
WebSocketSession sa = session(a);
|
||||
WebSocketSession sb = session(b);
|
||||
g.add(sa).add(sb);
|
||||
|
||||
g.broadcastExcept(sa, "hello");
|
||||
a.runPendingTasks();
|
||||
b.runPendingTasks();
|
||||
assertNull(a.readOutbound());
|
||||
Object out = b.readOutbound();
|
||||
TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out);
|
||||
assertEquals("hello", frame.text());
|
||||
frame.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeAllClosesUnderlyingChannels() {
|
||||
WebSocketGroup g = new WebSocketGroup("g");
|
||||
EmbeddedChannel a = uniqueChannel();
|
||||
g.add(session(a));
|
||||
g.closeAll();
|
||||
a.runPendingTasks();
|
||||
assertFalse(a.isActive());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fluentMethodsReturnGroup() {
|
||||
WebSocketGroup g = new WebSocketGroup("g");
|
||||
EmbeddedChannel a = uniqueChannel();
|
||||
WebSocketSession s = session(a);
|
||||
assertSame(g, g.add(s));
|
||||
assertSame(g, g.broadcast("x"));
|
||||
assertSame(g, g.broadcastBinary(new byte[]{1}));
|
||||
assertSame(g, g.broadcastJson(Map.of("a", 1)));
|
||||
assertSame(g, g.broadcastExcept(null, "y"));
|
||||
assertSame(g, g.remove(s));
|
||||
assertSame(g, g.closeAll());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.coph.nextusweb.server.websocket;
|
||||
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketHandlerTest {
|
||||
|
||||
@Test
|
||||
void defaultMethodsDoNotThrow() {
|
||||
WebSocketHandler handler = new WebSocketHandler() {};
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession session = new WebSocketSession(ch, "/ws", Map.of());
|
||||
assertDoesNotThrow(() -> handler.onOpen(session));
|
||||
assertDoesNotThrow(() -> handler.onMessage(session, "msg"));
|
||||
assertDoesNotThrow(() -> handler.onBinary(session, new byte[]{1}));
|
||||
assertDoesNotThrow(() -> handler.onClose(session, 1000, "ok"));
|
||||
assertDoesNotThrow(() -> handler.onError(session, new RuntimeException("e")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.coph.nextusweb.server.websocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketRouterTest {
|
||||
|
||||
private final WebSocketHandler handler = new WebSocketHandler() {};
|
||||
|
||||
@Test
|
||||
void resolvesExactPath() {
|
||||
WebSocketRouter r = new WebSocketRouter().on("/ws", handler);
|
||||
WebSocketRouter.Resolution res = r.resolve("/ws");
|
||||
assertNotNull(res);
|
||||
assertSame(handler, res.handler());
|
||||
assertTrue(res.pathParams().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullForUnknown() {
|
||||
WebSocketRouter r = new WebSocketRouter().on("/ws", handler);
|
||||
assertNull(r.resolve("/missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsPathParameters() {
|
||||
WebSocketRouter r = new WebSocketRouter().on("/rooms/{id}", handler);
|
||||
WebSocketRouter.Resolution res = r.resolve("/rooms/abc");
|
||||
assertNotNull(res);
|
||||
assertEquals("abc", res.pathParams().get("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void onIsFluent() {
|
||||
WebSocketRouter r = new WebSocketRouter();
|
||||
assertSame(r, r.on("/x", handler));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interiorNodeWithoutHandlerReturnsNull() {
|
||||
WebSocketRouter r = new WebSocketRouter().on("/a/b", handler);
|
||||
assertNull(r.resolve("/a"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package dev.coph.nextusweb.server.websocket;
|
||||
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.PingWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||
import io.netty.util.CharsetUtil;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketSessionTest {
|
||||
|
||||
private WebSocketSession session(EmbeddedChannel ch) {
|
||||
return new WebSocketSession(ch, "/ws/{id}", Map.of("id", "42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void idIsAssignedAndNonNull() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
assertNotNull(s.id());
|
||||
assertFalse(s.id().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathAndPathParamExpose() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
assertEquals("/ws/{id}", s.path());
|
||||
assertEquals("42", s.pathParam("id"));
|
||||
assertNull(s.pathParam("missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOpenWhileChannelActive() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
assertTrue(s.isOpen());
|
||||
ch.close();
|
||||
assertFalse(s.isOpen());
|
||||
}
|
||||
|
||||
@Test
|
||||
void channelGetterReturnsChannel() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
assertSame(ch, s.channel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void attributesSetAndRetrieve() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
s.attribute("k", "v");
|
||||
assertEquals("v", s.<String>attribute("k"));
|
||||
s.attribute("k", null);
|
||||
assertNull(s.<String>attribute("k"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendWritesTextFrame() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
s.send("hi");
|
||||
Object out = ch.readOutbound();
|
||||
TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out);
|
||||
assertEquals("hi", frame.text());
|
||||
frame.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendJsonProducesTextFrame() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
s.sendJson(Map.of("a", "b"));
|
||||
Object out = ch.readOutbound();
|
||||
TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out);
|
||||
String payload = frame.content().toString(CharsetUtil.UTF_8);
|
||||
assertTrue(payload.contains("\"a\""));
|
||||
frame.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendBinaryProducesBinaryFrame() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
s.sendBinary(new byte[]{1, 2, 3});
|
||||
Object out = ch.readOutbound();
|
||||
BinaryWebSocketFrame frame = assertInstanceOf(BinaryWebSocketFrame.class, out);
|
||||
assertEquals(3, frame.content().readableBytes());
|
||||
frame.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pingProducesPingFrame() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
s.ping();
|
||||
Object out = ch.readOutbound();
|
||||
assertInstanceOf(PingWebSocketFrame.class, out);
|
||||
((PingWebSocketFrame) out).release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeProducesCloseFrameAndClosesChannel() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
s.close(1001, "going-away");
|
||||
Object out = ch.readOutbound();
|
||||
CloseWebSocketFrame frame = assertInstanceOf(CloseWebSocketFrame.class, out);
|
||||
assertEquals(1001, frame.statusCode());
|
||||
assertEquals("going-away", frame.reasonText());
|
||||
frame.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendOnInactiveChannelDoesNotThrow() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
ch.close();
|
||||
assertDoesNotThrow(() -> s.send("ignored"));
|
||||
assertDoesNotThrow(() -> s.sendBinary(new byte[]{1}));
|
||||
assertDoesNotThrow(() -> s.ping());
|
||||
assertDoesNotThrow(() -> s.sendJson(Map.of("a", 1)));
|
||||
assertDoesNotThrow(() -> s.close());
|
||||
}
|
||||
|
||||
@Test
|
||||
void remoteAddressReturnsNullForUnconnectedEmbeddedChannel() {
|
||||
EmbeddedChannel ch = new EmbeddedChannel();
|
||||
WebSocketSession s = session(ch);
|
||||
assertDoesNotThrow(s::remoteAddress);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user