Introduce authentication framework with AuthConfig, AuthGate, and Authenticator classes, alongside comprehensive tests for rules, modes, and schemes.
CI - Test, Publish and Release / run-tests (push) Successful in 18s
CI - Test, Publish and Release / create-release (push) Successful in 20s
CI - Test, Publish and Release / check-and-publish (push) Successful in 18s

This commit is contained in:
CodingPhoenixx
2026-05-29 13:22:31 +02:00
parent d9b639a539
commit bcf5572aeb
39 changed files with 2629 additions and 326 deletions
@@ -0,0 +1,126 @@
package dev.coph.nextusweb.server.auth;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AuthConfigTest {
/** A distinct authenticator instance, identifiable by reference, that authenticates nobody. */
private Authenticator marker() {
return req -> null;
}
@Test
void ruleForReturnsNullWhenUnprotected() {
AuthConfig cfg = AuthConfig.builder(marker())
.protect("/admin")
.build();
assertNull(cfg.ruleFor("/public"));
}
@Test
void exactPathBeatsPrefix() {
Authenticator exactAuth = marker();
Authenticator prefixAuth = marker();
AuthConfig cfg = AuthConfig.builder(marker())
.protect("/api/health", exactAuth)
.protectPrefix("/api/", prefixAuth)
.build();
AuthConfig.Rule rule = cfg.ruleFor("/api/health");
assertNotNull(rule);
assertSame(exactAuth, rule.authenticator());
}
@Test
void longerPrefixWins() {
Authenticator shortAuth = marker();
Authenticator longAuth = marker();
AuthConfig cfg = AuthConfig.builder(marker())
.protectPrefix("/api/", shortAuth)
.protectPrefix("/api/v2/", longAuth)
.build();
assertSame(longAuth, cfg.ruleFor("/api/v2/users").authenticator());
assertSame(shortAuth, cfg.ruleFor("/api/v1/users").authenticator());
}
@Test
void protectUsesRequiredMode() {
AuthConfig cfg = AuthConfig.builder(marker()).protect("/admin").build();
assertEquals(AuthConfig.Mode.REQUIRED, cfg.ruleFor("/admin").mode());
}
@Test
void optionalUsesOptionalMode() {
AuthConfig cfg = AuthConfig.builder(marker()).optional("/feed").build();
assertEquals(AuthConfig.Mode.OPTIONAL, cfg.ruleFor("/feed").mode());
}
@Test
void requireEverywhereAppliesGlobalRequiredRule() {
AuthConfig cfg = AuthConfig.builder(marker())
.requireEverywhere()
.build();
AuthConfig.Rule rule = cfg.ruleFor("/anything");
assertNotNull(rule);
assertEquals(AuthConfig.Mode.REQUIRED, rule.mode());
}
@Test
void optionalEverywhereAppliesGlobalOptionalRule() {
AuthConfig cfg = AuthConfig.builder(marker())
.optionalEverywhere()
.build();
AuthConfig.Rule rule = cfg.ruleFor("/anything");
assertNotNull(rule);
assertEquals(AuthConfig.Mode.OPTIONAL, rule.mode());
}
@Test
void specificRuleBeatsGlobal() {
Authenticator specific = marker();
AuthConfig cfg = AuthConfig.builder(marker())
.optionalEverywhere()
.protect("/admin", specific)
.build();
AuthConfig.Rule adminRule = cfg.ruleFor("/admin");
assertEquals(AuthConfig.Mode.REQUIRED, adminRule.mode());
assertSame(specific, adminRule.authenticator());
// Everything else still falls through to the optional global rule.
assertEquals(AuthConfig.Mode.OPTIONAL, cfg.ruleFor("/other").mode());
}
@Test
void defaultAuthenticatorUsedWhenNotOverridden() {
Authenticator def = marker();
AuthConfig cfg = AuthConfig.builder(def)
.protect("/admin")
.protectPrefix("/api/")
.build();
assertSame(def, cfg.ruleFor("/admin").authenticator());
assertSame(def, cfg.ruleFor("/api/x").authenticator());
}
@Test
void challengeIsStored() {
AuthConfig cfg = AuthConfig.builder(marker())
.protect("/admin")
.challenge("Basic realm=\"api\"")
.build();
assertEquals("Basic realm=\"api\"", cfg.challenge());
}
@Test
void challengeDefaultsToNull() {
AuthConfig cfg = AuthConfig.builder(marker()).build();
assertNull(cfg.challenge());
}
@Test
void builderRejectsNullDefaultAuthenticator() {
assertThrows(NullPointerException.class, () -> AuthConfig.builder(null));
}
}
@@ -0,0 +1,147 @@
package dev.coph.nextusweb.server.auth;
import dev.coph.nextusweb.server.router.Request;
import dev.coph.nextusweb.server.router.Response;
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 org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class AuthGateTest {
private Request request(String apiKey) {
FullHttpRequest raw = new DefaultFullHttpRequest(
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
if (apiKey != null) raw.headers().set("X-API-Key", apiKey);
return new Request(raw, Map.of());
}
private Authenticator apiKeyAuth() {
return Authenticator.apiKey("X-API-Key",
key -> key.equals("valid") ? Principal.of("user-1") : null);
}
@Test
void unprotectedPathProceedsWithoutPrincipal() {
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
.protectPrefix("/admin")
.build());
Request req = request(null);
assertNull(gate.authenticate(req, "/public"));
assertNull(req.principal());
}
@Test
void protectedPathRejectsMissingCredentials() {
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
.protectPrefix("/admin")
.build());
Response rejection = gate.authenticate(request(null), "/admin/users");
assertNotNull(rejection);
assertEquals(401, rejection.status());
}
@Test
void protectedPathRejectsInvalidCredentials() {
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
.protect("/admin")
.build());
Response rejection = gate.authenticate(request("wrong"), "/admin");
assertNotNull(rejection);
assertEquals(401, rejection.status());
}
@Test
void protectedPathAttachesPrincipalOnSuccess() {
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
.protect("/admin")
.build());
Request req = request("valid");
assertNull(gate.authenticate(req, "/admin"));
assertNotNull(req.principal());
assertEquals("user-1", req.principal().id());
}
@Test
void optionalPathProceedsAnonymouslyButAttachesWhenPresent() {
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
.optionalPrefix("/feed")
.build());
Request anon = request(null);
assertNull(gate.authenticate(anon, "/feed"));
assertNull(anon.principal());
Request authed = request("valid");
assertNull(gate.authenticate(authed, "/feed"));
assertEquals("user-1", authed.principal().id());
}
@Test
void challengeHeaderAddedToUnauthorized() {
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
.protect("/admin")
.challenge("ApiKey realm=\"api\"")
.build());
Response rejection = gate.authenticate(request(null), "/admin");
assertEquals("ApiKey realm=\"api\"", rejection.headers().get("WWW-Authenticate"));
}
@Test
void authenticatorErrorYields500() {
Authenticator boom = req -> {
throw new IllegalStateException("db down");
};
AuthGate gate = new AuthGate(AuthConfig.builder(boom).protect("/admin").build());
Response rejection = gate.authenticate(request(null), "/admin");
assertNotNull(rejection);
assertEquals(500, rejection.status());
}
@Test
void exactPathAuthenticatorIsUsedOverPrefix() {
// The prefix authenticator never authenticates; the exact-path one accepts the "valid"
// key. The exact rule must win so the request on the exact path can succeed.
Authenticator prefixDeny = req -> null;
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
.protect("/api/health", apiKeyAuth())
.protectPrefix("/api/", prefixDeny)
.build());
Request exact = request("valid");
assertNull(gate.authenticate(exact, "/api/health"));
assertEquals("user-1", exact.principal().id());
// A sibling path under the prefix uses the (always-denying) prefix authenticator.
Response rejection = gate.authenticate(request("valid"), "/api/other");
assertNotNull(rejection);
assertEquals(401, rejection.status());
}
@Test
void requireEverywhereRejectsAnyPathWithoutCredentials() {
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
.requireEverywhere()
.build());
assertEquals(401, gate.authenticate(request(null), "/whatever").status());
assertNull(gate.authenticate(request("valid"), "/whatever"));
}
@Test
void anyOfAuthenticatorAcceptsEitherCredential() {
Authenticator combined = Authenticator.anyOf(
apiKeyAuth(),
Authenticator.cookie("sid", s -> s.equals("sess") ? Principal.of("cookie-user") : null));
AuthGate gate = new AuthGate(AuthConfig.builder(combined).protect("/admin").build());
Request viaKey = request("valid");
assertNull(gate.authenticate(viaKey, "/admin"));
assertEquals("user-1", viaKey.principal().id());
}
}
@@ -0,0 +1,119 @@
package dev.coph.nextusweb.server.auth;
import dev.coph.nextusweb.server.router.Request;
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 org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class AuthenticatorTest {
private Request request(String header, String value) {
FullHttpRequest raw = new DefaultFullHttpRequest(
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
if (header != null) raw.headers().set(header, value);
return new Request(raw, Map.of());
}
@Test
void apiKeyResolvesViaValidator() throws Exception {
Authenticator auth = Authenticator.apiKey("X-API-Key",
key -> key.equals("good") ? Principal.of("svc") : null);
assertEquals("svc", auth.authenticate(request("X-API-Key", "good")).id());
assertNull(auth.authenticate(request("X-API-Key", "bad")));
assertNull(auth.authenticate(request(null, null)));
}
@Test
void cookieResolvesViaValidator() throws Exception {
Authenticator auth = Authenticator.cookie("sid",
sid -> sid.equals("abc") ? Principal.of("u1") : null);
assertEquals("u1", auth.authenticate(request("Cookie", "sid=abc")).id());
assertNull(auth.authenticate(request("Cookie", "sid=zzz")));
assertNull(auth.authenticate(request(null, null)));
}
@Test
void basicDecodesCredentials() throws Exception {
Authenticator auth = Authenticator.basic(
(user, pass) -> user.equals("alice") && pass.equals("s3cret") ? Principal.of(user) : null);
String header = "Basic " + Base64.getEncoder()
.encodeToString("alice:s3cret".getBytes(StandardCharsets.UTF_8));
assertEquals("alice", auth.authenticate(request("Authorization", header)).id());
String wrong = "Basic " + Base64.getEncoder()
.encodeToString("alice:nope".getBytes(StandardCharsets.UTF_8));
assertNull(auth.authenticate(request("Authorization", wrong)));
assertNull(auth.authenticate(request("Authorization", "Basic not-base64!!")));
assertNull(auth.authenticate(request(null, null)));
}
@Test
void anyOfReturnsFirstMatch() throws Exception {
Authenticator key = Authenticator.apiKey("X-API-Key",
k -> k.equals("k") ? Principal.of("byKey") : null);
Authenticator cookie = Authenticator.cookie("sid",
s -> Principal.of("byCookie"));
Authenticator combined = Authenticator.anyOf(key, cookie);
assertEquals("byKey", combined.authenticate(request("X-API-Key", "k")).id());
assertEquals("byCookie", combined.authenticate(request("Cookie", "sid=x")).id());
assertNull(combined.authenticate(request(null, null)));
}
@Test
void bearerDecodesToken() throws Exception {
Authenticator auth = Authenticator.bearer(
token -> token.equals("tok123") ? Principal.of("u") : null);
assertEquals("u", auth.authenticate(request("Authorization", "Bearer tok123")).id());
assertNull(auth.authenticate(request("Authorization", "Bearer wrong")));
assertNull(auth.authenticate(request("Authorization", "Bearer ")));
assertNull(auth.authenticate(request("Authorization", "Basic abc")));
assertNull(auth.authenticate(request(null, null)));
}
@Test
void authSchemesAreCaseInsensitive() throws Exception {
Authenticator bearer = Authenticator.bearer(t -> Principal.of("b"));
assertEquals("b", bearer.authenticate(request("Authorization", "bearer tok")).id());
Authenticator basic = Authenticator.basic((u, p) -> Principal.of(u));
String header = "basic " + Base64.getEncoder()
.encodeToString("alice:pw".getBytes(StandardCharsets.UTF_8));
assertEquals("alice", basic.authenticate(request("Authorization", header)).id());
}
@Test
void basicReturnsNullWhenNoColon() throws Exception {
Authenticator auth = Authenticator.basic((u, p) -> Principal.of(u));
String header = "Basic " + Base64.getEncoder()
.encodeToString("nocolon".getBytes(StandardCharsets.UTF_8));
assertNull(auth.authenticate(request("Authorization", header)));
}
@Test
void basicAllowsEmptyPassword() throws Exception {
Authenticator auth = Authenticator.basic((u, p) -> Principal.of(u + ":" + p));
String header = "Basic " + Base64.getEncoder()
.encodeToString("user:".getBytes(StandardCharsets.UTF_8));
assertEquals("user:", auth.authenticate(request("Authorization", header)).id());
}
@Test
void apiKeyEmptyHeaderTreatedAsAbsent() throws Exception {
Authenticator auth = Authenticator.apiKey("X-API-Key", k -> Principal.of("never"));
assertNull(auth.authenticate(request("X-API-Key", "")));
}
}
@@ -0,0 +1,61 @@
package dev.coph.nextusweb.server.auth;
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class PrincipalTest {
@Test
void ofIdOnlyHasNoRolesOrClaims() {
Principal p = Principal.of("user-1");
assertEquals("user-1", p.id());
assertTrue(p.roles().isEmpty());
assertTrue(p.claims().isEmpty());
assertFalse(p.hasRole("admin"));
}
@Test
void ofWithRolesExposesRolesAndHasRole() {
Principal p = Principal.of("user-2", Set.of("admin", "ops"));
assertEquals("user-2", p.id());
assertEquals(Set.of("admin", "ops"), p.roles());
assertTrue(p.hasRole("admin"));
assertTrue(p.hasRole("ops"));
assertFalse(p.hasRole("guest"));
}
@Test
void rolesAreDefensivelyCopiedAndImmutable() {
Set<String> source = new HashSet<>(Set.of("admin"));
Principal p = Principal.of("user-3", source);
// Mutating the source after construction must not affect the principal.
source.add("sneaky");
assertEquals(Set.of("admin"), p.roles());
// The exposed set must be unmodifiable.
assertThrows(UnsupportedOperationException.class, () -> p.roles().add("x"));
}
@Test
void customImplementationIsSupported() {
Principal custom = new Principal() {
@Override
public String id() {
return "svc";
}
@Override
public Set<String> roles() {
return Set.of("service");
}
};
assertEquals("svc", custom.id());
assertTrue(custom.hasRole("service"));
assertTrue(custom.claims().isEmpty());
}
}
@@ -0,0 +1,56 @@
package dev.coph.nextusweb.server.net;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ClientIpTest {
@Test
void usesSocketIpWhenNoForwardedHeader() {
assertEquals("203.0.113.5",
ClientIp.resolve("203.0.113.5", null, TrustedProxies.all()));
}
@Test
void ignoresForwardedHeaderWhenPeerNotTrusted() {
// A direct (untrusted) client cannot spoof its IP via X-Forwarded-For.
assertEquals("203.0.113.5",
ClientIp.resolve("203.0.113.5", "1.2.3.4", TrustedProxies.none()));
}
@Test
void usesForwardedHeaderWhenPeerTrusted() {
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
assertEquals("1.2.3.4",
ClientIp.resolve("10.0.0.1", "1.2.3.4", trusted));
}
@Test
void returnsFirstUntrustedHopFromTheRight() {
// Chain: realClient, edgeProxy, internalProxy(=peer). Both proxies are trusted, so the
// resolved client is the first untrusted entry walking from the right.
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
String xff = "9.9.9.9, 10.0.0.9, 10.0.0.8";
assertEquals("9.9.9.9",
ClientIp.resolve("10.0.0.8", xff, trusted));
}
@Test
void spoofedLeadingEntriesAreIgnored() {
// Attacker prepends a fake hop; since the genuine client hop (8.8.8.8) is the first
// untrusted from the right, the forged "1.1.1.1" is never returned.
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
String xff = "1.1.1.1, 8.8.8.8, 10.0.0.8";
assertEquals("8.8.8.8",
ClientIp.resolve("10.0.0.8", xff, trusted));
}
@Test
void allHopsTrustedFallsBackToLeftmost() {
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
String xff = "10.0.0.7, 10.0.0.8";
assertEquals("10.0.0.7",
ClientIp.resolve("10.0.0.8", xff, trusted));
}
}
@@ -0,0 +1,64 @@
package dev.coph.nextusweb.server.net;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class TrustedProxiesTest {
@Test
void noneTrustsNothing() {
TrustedProxies tp = TrustedProxies.none();
assertFalse(tp.isTrusted("127.0.0.1"));
assertFalse(tp.isTrusted("10.0.0.1"));
}
@Test
void allTrustsEverything() {
TrustedProxies tp = TrustedProxies.all();
assertTrue(tp.isTrusted("8.8.8.8"));
assertTrue(tp.isTrusted("::1"));
}
@Test
void matchesIpv4Cidr() {
TrustedProxies tp = TrustedProxies.of("10.0.0.0/8");
assertTrue(tp.isTrusted("10.1.2.3"));
assertTrue(tp.isTrusted("10.255.255.255"));
assertFalse(tp.isTrusted("11.0.0.1"));
assertFalse(tp.isTrusted("192.168.0.1"));
}
@Test
void matchesBareHostAsSingleAddress() {
TrustedProxies tp = TrustedProxies.of("127.0.0.1");
assertTrue(tp.isTrusted("127.0.0.1"));
assertFalse(tp.isTrusted("127.0.0.2"));
}
@Test
void matchesIpv6Cidr() {
TrustedProxies tp = TrustedProxies.of("fd00::/8");
assertTrue(tp.isTrusted("fd12:3456::1"));
assertFalse(tp.isTrusted("fe80::1"));
}
@Test
void differentFamilyDoesNotMatch() {
TrustedProxies tp = TrustedProxies.of("10.0.0.0/8");
assertFalse(tp.isTrusted("::1"));
}
@Test
void invalidAddressIsNotTrusted() {
TrustedProxies tp = TrustedProxies.of("10.0.0.0/8");
assertFalse(tp.isTrusted("not-an-ip"));
assertFalse(tp.isTrusted(null));
}
@Test
void rejectsInvalidCidr() {
assertThrows(IllegalArgumentException.class, () -> TrustedProxies.of("10.0.0.0/40"));
assertThrows(IllegalArgumentException.class, () -> TrustedProxies.of("garbage"));
}
}
@@ -1,53 +1,70 @@
package dev.coph.nextusweb.server.ratelimit;
import io.netty.handler.codec.http.DefaultHttpRequest;
import dev.coph.nextusweb.server.auth.Principal;
import dev.coph.nextusweb.server.router.Request;
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.HttpRequest;
import io.netty.handler.codec.http.HttpVersion;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.Set;
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;
private Request request() {
FullHttpRequest raw = new DefaultFullHttpRequest(
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
return new Request(raw, Map.of());
}
private Request requestWith(String header, String value) {
FullHttpRequest raw = new DefaultFullHttpRequest(
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
raw.headers().set(header, value);
return new Request(raw, Map.of());
}
@Test
void clientIpUsesRemoteWhenNoForwardedHeader() {
assertEquals("10.0.0.1", KeyResolver.clientIp().resolve(req(null, null), "10.0.0.1"));
void clientIpReturnsResolvedIpVerbatim() {
assertEquals("10.0.0.1", KeyResolver.clientIp().resolve(request(), "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"));
void headerResolverUsesHeaderValue() {
Request r = requestWith("X-API-Key", "secret123");
assertEquals("h:secret123", KeyResolver.header("X-API-Key").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"));
void headerResolverFallsBackToClientIp() {
assertEquals("ip:10.0.0.1", KeyResolver.header("X-API-Key").resolve(request(), "10.0.0.1"));
}
@Test
void userOrIpReturnsBearerToken() {
HttpRequest r = req("Authorization", "Bearer abc123");
assertEquals("u:abc123", KeyResolver.userOrIp().resolve(r, "10.0.0.1"));
void cookieResolverUsesCookieValue() {
Request r = requestWith("Cookie", "sid=abc; other=x");
assertEquals("c:abc", KeyResolver.cookie("sid").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"));
void cookieResolverFallsBackToClientIp() {
assertEquals("ip:10.0.0.1", KeyResolver.cookie("sid").resolve(request(), "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"));
void principalResolverUsesPrincipalId() {
Request r = request();
r.principal(Principal.of("user-42", Set.of("admin")));
assertEquals("p:user-42", KeyResolver.principal().resolve(r, "10.0.0.1"));
}
@Test
void principalResolverFallsBackToClientIpWhenAnonymous() {
assertEquals("ip:10.0.0.1", KeyResolver.principal().resolve(request(), "10.0.0.1"));
}
}
@@ -75,4 +75,30 @@ class RateLimitConfigTest {
assertEquals("global", rules.get(0).name());
assertEquals("/x", rules.get(1).name());
}
@Test
void allLimitersCollectsEveryDistinctLimiter() {
// Distinct concrete instances (non-capturing lambdas would be the same JVM singleton).
RateLimiter a = new FixedWindowLimiter(1, 1000);
RateLimiter b = new FixedWindowLimiter(1, 1000);
RateLimiter c = new FixedWindowLimiter(1, 1000);
RateLimitConfig cfg = RateLimitConfig.builder()
.global(a, keyer())
.forPath("/x", b, keyer())
.forPrefix("/api/", c, keyer())
.build();
assertEquals(3, cfg.allLimiters().size());
assertTrue(cfg.allLimiters().containsAll(List.of(a, b, c)));
}
@Test
void allLimitersDeduplicatesSharedInstance() {
RateLimiter shared = alwaysAllow();
RateLimitConfig cfg = RateLimitConfig.builder()
.global(shared, keyer())
.forPath("/x", shared, keyer())
.forPrefix("/api/", shared, keyer())
.build();
assertEquals(1, cfg.allLimiters().size());
}
}
@@ -1,18 +1,24 @@
package dev.coph.nextusweb.server.ratelimit;
import dev.coph.nextusweb.server.router.Request;
import dev.coph.nextusweb.server.router.Response;
import io.netty.handler.codec.http.DefaultHttpRequest;
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.HttpRequest;
import io.netty.handler.codec.http.HttpVersion;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class RateLimitGateTest {
private HttpRequest req() {
return new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/");
private Request req() {
FullHttpRequest raw = new DefaultFullHttpRequest(
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
return new Request(raw, Map.of());
}
@Test
@@ -1,5 +1,6 @@
package dev.coph.nextusweb.server.router;
import dev.coph.nextusweb.server.auth.Principal;
import dev.coph.nextusweb.server.router.exception.BadRequestException;
import io.netty.buffer.Unpooled;
import io.netty.handler.codec.http.DefaultFullHttpRequest;
@@ -101,4 +102,48 @@ class RequestTest {
Request req = new Request(build(HttpMethod.POST, "/", "not-json"), Map.of());
assertThrows(BadRequestException.class, () -> req.jsonAs(Payload.class));
}
@Test
void cookieParsesNamedCookie() {
FullHttpRequest raw = build(HttpMethod.GET, "/", null);
raw.headers().set("Cookie", "sid=abc123; theme=dark");
Request req = new Request(raw, Map.of());
assertEquals("abc123", req.cookie("sid"));
assertEquals("dark", req.cookie("theme"));
assertNull(req.cookie("missing"));
}
@Test
void cookieReturnsNullWhenNoCookieHeader() {
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
assertNull(req.cookie("sid"));
}
@Test
void attributesSetGetAndRemove() {
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
assertNull(req.attribute("k"));
req.attribute("k", "v");
assertEquals("v", req.<String>attribute("k"));
req.attribute("k", null);
assertNull(req.attribute("k"));
}
@Test
void clientIpRoundTrips() {
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
assertNull(req.clientIp());
req.clientIp("203.0.113.9");
assertEquals("203.0.113.9", req.clientIp());
}
@Test
void principalRoundTripsAndDrivesIsAuthenticated() {
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
assertFalse(req.isAuthenticated());
assertNull(req.principal());
req.principal(Principal.of("user-7"));
assertTrue(req.isAuthenticated());
assertEquals("user-7", req.principal().id());
}
}
@@ -0,0 +1,22 @@
package dev.coph.nextusweb.server.tls;
import org.junit.jupiter.api.Test;
import java.io.File;
import static org.junit.jupiter.api.Assertions.*;
class TlsConfigTest {
@Test
void fromPemWithMissingFilesThrowsIllegalState() {
File missingCert = new File("does-not-exist-cert.pem");
File missingKey = new File("does-not-exist-key.pem");
assertThrows(IllegalStateException.class, () -> TlsConfig.fromPem(missingCert, missingKey));
}
@Test
void fromSslContextRejectsNull() {
assertThrows(NullPointerException.class, () -> TlsConfig.fromSslContext(null));
}
}
@@ -17,7 +17,7 @@ class WebSocketGroupTest {
}
private WebSocketSession session(EmbeddedChannel ch) {
return new WebSocketSession(ch, "/ws", Map.of());
return new WebSocketSession(ch, "/ws", Map.of(), null);
}
@Test
@@ -13,7 +13,7 @@ class WebSocketHandlerTest {
void defaultMethodsDoNotThrow() {
WebSocketHandler handler = new WebSocketHandler() {};
EmbeddedChannel ch = new EmbeddedChannel();
WebSocketSession session = new WebSocketSession(ch, "/ws", Map.of());
WebSocketSession session = new WebSocketSession(ch, "/ws", Map.of(), null);
assertDoesNotThrow(() -> handler.onOpen(session));
assertDoesNotThrow(() -> handler.onMessage(session, "msg"));
assertDoesNotThrow(() -> handler.onBinary(session, new byte[]{1}));
@@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.*;
class WebSocketSessionTest {
private WebSocketSession session(EmbeddedChannel ch) {
return new WebSocketSession(ch, "/ws/{id}", Map.of("id", "42"));
return new WebSocketSession(ch, "/ws/{id}", Map.of("id", "42"), null);
}
@Test