package com.xfestudio.xfeservermanager.infra.http;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.google.gson.JsonParser;
import com.xfestudio.xfeservermanager.api.audit.AuditEvent;
import com.xfestudio.xfeservermanager.api.audit.AuditOutcome;
import com.xfestudio.xfeservermanager.core.player.PlayerOperationException;
import com.xfestudio.xfeservermanager.core.economy.EconomyConflictException;
import com.xfestudio.xfeservermanager.api.operation.OperationStatus;
import com.xfestudio.xfeservermanager.core.operation.InvalidOperationTransitionException;
import com.xfestudio.xfeservermanager.core.operation.OperationNotFoundException;
import com.xfestudio.xfeservermanager.core.policy.InvalidPolicyException;
import com.xfestudio.xfeservermanager.core.policy.NoPolicyDraftException;
import com.xfestudio.xfeservermanager.core.policy.PolicyDraftConflictException;
import com.xfestudio.xfeservermanager.core.policy.PolicyDraftPresentException;
import com.xfestudio.xfeservermanager.core.policy.PolicyVersionConflictException;
import com.xfestudio.xfeservermanager.core.trigger.TriggerRevisionConflictException;
import com.xfestudio.xfeservermanager.core.trigger.TriggerStateRevisionConflictException;
import com.xfestudio.xfeservermanager.infra.config.ServerConfiguration;
import com.xfestudio.xfeservermanager.infra.persistence.SqliteDatabase;
import com.xfestudio.xfeservermanager.infra.security.AesGcmSecretBox;
import com.xfestudio.xfeservermanager.infra.security.AccountConflictException;
import com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal;
import com.xfestudio.xfeservermanager.infra.security.AuthenticationService;
import com.xfestudio.xfeservermanager.infra.security.BootstrapTokenService;
import com.xfestudio.xfeservermanager.infra.security.Capability;
import com.xfestudio.xfeservermanager.infra.security.PasswordHasher;
import com.xfestudio.xfeservermanager.infra.security.Role;
import com.xfestudio.xfeservermanager.infra.security.SlidingWindowRateLimiter;
import com.xfestudio.xfeservermanager.infra.security.SqliteAuthRepository;
import com.xfestudio.xfeservermanager.infra.security.TotpService;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.io.TempDir;
class HttpApiServerSecurityTest {
@TempDir
Path directory;
@Test
void contentIsReadableButOnlyOwnersCanChangeRegistrationsOrAssets() {
for (String path : List.of("/content", "/content/catalog", "/content/assets", "/content/export")) {
assertThat(HttpApiServer.extendedCapability("GET", "/api/v1" + path, null))
.isEqualTo(Capability.TRIGGER_READ);
}
assertThat(HttpApiServer.extendedCapability("POST", "/api/v1/content/validate", Map.of()))
.isEqualTo(Capability.TRIGGER_READ);
assertThat(HttpApiServer.extendedStateMutation("POST", "/api/v1/content/validate")).isFalse();
for (String path : List.of("/content", "/content/publish", "/content/rollback", "/content/assets")) {
Capability capability = HttpApiServer.extendedCapability("POST", "/api/v1" + path, Map.of());
assertThat(capability).isEqualTo(Capability.CONSOLE_EXECUTE);
assertThat(Role.ADMINISTRATOR.permits(capability)).isFalse();
assertThat(Role.OWNER.permits(capability)).isTrue();
assertThat(HttpApiServer.extendedStateMutation("POST", "/api/v1" + path)).isTrue();
}
assertThat(HttpApiServer.extendedCapability("PUT", "/api/v1/content", Map.of()))
.isEqualTo(Capability.CONSOLE_EXECUTE);
}
@Test
void policyValidationAndPreviewsAreSynchronousResponses() {
assertThat(HttpApiServer.extendedSuccessStatus(
"POST", "/api/v1/policies/drafts/example/validate"))
.isEqualTo(200);
assertThat(HttpApiServer.extendedSuccessStatus(
"POST", "/api/v1/policies/drafts/example/publish"))
.isEqualTo(202);
assertThat(HttpApiServer.extendedStateMutation(
"GET", "/api/v1/policies/commands"))
.isFalse();
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/policies/commands", null))
.isEqualTo(Capability.POLICY_EDIT);
}
@Test
void selectsLeastPlayerCapabilityFromTheStructuredRouteAndAction() {
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/players/example/actions", Map.of("action", "message")))
.isEqualTo(Capability.PLAYER_MESSAGE);
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/players/example/actions", Map.of("action", "heal")))
.isEqualTo(Capability.PLAYER_SAFE_WRITE);
}
@Test
void protectsInventoryReadsAsInventoryManagementData() {
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/players/example/inventory", null))
.isEqualTo(Capability.INVENTORY_WRITE);
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/players/example/ender-chest", null))
.isEqualTo(Capability.INVENTORY_WRITE);
}
@Test
void protectsStoredOperationDetailsFromStatusOnlyRoles() {
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/operations/example", null))
.isEqualTo(Capability.PLAYER_DESTRUCTIVE_WRITE);
}
@Test
void separatesTriggerReadAndStrongMutationCapabilities() {
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/triggers/catalog", null))
.isEqualTo(Capability.TRIGGER_READ);
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/trigger-groups", Map.of()))
.isEqualTo(Capability.TRIGGER_WRITE);
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/triggers/validate", Map.of()))
.isEqualTo(Capability.TRIGGER_READ);
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/trigger-executions/example", null))
.isEqualTo(Capability.TRIGGER_READ);
assertThat(HttpApiServer.extendedStateMutation(
"POST", "/api/v1/triggers/validate")).isFalse();
assertThat(HttpApiServer.extendedStateMutation(
"POST", "/api/v1/triggers")).isTrue();
assertThat(HttpApiServer.extendedSuccessStatus(
"PATCH", "/api/v1/triggers/example/state")).isEqualTo(200);
}
@Test
void separatesEconomyReadAndStrongMutationCapabilities() {
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/economy/accounts", null))
.isEqualTo(Capability.ECONOMY_READ);
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/economy/transfers", Map.of()))
.isEqualTo(Capability.ECONOMY_MANAGE);
assertThat(HttpApiServer.extendedStateMutation(
"GET", "/api/v1/economy/transactions")).isFalse();
assertThat(HttpApiServer.extendedStateMutation(
"POST", "/api/v1/economy/adjustments")).isTrue();
assertThat(HttpApiServer.domainFailureStatus(new EconomyConflictException("stale balance")))
.isEqualTo(409);
}
@Test
void gatewayTimeoutDoesNotPoisonAnOperationThatIsStillQueued() {
java.util.concurrent.CompletableFuture<String> pending = new java.util.concurrent.CompletableFuture<>();
assertThatThrownBy(() -> HttpApiServer.await(pending))
.isInstanceOf(java.util.concurrent.CompletionException.class)
.hasCauseInstanceOf(java.util.concurrent.TimeoutException.class);
assertThat(pending).isNotDone();
pending.complete("one eventual result");
assertThat(HttpApiServer.await(pending)).isEqualTo("one eventual result");
}
@Test
void mapsDomainConflictsWithoutFallingThroughToInternalServerError() {
assertThat(HttpApiServer.domainFailureStatus(new com.xfestudio.xfeservermanager.core.content.ContentConflictException("stale draft")))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PlayerOperationException(
"inventory_revision_conflict", "inventory changed")))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PlayerOperationException(
"player_offline", "player disconnected")))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PolicyVersionConflictException(1, 2)))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PolicyDraftConflictException("a", "b")))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new TriggerRevisionConflictException(
"trigger", java.util.UUID.randomUUID(), 1, 2)))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new TriggerStateRevisionConflictException(
"progress", 1, 2)))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PolicyDraftPresentException()))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new InvalidPolicyException(List.of())))
.isEqualTo(400);
assertThat(HttpApiServer.domainFailureStatus(new NoPolicyDraftException()))
.isEqualTo(404);
assertThat(HttpApiServer.domainFailureStatus(new OperationNotFoundException("missing")))
.isEqualTo(404);
assertThat(HttpApiServer.domainFailureStatus(new InvalidOperationTransitionException(
OperationStatus.SUCCEEDED, OperationStatus.RUNNING)))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new RuntimeException("internal")))
.isZero();
}
@Test
void unavailableAuditAdmissionPreventsTheMutation() {
AtomicBoolean targetRead = new AtomicBoolean();
AtomicBoolean mutated = new AtomicBoolean();
try (var server = testServer(new StubGateway(), "audit-rejection.db")) {
var result = server.auditedAccountMutation(
ownerPrincipal(),
"account.create",
"username:Helper_One",
() -> {
targetRead.set(true);
return null;
},
"request-audit-denied",
() -> {
mutated.set(true);
return accountView();
});
assertThatThrownBy(() -> result.toCompletableFuture().join())
.hasCauseInstanceOf(RuntimeException.class)
.hasStackTraceContaining("security audit is unavailable");
assertThat(targetRead).isFalse();
assertThat(mutated).isFalse();
}
}
@Test
void accountAuditContainsOnlySafeViewsAndRecordsSuccessCompletion() {
CapturingAuditGateway gateway = new CapturingAuditGateway();
String sensitivePassword = "another correct horse battery staple";
try (var server = testServer(gateway, "audit-success.db")) {
var result = server.auditedAccountMutation(
ownerPrincipal(),
"account.create",
"username:Helper_One",
() -> null,
"request-audit-success",
() -> {
assertThat(sensitivePassword).isNotBlank();
return accountView();
}).toCompletableFuture().join();
assertThat(result.username()).isEqualTo("Helper_One");
assertThat(gateway.events).hasSize(2);
AuditEvent admission = gateway.events.get(0);
AuditEvent completion = gateway.events.get(1);
assertThat(admission.metadata()).containsEntry("phase", "admission");
assertThat(admission.requestId()).isEqualTo("request-audit-success");
assertThat(completion.metadata())
.containsEntry("phase", "completion")
.containsEntry("result", "succeeded");
assertThat(completion.outcome()).isEqualTo(AuditOutcome.SUCCEEDED);
assertThat(completion.target()).isEqualTo(result.id().toString());
assertThat(completion.afterValues())
.containsEntry("username", "Helper_One")
.containsEntry("role", "helper");
assertThat(completion.durationMillis()).isNotNegative();
String encodedEvents = new String(
new JsonCodec().writeBytes(gateway.events), StandardCharsets.UTF_8);
assertThat(encodedEvents)
.doesNotContain(
sensitivePassword,
"passwordHash",
"totpSecret",
"recoveryCode",
"$argon2");
}
}
@Test
void accountMutationFailureGetsADurableFailureCompletion() {
CapturingAuditGateway gateway = new CapturingAuditGateway();
try (var server = testServer(gateway, "audit-failure.db")) {
var result = server.auditedAccountMutation(
ownerPrincipal(),
"account.update",
accountView().id().toString(),
HttpApiServerSecurityTest::accountView,
"request-audit-failure",
() -> {
throw new AccountConflictException("cannot modify the final owner");
});
assertThatThrownBy(() -> result.toCompletableFuture().join())
.hasCauseInstanceOf(AccountConflictException.class);
assertThat(gateway.events).hasSize(2);
assertThat(gateway.events.get(1).outcome()).isEqualTo(AuditOutcome.FAILED);
assertThat(gateway.events.get(1).metadata())
.containsEntry("phase", "completion")
.containsEntry("result", "failed")
.containsEntry("errorType", "AccountConflictException");
}
}
@Test
void anonymousSessionAndBootstrapAreOriginCheckedAndSafelyRetryable() throws Exception {
try {
exerciseHttpContract();
} catch (java.io.IOException exception) {
// Some locked-down Windows runners cannot initialize WEPoll because the JDK's
// selector wake-up pipe cannot establish its private loopback connection.
Assumptions.assumeFalse(
isUnavailableWindowsSelector(exception),
"host cannot initialize the JDK Windows selector: " + exception.getMessage());
throw exception;
}
}
private void exerciseHttpContract() throws Exception {
int port;
try (var reservation = new ServerSocket(0)) {
port = reservation.getLocalPort();
}
String origin = "http://127.0.0.1:" + port;
ServerConfiguration defaults = ServerConfiguration.secureDefaults(directory);
var configuration = new ServerConfiguration(
defaults.bindAddress(),
port,
origin,
List.of(),
directory,
defaults.idleSessionTimeout(),
defaults.absoluteSessionTimeout(),
defaults.recentAuthenticationWindow(),
2,
1,
false,
false,
false,
false,
false,
false,
false,
defaults.joinExperienceEnabled(),
defaults.defaultMessageSender(),
defaults.joinWelcomeEnabled(),
defaults.joinWelcomeMessage(),
defaults.firstJoinMessageEnabled(),
defaults.firstJoinMessage(),
defaults.dailyAnnouncementsEnabled(),
defaults.dailyAnnouncements(),
defaults.announcementTimeZone(),
defaults.rulesReminderEnabled(),
defaults.rulesMessage(),
defaults.maintenanceJoinReminderEnabled(),
defaults.crashProtection());
Clock clock = Clock.systemUTC();
var database = new SqliteDatabase(directory.resolve("control.db"), clock);
database.migrate();
var bootstrap = new BootstrapTokenService(clock);
var authentication = new AuthenticationService(
new SqliteAuthRepository(database),
new FastPasswordHasher(),
new TotpService(clock),
new AesGcmSecretBox(new SecretKeySpec(new byte[32], "AES")),
bootstrap,
new SlidingWindowRateLimiter(10, Duration.ofMinutes(5), clock),
configuration,
clock);
try (var server = new HttpApiServer(
configuration,
authentication,
bootstrap,
new AcceptingAuditGateway(),
new JsonCodec(),
new SseBroker(1),
"")) {
server.start();
var anonymous = request(origin + "/api/v1/auth/session", "GET", null, null, null);
assertThat(anonymous.status()).isEqualTo(200);
assertThat(anonymous.body()).contains("\"authenticated\":false", "\"bootstrapRequired\":true");
String token = bootstrap.issue(Duration.ofMinutes(10)).token();
String body = "{\"bootstrapToken\":\"" + token
+ "\",\"username\":\"Owner_One\","
+ "\"password\":\"correct horse battery staple\"}";
var rejected = request(
origin + "/api/v1/auth/bootstrap", "POST", body, null, "retry-key-01");
assertThat(rejected.status()).isEqualTo(403);
assertThat(rejected.contentType()).startsWith("application/problem+json");
var created = request(
origin + "/api/v1/auth/bootstrap", "POST", body, origin, "retry-key-01");
var replay = request(
origin + "/api/v1/auth/bootstrap", "POST", body, origin, "retry-key-01");
assertThat(created.status()).withFailMessage(created.body()).isEqualTo(201);
assertThat(replay.status()).isEqualTo(201);
assertThat(replay.body()).isEqualTo(created.body());
String cookie = created.cookie().split(";", 2)[0];
String csrf = JsonParser.parseString(created.body())
.getAsJsonObject().get("csrfToken").getAsString();
String accountBody = "{\"username\":\"Helper_One\","
+ "\"password\":\"another correct horse battery staple\","
+ "\"role\":\"helper\"}";
var weakSession = request(
origin + "/api/v1/accounts",
"POST",
accountBody,
origin,
"account-create-01",
cookie,
csrf);
assertThat(weakSession.status()).isEqualTo(428);
var enrollment = request(
origin + "/api/v1/auth/totp/enrollment",
"POST",
"{\"password\":\"correct horse battery staple\"}",
origin,
"totp-enroll-01",
cookie,
csrf);
assertThat(enrollment.status()).isEqualTo(200);
String secretText = JsonParser.parseString(enrollment.body())
.getAsJsonObject().get("secret").getAsString();
byte[] secret = decodeBase32(secretText);
String code;
try {
TotpService totp = new TotpService(clock);
code = totp.formatCode(totp.currentCode(secret));
} finally {
java.util.Arrays.fill(secret, (byte) 0);
}
var confirmed = request(
origin + "/api/v1/auth/totp/confirm",
"POST",
"{\"code\":\"" + code + "\"}",
origin,
"totp-confirm-01",
cookie,
csrf);
assertThat(confirmed.status()).isEqualTo(200);
var missingCsrf = request(
origin + "/api/v1/accounts",
"POST",
accountBody,
origin,
"account-create-02",
cookie,
null);
assertThat(missingCsrf.status()).isEqualTo(403);
var accountCreated = request(
origin + "/api/v1/accounts",
"POST",
accountBody,
origin,
"account-create-02",
cookie,
csrf);
var accountReplay = request(
origin + "/api/v1/accounts",
"POST",
accountBody,
origin,
"account-create-02",
cookie,
csrf);
assertThat(accountCreated.status()).isEqualTo(201);
assertThat(accountReplay.status()).isEqualTo(201);
assertThat(accountReplay.body()).isEqualTo(accountCreated.body());
var conflictingReplay = request(
origin + "/api/v1/accounts",
"POST",
accountBody.replace("\"helper\"", "\"observer\""),
origin,
"account-create-02",
cookie,
csrf);
assertThat(conflictingReplay.status()).isEqualTo(409);
var accounts = request(
origin + "/api/v1/accounts",
"GET",
null,
null,
null,
cookie,
null);
assertThat(accounts.status()).isEqualTo(200);
assertThat(accounts.body())
.contains("\"username\":\"Helper_One\"", "\"role\":\"helper\"")
.doesNotContain("password", "totpSecret", "normalizedUsername");
}
}
@Test
void sseSessionGuardObservesRevocation() {
ServerConfiguration configuration = ServerConfiguration.secureDefaults(directory);
Clock clock = Clock.systemUTC();
var database = new SqliteDatabase(directory.resolve("sse-control.db"), clock);
database.migrate();
var bootstrap = new BootstrapTokenService(clock);
var authentication = new AuthenticationService(
new SqliteAuthRepository(database),
new FastPasswordHasher(),
new TotpService(clock),
new AesGcmSecretBox(new SecretKeySpec(new byte[32], "AES")),
bootstrap,
new SlidingWindowRateLimiter(10, Duration.ofMinutes(5), clock),
configuration,
clock);
var issued = authentication.createInitialOwner(
bootstrap.issue(Duration.ofMinutes(10)).token(),
"Owner_One",
"correct horse battery staple".toCharArray(),
"127.0.0.1",
"test");
try (var server = new HttpApiServer(
configuration,
authentication,
bootstrap,
new StubGateway(),
new JsonCodec(),
new SseBroker(1),
"")) {
assertThat(server.isSessionActive(issued.sessionToken())).isTrue();
authentication.logout(issued.principal());
assertThat(server.isSessionActive(issued.sessionToken())).isFalse();
}
}
private static boolean isUnavailableWindowsSelector(Throwable throwable) {
for (Throwable current = throwable; current != null; current = current.getCause()) {
if (current.getMessage() != null
&& current.getMessage().contains("Unable to establish loopback connection")) {
return true;
}
}
return false;
}
private static Response request(
String uri, String method, String body, String origin, String idempotencyKey)
throws Exception {
return request(uri, method, body, origin, idempotencyKey, null, null);
}
private static Response request(
String uri,
String method,
String body,
String origin,
String idempotencyKey,
String cookie,
String csrf)
throws Exception {
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(uri))
.timeout(Duration.ofSeconds(2));
if (origin != null) request.header("Origin", origin);
if (idempotencyKey != null) request.header("Idempotency-Key", idempotencyKey);
if (cookie != null) request.header("Cookie", cookie);
if (csrf != null) request.header("X-CSRF-Token", csrf);
if (body == null) {
request.method(method, HttpRequest.BodyPublishers.noBody());
} else {
request.header("Content-Type", "application/json");
request.method(method, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
}
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build()
.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new Response(
response.statusCode(),
response.body(),
response.headers().firstValue("Content-Type").orElse(""),
response.headers().firstValue("Set-Cookie").orElse(""));
}
private static byte[] decodeBase32(String value) {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
int buffer = 0;
int bits = 0;
for (char character : value.toCharArray()) {
int decoded = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(character);
if (decoded < 0) throw new IllegalArgumentException("bad base32");
buffer = (buffer << 5) | decoded;
bits += 5;
if (bits >= 8) {
output.write((buffer >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return output.toByteArray();
}
private HttpApiServer testServer(ManagementGateway gateway, String databaseName) {
ServerConfiguration configuration = ServerConfiguration.secureDefaults(directory);
Clock clock = Clock.systemUTC();
var database = new SqliteDatabase(directory.resolve(databaseName), clock);
database.migrate();
var bootstrap = new BootstrapTokenService(clock);
var authentication = new AuthenticationService(
new SqliteAuthRepository(database),
new FastPasswordHasher(),
new TotpService(clock),
new AesGcmSecretBox(new SecretKeySpec(new byte[32], "AES")),
bootstrap,
new SlidingWindowRateLimiter(10, Duration.ofMinutes(5), clock),
configuration,
clock);
return new HttpApiServer(
configuration,
authentication,
bootstrap,
gateway,
new JsonCodec(),
new SseBroker(1),
"");
}
private static AuthenticatedPrincipal ownerPrincipal() {
Instant now = Instant.now();
return new AuthenticatedPrincipal(
UUID.randomUUID(),
UUID.randomUUID(),
"Owner_One",
Role.OWNER,
true,
now,
now.plus(Duration.ofHours(1)));
}
private static AuthenticationService.AccountView accountView() {
Instant now = Instant.now();
return new AuthenticationService.AccountView(
UUID.randomUUID(),
"Helper_One",
"helper",
false,
false,
now,
now,
null);
}
private record Response(int status, String body, String contentType, String cookie) { }
private static final class FastPasswordHasher implements PasswordHasher {
@Override
public String hash(char[] password) {
return "$test$" + new String(password);
}
@Override
public boolean verify(char[] password, String encodedHash) {
return encodedHash.equals(hash(password));
}
}
private static class StubGateway implements ManagementGateway {
@Override public java.util.concurrent.CompletionStage<?> status(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal) {
return CompletableFuture.completedFuture(java.util.Map.of());
}
@Override public java.util.concurrent.CompletionStage<?> players(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal) {
return CompletableFuture.completedFuture(java.util.List.of());
}
@Override public java.util.concurrent.CompletionStage<?> audit(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal,
java.util.Map<String, String> query) {
return CompletableFuture.completedFuture(java.util.List.of());
}
@Override public java.util.concurrent.CompletionStage<?> submitOperation(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal,
OperationRequest request,
String requestId,
String idempotencyKey) {
return CompletableFuture.completedFuture(java.util.Map.of());
}
@Override public java.util.concurrent.CompletionStage<?> executeConsole(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal,
ConsoleRequest request,
String requestId,
String idempotencyKey) {
return CompletableFuture.completedFuture(java.util.Map.of());
}
@Override public boolean serverReady() {
return true;
}
}
private static final class AcceptingAuditGateway extends StubGateway {
@Override
public java.util.concurrent.CompletionStage<Void> appendSecurityAudit(AuditEvent event) {
return CompletableFuture.completedFuture(null);
}
}
private static final class CapturingAuditGateway extends StubGateway {
private final List<AuditEvent> events = new CopyOnWriteArrayList<>();
@Override
public java.util.concurrent.CompletionStage<Void> appendSecurityAudit(AuditEvent event) {
events.add(event);
return CompletableFuture.completedFuture(null);
}
}
}
package com.xfestudio.xfeservermanager.infra.http;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.google.gson.JsonParser;
import com.xfestudio.xfeservermanager.api.audit.AuditEvent;
import com.xfestudio.xfeservermanager.api.audit.AuditOutcome;
import com.xfestudio.xfeservermanager.core.player.PlayerOperationException;
import com.xfestudio.xfeservermanager.core.economy.EconomyConflictException;
import com.xfestudio.xfeservermanager.api.operation.OperationStatus;
import com.xfestudio.xfeservermanager.core.operation.InvalidOperationTransitionException;
import com.xfestudio.xfeservermanager.core.operation.OperationNotFoundException;
import com.xfestudio.xfeservermanager.core.policy.InvalidPolicyException;
import com.xfestudio.xfeservermanager.core.policy.NoPolicyDraftException;
import com.xfestudio.xfeservermanager.core.policy.PolicyDraftConflictException;
import com.xfestudio.xfeservermanager.core.policy.PolicyDraftPresentException;
import com.xfestudio.xfeservermanager.core.policy.PolicyVersionConflictException;
import com.xfestudio.xfeservermanager.core.trigger.TriggerRevisionConflictException;
import com.xfestudio.xfeservermanager.core.trigger.TriggerStateRevisionConflictException;
import com.xfestudio.xfeservermanager.infra.config.ServerConfiguration;
import com.xfestudio.xfeservermanager.infra.persistence.SqliteDatabase;
import com.xfestudio.xfeservermanager.infra.security.AesGcmSecretBox;
import com.xfestudio.xfeservermanager.infra.security.AccountConflictException;
import com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal;
import com.xfestudio.xfeservermanager.infra.security.AuthenticationService;
import com.xfestudio.xfeservermanager.infra.security.BootstrapTokenService;
import com.xfestudio.xfeservermanager.infra.security.Capability;
import com.xfestudio.xfeservermanager.infra.security.PasswordHasher;
import com.xfestudio.xfeservermanager.infra.security.Role;
import com.xfestudio.xfeservermanager.infra.security.SlidingWindowRateLimiter;
import com.xfestudio.xfeservermanager.infra.security.SqliteAuthRepository;
import com.xfestudio.xfeservermanager.infra.security.TotpService;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.io.TempDir;
class HttpApiServerSecurityTest {
@TempDir
Path directory;
@Test
void contentIsReadableButOnlyOwnersCanChangeRegistrationsOrAssets() {
for (String path : List.of("/content", "/content/catalog", "/content/assets", "/content/export")) {
assertThat(HttpApiServer.extendedCapability("GET", "/api/v1" + path, null))
.isEqualTo(Capability.TRIGGER_READ);
}
assertThat(HttpApiServer.extendedCapability("POST", "/api/v1/content/validate", Map.of()))
.isEqualTo(Capability.TRIGGER_READ);
assertThat(HttpApiServer.extendedStateMutation("POST", "/api/v1/content/validate")).isFalse();
for (String path : List.of("/content", "/content/publish", "/content/rollback", "/content/assets")) {
Capability capability = HttpApiServer.extendedCapability("POST", "/api/v1" + path, Map.of());
assertThat(capability).isEqualTo(Capability.CONSOLE_EXECUTE);
assertThat(Role.ADMINISTRATOR.permits(capability)).isFalse();
assertThat(Role.OWNER.permits(capability)).isTrue();
assertThat(HttpApiServer.extendedStateMutation("POST", "/api/v1" + path)).isTrue();
}
assertThat(HttpApiServer.extendedCapability("PUT", "/api/v1/content", Map.of()))
.isEqualTo(Capability.CONSOLE_EXECUTE);
}
@Test
void policyValidationAndPreviewsAreSynchronousResponses() {
assertThat(HttpApiServer.extendedSuccessStatus(
"POST", "/api/v1/policies/drafts/example/validate"))
.isEqualTo(200);
assertThat(HttpApiServer.extendedSuccessStatus(
"POST", "/api/v1/policies/drafts/example/publish"))
.isEqualTo(202);
assertThat(HttpApiServer.extendedStateMutation(
"GET", "/api/v1/policies/commands"))
.isFalse();
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/policies/commands", null))
.isEqualTo(Capability.POLICY_EDIT);
}
@Test
void selectsLeastPlayerCapabilityFromTheStructuredRouteAndAction() {
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/players/example/actions", Map.of("action", "message")))
.isEqualTo(Capability.PLAYER_MESSAGE);
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/players/example/actions", Map.of("action", "heal")))
.isEqualTo(Capability.PLAYER_SAFE_WRITE);
}
@Test
void protectsInventoryReadsAsInventoryManagementData() {
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/players/example/inventory", null))
.isEqualTo(Capability.INVENTORY_WRITE);
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/players/example/ender-chest", null))
.isEqualTo(Capability.INVENTORY_WRITE);
}
@Test
void protectsStoredOperationDetailsFromStatusOnlyRoles() {
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/operations/example", null))
.isEqualTo(Capability.PLAYER_DESTRUCTIVE_WRITE);
}
@Test
void separatesTriggerReadAndStrongMutationCapabilities() {
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/triggers/catalog", null))
.isEqualTo(Capability.TRIGGER_READ);
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/trigger-groups", Map.of()))
.isEqualTo(Capability.TRIGGER_WRITE);
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/triggers/validate", Map.of()))
.isEqualTo(Capability.TRIGGER_READ);
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/trigger-executions/example", null))
.isEqualTo(Capability.TRIGGER_READ);
assertThat(HttpApiServer.extendedStateMutation(
"POST", "/api/v1/triggers/validate")).isFalse();
assertThat(HttpApiServer.extendedStateMutation(
"POST", "/api/v1/triggers")).isTrue();
assertThat(HttpApiServer.extendedSuccessStatus(
"PATCH", "/api/v1/triggers/example/state")).isEqualTo(200);
}
@Test
void separatesEconomyReadAndStrongMutationCapabilities() {
assertThat(HttpApiServer.extendedCapability(
"GET", "/api/v1/economy/accounts", null))
.isEqualTo(Capability.ECONOMY_READ);
assertThat(HttpApiServer.extendedCapability(
"POST", "/api/v1/economy/transfers", Map.of()))
.isEqualTo(Capability.ECONOMY_MANAGE);
assertThat(HttpApiServer.extendedStateMutation(
"GET", "/api/v1/economy/transactions")).isFalse();
assertThat(HttpApiServer.extendedStateMutation(
"POST", "/api/v1/economy/adjustments")).isTrue();
assertThat(HttpApiServer.domainFailureStatus(new EconomyConflictException("stale balance")))
.isEqualTo(409);
}
@Test
void gatewayTimeoutDoesNotPoisonAnOperationThatIsStillQueued() {
java.util.concurrent.CompletableFuture<String> pending = new java.util.concurrent.CompletableFuture<>();
assertThatThrownBy(() -> HttpApiServer.await(pending))
.isInstanceOf(java.util.concurrent.CompletionException.class)
.hasCauseInstanceOf(java.util.concurrent.TimeoutException.class);
assertThat(pending).isNotDone();
pending.complete("one eventual result");
assertThat(HttpApiServer.await(pending)).isEqualTo("one eventual result");
}
@Test
void mapsDomainConflictsWithoutFallingThroughToInternalServerError() {
assertThat(HttpApiServer.domainFailureStatus(new com.xfestudio.xfeservermanager.core.content.ContentConflictException("stale draft")))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PlayerOperationException(
"inventory_revision_conflict", "inventory changed")))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PlayerOperationException(
"player_offline", "player disconnected")))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PolicyVersionConflictException(1, 2)))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PolicyDraftConflictException("a", "b")))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new TriggerRevisionConflictException(
"trigger", java.util.UUID.randomUUID(), 1, 2)))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new TriggerStateRevisionConflictException(
"progress", 1, 2)))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new PolicyDraftPresentException()))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new InvalidPolicyException(List.of())))
.isEqualTo(400);
assertThat(HttpApiServer.domainFailureStatus(new NoPolicyDraftException()))
.isEqualTo(404);
assertThat(HttpApiServer.domainFailureStatus(new OperationNotFoundException("missing")))
.isEqualTo(404);
assertThat(HttpApiServer.domainFailureStatus(new InvalidOperationTransitionException(
OperationStatus.SUCCEEDED, OperationStatus.RUNNING)))
.isEqualTo(409);
assertThat(HttpApiServer.domainFailureStatus(new RuntimeException("internal")))
.isZero();
}
@Test
void unavailableAuditAdmissionPreventsTheMutation() {
AtomicBoolean targetRead = new AtomicBoolean();
AtomicBoolean mutated = new AtomicBoolean();
try (var server = testServer(new StubGateway(), "audit-rejection.db")) {
var result = server.auditedAccountMutation(
ownerPrincipal(),
"account.create",
"username:Helper_One",
() -> {
targetRead.set(true);
return null;
},
"request-audit-denied",
() -> {
mutated.set(true);
return accountView();
});
assertThatThrownBy(() -> result.toCompletableFuture().join())
.hasCauseInstanceOf(RuntimeException.class)
.hasStackTraceContaining("security audit is unavailable");
assertThat(targetRead).isFalse();
assertThat(mutated).isFalse();
}
}
@Test
void accountAuditContainsOnlySafeViewsAndRecordsSuccessCompletion() {
CapturingAuditGateway gateway = new CapturingAuditGateway();
String sensitivePassword = "another correct horse battery staple";
try (var server = testServer(gateway, "audit-success.db")) {
var result = server.auditedAccountMutation(
ownerPrincipal(),
"account.create",
"username:Helper_One",
() -> null,
"request-audit-success",
() -> {
assertThat(sensitivePassword).isNotBlank();
return accountView();
}).toCompletableFuture().join();
assertThat(result.username()).isEqualTo("Helper_One");
assertThat(gateway.events).hasSize(2);
AuditEvent admission = gateway.events.get(0);
AuditEvent completion = gateway.events.get(1);
assertThat(admission.metadata()).containsEntry("phase", "admission");
assertThat(admission.requestId()).isEqualTo("request-audit-success");
assertThat(completion.metadata())
.containsEntry("phase", "completion")
.containsEntry("result", "succeeded");
assertThat(completion.outcome()).isEqualTo(AuditOutcome.SUCCEEDED);
assertThat(completion.target()).isEqualTo(result.id().toString());
assertThat(completion.afterValues())
.containsEntry("username", "Helper_One")
.containsEntry("role", "helper");
assertThat(completion.durationMillis()).isNotNegative();
String encodedEvents = new String(
new JsonCodec().writeBytes(gateway.events), StandardCharsets.UTF_8);
assertThat(encodedEvents)
.doesNotContain(
sensitivePassword,
"passwordHash",
"totpSecret",
"recoveryCode",
"$argon2");
}
}
@Test
void accountMutationFailureGetsADurableFailureCompletion() {
CapturingAuditGateway gateway = new CapturingAuditGateway();
try (var server = testServer(gateway, "audit-failure.db")) {
var result = server.auditedAccountMutation(
ownerPrincipal(),
"account.update",
accountView().id().toString(),
HttpApiServerSecurityTest::accountView,
"request-audit-failure",
() -> {
throw new AccountConflictException("cannot modify the final owner");
});
assertThatThrownBy(() -> result.toCompletableFuture().join())
.hasCauseInstanceOf(AccountConflictException.class);
assertThat(gateway.events).hasSize(2);
assertThat(gateway.events.get(1).outcome()).isEqualTo(AuditOutcome.FAILED);
assertThat(gateway.events.get(1).metadata())
.containsEntry("phase", "completion")
.containsEntry("result", "failed")
.containsEntry("errorType", "AccountConflictException");
}
}
@Test
void anonymousSessionAndBootstrapAreOriginCheckedAndSafelyRetryable() throws Exception {
try {
exerciseHttpContract();
} catch (java.io.IOException exception) {
// Some locked-down Windows runners cannot initialize WEPoll because the JDK's
// selector wake-up pipe cannot establish its private loopback connection.
Assumptions.assumeFalse(
isUnavailableWindowsSelector(exception),
"host cannot initialize the JDK Windows selector: " + exception.getMessage());
throw exception;
}
}
private void exerciseHttpContract() throws Exception {
int port;
try (var reservation = new ServerSocket(0)) {
port = reservation.getLocalPort();
}
String origin = "http://127.0.0.1:" + port;
ServerConfiguration defaults = ServerConfiguration.secureDefaults(directory);
var configuration = new ServerConfiguration(
defaults.bindAddress(),
port,
origin,
List.of(),
directory,
defaults.idleSessionTimeout(),
defaults.absoluteSessionTimeout(),
defaults.recentAuthenticationWindow(),
2,
1,
false,
false,
false,
false,
false,
false,
false,
defaults.joinExperienceEnabled(),
defaults.defaultMessageSender(),
defaults.joinWelcomeEnabled(),
defaults.joinWelcomeMessage(),
defaults.firstJoinMessageEnabled(),
defaults.firstJoinMessage(),
defaults.dailyAnnouncementsEnabled(),
defaults.dailyAnnouncements(),
defaults.announcementTimeZone(),
defaults.rulesReminderEnabled(),
defaults.rulesMessage(),
defaults.maintenanceJoinReminderEnabled(),
defaults.crashProtection());
Clock clock = Clock.systemUTC();
var database = new SqliteDatabase(directory.resolve("control.db"), clock);
database.migrate();
var bootstrap = new BootstrapTokenService(clock);
var authentication = new AuthenticationService(
new SqliteAuthRepository(database),
new FastPasswordHasher(),
new TotpService(clock),
new AesGcmSecretBox(new SecretKeySpec(new byte[32], "AES")),
bootstrap,
new SlidingWindowRateLimiter(10, Duration.ofMinutes(5), clock),
configuration,
clock);
try (var server = new HttpApiServer(
configuration,
authentication,
bootstrap,
new AcceptingAuditGateway(),
new JsonCodec(),
new SseBroker(1),
"")) {
server.start();
var anonymous = request(origin + "/api/v1/auth/session", "GET", null, null, null);
assertThat(anonymous.status()).isEqualTo(200);
assertThat(anonymous.body()).contains("\"authenticated\":false", "\"bootstrapRequired\":true");
String token = bootstrap.issue(Duration.ofMinutes(10)).token();
String body = "{\"bootstrapToken\":\"" + token
+ "\",\"username\":\"Owner_One\","
+ "\"password\":\"correct horse battery staple\"}";
var rejected = request(
origin + "/api/v1/auth/bootstrap", "POST", body, null, "retry-key-01");
assertThat(rejected.status()).isEqualTo(403);
assertThat(rejected.contentType()).startsWith("application/problem+json");
var created = request(
origin + "/api/v1/auth/bootstrap", "POST", body, origin, "retry-key-01");
var replay = request(
origin + "/api/v1/auth/bootstrap", "POST", body, origin, "retry-key-01");
assertThat(created.status()).withFailMessage(created.body()).isEqualTo(201);
assertThat(replay.status()).isEqualTo(201);
assertThat(replay.body()).isEqualTo(created.body());
String cookie = created.cookie().split(";", 2)[0];
String csrf = JsonParser.parseString(created.body())
.getAsJsonObject().get("csrfToken").getAsString();
String accountBody = "{\"username\":\"Helper_One\","
+ "\"password\":\"another correct horse battery staple\","
+ "\"role\":\"helper\"}";
var weakSession = request(
origin + "/api/v1/accounts",
"POST",
accountBody,
origin,
"account-create-01",
cookie,
csrf);
assertThat(weakSession.status()).isEqualTo(428);
var enrollment = request(
origin + "/api/v1/auth/totp/enrollment",
"POST",
"{\"password\":\"correct horse battery staple\"}",
origin,
"totp-enroll-01",
cookie,
csrf);
assertThat(enrollment.status()).isEqualTo(200);
String secretText = JsonParser.parseString(enrollment.body())
.getAsJsonObject().get("secret").getAsString();
byte[] secret = decodeBase32(secretText);
String code;
try {
TotpService totp = new TotpService(clock);
code = totp.formatCode(totp.currentCode(secret));
} finally {
java.util.Arrays.fill(secret, (byte) 0);
}
var confirmed = request(
origin + "/api/v1/auth/totp/confirm",
"POST",
"{\"code\":\"" + code + "\"}",
origin,
"totp-confirm-01",
cookie,
csrf);
assertThat(confirmed.status()).isEqualTo(200);
var missingCsrf = request(
origin + "/api/v1/accounts",
"POST",
accountBody,
origin,
"account-create-02",
cookie,
null);
assertThat(missingCsrf.status()).isEqualTo(403);
var accountCreated = request(
origin + "/api/v1/accounts",
"POST",
accountBody,
origin,
"account-create-02",
cookie,
csrf);
var accountReplay = request(
origin + "/api/v1/accounts",
"POST",
accountBody,
origin,
"account-create-02",
cookie,
csrf);
assertThat(accountCreated.status()).isEqualTo(201);
assertThat(accountReplay.status()).isEqualTo(201);
assertThat(accountReplay.body()).isEqualTo(accountCreated.body());
var conflictingReplay = request(
origin + "/api/v1/accounts",
"POST",
accountBody.replace("\"helper\"", "\"observer\""),
origin,
"account-create-02",
cookie,
csrf);
assertThat(conflictingReplay.status()).isEqualTo(409);
var accounts = request(
origin + "/api/v1/accounts",
"GET",
null,
null,
null,
cookie,
null);
assertThat(accounts.status()).isEqualTo(200);
assertThat(accounts.body())
.contains("\"username\":\"Helper_One\"", "\"role\":\"helper\"")
.doesNotContain("password", "totpSecret", "normalizedUsername");
}
}
@Test
void sseSessionGuardObservesRevocation() {
ServerConfiguration configuration = ServerConfiguration.secureDefaults(directory);
Clock clock = Clock.systemUTC();
var database = new SqliteDatabase(directory.resolve("sse-control.db"), clock);
database.migrate();
var bootstrap = new BootstrapTokenService(clock);
var authentication = new AuthenticationService(
new SqliteAuthRepository(database),
new FastPasswordHasher(),
new TotpService(clock),
new AesGcmSecretBox(new SecretKeySpec(new byte[32], "AES")),
bootstrap,
new SlidingWindowRateLimiter(10, Duration.ofMinutes(5), clock),
configuration,
clock);
var issued = authentication.createInitialOwner(
bootstrap.issue(Duration.ofMinutes(10)).token(),
"Owner_One",
"correct horse battery staple".toCharArray(),
"127.0.0.1",
"test");
try (var server = new HttpApiServer(
configuration,
authentication,
bootstrap,
new StubGateway(),
new JsonCodec(),
new SseBroker(1),
"")) {
assertThat(server.isSessionActive(issued.sessionToken())).isTrue();
authentication.logout(issued.principal());
assertThat(server.isSessionActive(issued.sessionToken())).isFalse();
}
}
private static boolean isUnavailableWindowsSelector(Throwable throwable) {
for (Throwable current = throwable; current != null; current = current.getCause()) {
if (current.getMessage() != null
&& current.getMessage().contains("Unable to establish loopback connection")) {
return true;
}
}
return false;
}
private static Response request(
String uri, String method, String body, String origin, String idempotencyKey)
throws Exception {
return request(uri, method, body, origin, idempotencyKey, null, null);
}
private static Response request(
String uri,
String method,
String body,
String origin,
String idempotencyKey,
String cookie,
String csrf)
throws Exception {
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(uri))
.timeout(Duration.ofSeconds(2));
if (origin != null) request.header("Origin", origin);
if (idempotencyKey != null) request.header("Idempotency-Key", idempotencyKey);
if (cookie != null) request.header("Cookie", cookie);
if (csrf != null) request.header("X-CSRF-Token", csrf);
if (body == null) {
request.method(method, HttpRequest.BodyPublishers.noBody());
} else {
request.header("Content-Type", "application/json");
request.method(method, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
}
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build()
.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new Response(
response.statusCode(),
response.body(),
response.headers().firstValue("Content-Type").orElse(""),
response.headers().firstValue("Set-Cookie").orElse(""));
}
private static byte[] decodeBase32(String value) {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
int buffer = 0;
int bits = 0;
for (char character : value.toCharArray()) {
int decoded = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(character);
if (decoded < 0) throw new IllegalArgumentException("bad base32");
buffer = (buffer << 5) | decoded;
bits += 5;
if (bits >= 8) {
output.write((buffer >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return output.toByteArray();
}
private HttpApiServer testServer(ManagementGateway gateway, String databaseName) {
ServerConfiguration configuration = ServerConfiguration.secureDefaults(directory);
Clock clock = Clock.systemUTC();
var database = new SqliteDatabase(directory.resolve(databaseName), clock);
database.migrate();
var bootstrap = new BootstrapTokenService(clock);
var authentication = new AuthenticationService(
new SqliteAuthRepository(database),
new FastPasswordHasher(),
new TotpService(clock),
new AesGcmSecretBox(new SecretKeySpec(new byte[32], "AES")),
bootstrap,
new SlidingWindowRateLimiter(10, Duration.ofMinutes(5), clock),
configuration,
clock);
return new HttpApiServer(
configuration,
authentication,
bootstrap,
gateway,
new JsonCodec(),
new SseBroker(1),
"");
}
private static AuthenticatedPrincipal ownerPrincipal() {
Instant now = Instant.now();
return new AuthenticatedPrincipal(
UUID.randomUUID(),
UUID.randomUUID(),
"Owner_One",
Role.OWNER,
true,
now,
now.plus(Duration.ofHours(1)));
}
private static AuthenticationService.AccountView accountView() {
Instant now = Instant.now();
return new AuthenticationService.AccountView(
UUID.randomUUID(),
"Helper_One",
"helper",
false,
false,
now,
now,
null);
}
private record Response(int status, String body, String contentType, String cookie) { }
private static final class FastPasswordHasher implements PasswordHasher {
@Override
public String hash(char[] password) {
return "$test$" + new String(password);
}
@Override
public boolean verify(char[] password, String encodedHash) {
return encodedHash.equals(hash(password));
}
}
private static class StubGateway implements ManagementGateway {
@Override public java.util.concurrent.CompletionStage<?> status(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal) {
return CompletableFuture.completedFuture(java.util.Map.of());
}
@Override public java.util.concurrent.CompletionStage<?> players(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal) {
return CompletableFuture.completedFuture(java.util.List.of());
}
@Override public java.util.concurrent.CompletionStage<?> audit(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal,
java.util.Map<String, String> query) {
return CompletableFuture.completedFuture(java.util.List.of());
}
@Override public java.util.concurrent.CompletionStage<?> submitOperation(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal,
OperationRequest request,
String requestId,
String idempotencyKey) {
return CompletableFuture.completedFuture(java.util.Map.of());
}
@Override public java.util.concurrent.CompletionStage<?> executeConsole(
com.xfestudio.xfeservermanager.infra.security.AuthenticatedPrincipal principal,
ConsoleRequest request,
String requestId,
String idempotencyKey) {
return CompletableFuture.completedFuture(java.util.Map.of());
}
@Override public boolean serverReady() {
return true;
}
}
private static final class AcceptingAuditGateway extends StubGateway {
@Override
public java.util.concurrent.CompletionStage<Void> appendSecurityAudit(AuditEvent event) {
return CompletableFuture.completedFuture(null);
}
}
private static final class CapturingAuditGateway extends StubGateway {
private final List<AuditEvent> events = new CopyOnWriteArrayList<>();
@Override
public java.util.concurrent.CompletionStage<Void> appendSecurityAudit(AuditEvent event) {
events.add(event);
return CompletableFuture.completedFuture(null);
}
}
}