load(String path) {
+ private static String load(String path) {
try {
- return Optional.of(Files.readString(Path.of(path)).trim());
+ return Files.readString(Path.of(path)).trim();
} catch (IOException e) {
log.warn("No registration certificate at {} — GET /domain/registration will 404 "
+ "until the dev setup script (dev.md) generates it.", path);
- return Optional.empty();
+ return null;
}
}
@@ -45,6 +45,6 @@ public class RegistrationCertificateStore {
if (!testDomain.equals(domain)) {
return Optional.empty();
}
- return cert;
+ return Optional.ofNullable(cert);
}
}
diff --git a/server/src/main/java/dev/locqr/server/util/Base64Url.java b/server/src/main/java/dev/locqr/server/util/Base64Url.java
index f4f3259..365e091 100644
--- a/server/src/main/java/dev/locqr/server/util/Base64Url.java
+++ b/server/src/main/java/dev/locqr/server/util/Base64Url.java
@@ -1,6 +1,5 @@
package dev.locqr.server.util;
-import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
@@ -18,8 +17,4 @@ public final class Base64Url {
public static byte[] decode(String s) {
return Base64.getUrlDecoder().decode(s);
}
-
- public static String encodeUtf8(String s) {
- return encode(s.getBytes(StandardCharsets.UTF_8));
- }
}
diff --git a/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java b/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java
index 8509cf7..7b34b8b 100644
--- a/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java
+++ b/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java
@@ -1,20 +1,22 @@
package dev.locqr.server;
-import com.fasterxml.jackson.databind.ObjectMapper;
import dev.locqr.server.dto.*;
import dev.locqr.server.support.TestEd25519Keys;
+import org.jspecify.annotations.NonNull;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
+import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -32,6 +34,10 @@ import static org.assertj.core.api.Assertions.assertThat;
* WebSocket stack rather than by inspecting code. A hermetic, generated
* Ed25519 key is used — these tests never depend on the committed
* {@code dev/keys/} material, so they're unaffected by dev key rotation.
+ *
+ * Uses {@link RestTestClient} (Spring Framework 7 / Boot 4), not the
+ * older {@code TestRestTemplate} — that class no longer ships in
+ * {@code spring-boot-starter-test} as of Boot 4.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RunFlowIntegrationTest {
@@ -39,10 +45,13 @@ class RunFlowIntegrationTest {
@LocalServerPort
int port;
- @org.springframework.beans.factory.annotation.Autowired
- TestRestTemplate restTemplate;
+ private RestTestClient client;
+ private final ObjectMapper objectMapper = new JsonMapper();
- private final ObjectMapper objectMapper = new ObjectMapper();
+ @BeforeEach
+ void setUpClient() {
+ client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
+ }
@DynamicPropertySource
static void dynamicKeyAndCert(DynamicPropertyRegistry registry) throws Exception {
@@ -65,62 +74,82 @@ class RunFlowIntegrationTest {
var upload = new RunBundleUploadRequest(
runId, "https://test.locqr.dev", randomBase64(32), randomBase64(1184));
- ResponseEntity uploadResponse =
- restTemplate.postForEntity("/run/bundle", upload, RunBundleUploadResponse.class);
+ RunBundleUploadResponse body = client.post().uri("/run/bundle")
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(upload)
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(RunBundleUploadResponse.class)
+ .returnResult()
+ .getResponseBody();
- assertThat(uploadResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
- RunBundleUploadResponse body = uploadResponse.getBody();
assertThat(body).isNotNull();
assertThat(body.qrTtl()).isEqualTo(90);
assertThat(body.maxAutoRefresh()).isEqualTo(3);
assertThat(body.pinTtl()).isEqualTo(120);
assertThat(body.signedToken()).isNotBlank();
- ResponseEntity firstFetch =
- restTemplate.getForEntity("/run/bundle/{runId}", RunBundleFetchResponse.class, runId);
- assertThat(firstFetch.getStatusCode()).isEqualTo(HttpStatus.OK);
- assertThat(firstFetch.getBody()).isNotNull();
- assertThat(firstFetch.getBody().x25519Pubkey()).isEqualTo(upload.x25519Pubkey());
- assertThat(firstFetch.getBody().kemPubkey()).isEqualTo(upload.kemPubkey());
+ RunBundleFetchResponse firstFetch = client.get().uri("/run/bundle/{runId}", runId)
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(RunBundleFetchResponse.class)
+ .returnResult()
+ .getResponseBody();
- ResponseEntity secondFetch =
- restTemplate.getForEntity("/run/bundle/{runId}", RunBundleFetchResponse.class, runId);
- assertThat(secondFetch.getStatusCode())
- .as("at-most-once: the bundle must be gone after the first fetch")
- .isEqualTo(HttpStatus.NOT_FOUND);
+ assertThat(firstFetch).isNotNull();
+ assertThat(firstFetch.x25519Pubkey()).isEqualTo(upload.x25519Pubkey());
+ assertThat(firstFetch.kemPubkey()).isEqualTo(upload.kemPubkey());
+
+ // at-most-once: the bundle must be gone after the first fetch
+ client.get().uri("/run/bundle/{runId}", runId)
+ .exchange()
+ .expectStatus().isNotFound();
}
@Test
void fetchingAnUnknownRunId_returnsNotFound() {
- ResponseEntity response = restTemplate.getForEntity(
- "/run/bundle/{runId}", RunBundleFetchResponse.class, "never-uploaded-" + UUID.randomUUID());
-
- assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ client.get().uri("/run/bundle/{runId}", "never-uploaded-" + UUID.randomUUID())
+ .exchange()
+ .expectStatus().isNotFound();
}
// --- Domain status / registration (interfaces.md) ---
@Test
void domainStatus_isValidForTheTestDomain_andRejectedForAnyOther() {
- ResponseEntity valid = restTemplate.getForEntity(
- "/domain/status?domain={d}", DomainStatusResponse.class, "test.locqr.dev");
- assertThat(valid.getBody().status()).isEqualTo(DomainStatusResponse.VALID);
+ DomainStatusResponse valid = client.get().uri("/domain/status?domain={d}", "test.locqr.dev")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(DomainStatusResponse.class)
+ .returnResult()
+ .getResponseBody();
+ assertThat(valid).isNotNull();
+ assertThat(valid.status()).isEqualTo(DomainStatusResponse.VALID);
- ResponseEntity rejected = restTemplate.getForEntity(
- "/domain/status?domain={d}", DomainStatusResponse.class, "evil.example.com");
- assertThat(rejected.getBody().status()).isEqualTo(DomainStatusResponse.REJECTED);
+ DomainStatusResponse rejected = client.get().uri("/domain/status?domain={d}", "evil.example.com")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(DomainStatusResponse.class)
+ .returnResult()
+ .getResponseBody();
+ assertThat(rejected).isNotNull();
+ assertThat(rejected.status()).isEqualTo(DomainStatusResponse.REJECTED);
}
@Test
void registration_returnsTheCert_forTheTestDomain_andNotFoundForAnyOther() {
- ResponseEntity found = restTemplate.getForEntity(
- "/domain/registration?domain={d}", RegistrationResponse.class, "test.locqr.dev");
- assertThat(found.getStatusCode()).isEqualTo(HttpStatus.OK);
- assertThat(found.getBody().cert()).isEqualTo("dummy-registration-envelope");
+ RegistrationResponse found = client.get().uri("/domain/registration?domain={d}", "test.locqr.dev")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(RegistrationResponse.class)
+ .returnResult()
+ .getResponseBody();
+ assertThat(found).isNotNull();
+ assertThat(found.cert()).isEqualTo("dummy-registration-envelope");
- ResponseEntity notFound = restTemplate.getForEntity(
- "/domain/registration?domain={d}", RegistrationResponse.class, "evil.example.com");
- assertThat(notFound.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ client.get().uri("/domain/registration?domain={d}", "evil.example.com")
+ .exchange()
+ .expectStatus().isNotFound();
}
// --- Security report (interfaces.md) ---
@@ -129,9 +158,11 @@ class RunFlowIntegrationTest {
void securityReport_isAccepted_unauthenticated() {
var report = new SecurityReportRequest(UUID.randomUUID().toString(), "pin_mismatch", 1748390461L);
- ResponseEntity response = restTemplate.postForEntity("/security/report", report, Void.class);
-
- assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ client.post().uri("/security/report")
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(report)
+ .exchange()
+ .expectStatus().isOk();
}
// --- Relay: WebSocket + POST both live at /run/relay/:runId (server/claude.md) ---
@@ -149,37 +180,39 @@ class RunFlowIntegrationTest {
var buffered = new RelayMessage(RelayMessage.KEM_CIPHERTEXT, "QlVGRkVSRUQ");
// POSTed before any socket is open -> must be buffered, not dropped or rejected.
- ResponseEntity bufferedPost =
- restTemplate.postForEntity("/run/relay/{runId}", buffered, Void.class, runId);
- assertThat(bufferedPost.getStatusCode()).isEqualTo(HttpStatus.OK);
+ client.post().uri("/run/relay/{runId}", runId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(buffered)
+ .exchange()
+ .expectStatus().isOk();
BlockingQueue received = new LinkedBlockingQueue<>();
- StandardWebSocketClient client = new StandardWebSocketClient();
- WebSocketSession session = client.execute(
+ StandardWebSocketClient wsClient = new StandardWebSocketClient();
+
+ try (WebSocketSession session = wsClient.execute(
new TextWebSocketHandler() {
@Override
- protected void handleTextMessage(WebSocketSession s, TextMessage message) {
+ protected void handleTextMessage(@NonNull WebSocketSession s, @NonNull TextMessage message) {
received.add(message.getPayload());
}
},
"ws://localhost:{port}/run/relay/{runId}", port, runId)
- .get(5, TimeUnit.SECONDS);
+ .get(5, TimeUnit.SECONDS)) {
- try {
String flushedJson = received.poll(5, TimeUnit.SECONDS);
assertThat(flushedJson).isNotNull();
assertThat(objectMapper.readValue(flushedJson, RelayMessage.class)).isEqualTo(buffered);
var live = new RelayMessage(RelayMessage.CREDENTIAL, "TElWRQ");
- ResponseEntity livePost =
- restTemplate.postForEntity("/run/relay/{runId}", live, Void.class, runId);
- assertThat(livePost.getStatusCode()).isEqualTo(HttpStatus.OK);
+ client.post().uri("/run/relay/{runId}", runId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(live)
+ .exchange()
+ .expectStatus().isOk();
String liveJson = received.poll(5, TimeUnit.SECONDS);
assertThat(liveJson).isNotNull();
assertThat(objectMapper.readValue(liveJson, RelayMessage.class)).isEqualTo(live);
- } finally {
- session.close();
}
}
@@ -188,10 +221,11 @@ class RunFlowIntegrationTest {
String runId = "ws-it-invalid-" + UUID.randomUUID();
var invalid = new RelayMessage("not_a_real_type", "AAAA");
- ResponseEntity response =
- restTemplate.postForEntity("/run/relay/{runId}", invalid, Void.class, runId);
-
- assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ client.post().uri("/run/relay/{runId}", runId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(invalid)
+ .exchange()
+ .expectStatus().isBadRequest();
}
private static String randomBase64(int numBytes) {
diff --git a/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java b/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java
index 7376964..1f72018 100644
--- a/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java
+++ b/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java
@@ -9,7 +9,6 @@ import org.springframework.web.socket.WebSocketSession;
import java.util.ArrayList;
import java.util.List;
-import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
diff --git a/server/src/test/java/dev/locqr/server/store/InMemoryKeyBundleStoreTest.java b/server/src/test/java/dev/locqr/server/store/InMemoryKeyBundleStoreTest.java
index 58e94ed..f572352 100644
--- a/server/src/test/java/dev/locqr/server/store/InMemoryKeyBundleStoreTest.java
+++ b/server/src/test/java/dev/locqr/server/store/InMemoryKeyBundleStoreTest.java
@@ -41,29 +41,32 @@ class InMemoryKeyBundleStoreTest {
store.put(bundle);
int attempts = 50;
- var pool = java.util.concurrent.Executors.newFixedThreadPool(attempts);
var successes = new java.util.concurrent.atomic.AtomicInteger();
var latch = new java.util.concurrent.CountDownLatch(attempts);
- for (int i = 0; i < attempts; i++) {
- pool.submit(() -> {
- try {
- if (store.takeIfPresent(bundle.runId()).isPresent()) {
- successes.incrementAndGet();
+ try (var pool = java.util.concurrent.Executors.newFixedThreadPool(attempts)) {
+ for (int i = 0; i < attempts; i++) {
+ pool.submit(() -> {
+ try {
+ if (store.takeIfPresent(bundle.runId()).isPresent()) {
+ successes.incrementAndGet();
+ }
+ } finally {
+ latch.countDown();
}
- } finally {
- latch.countDown();
- }
- });
+ });
+ }
+ latch.await();
}
- latch.await();
- pool.shutdown();
assertThat(successes.get())
.as("ConcurrentHashMap.remove() must be atomic: exactly one concurrent fetch wins")
.isEqualTo(1);
}
+ // Only ever called with 60s in this file, so IntelliJ flags the parameter —
+ // kept anyway, since "an unexpired bundle" is the meaningful concept a
+ // reader needs, not the literal 60.
private static KeyBundle bundleExpiringIn(long seconds) {
return new KeyBundle(
"run-1", "https://test.locqr.dev", "x25519pub", "kempub", Instant.now().plusSeconds(seconds));
diff --git a/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java b/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java
index 6a7a02e..ed6bbe0 100644
--- a/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java
+++ b/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java
@@ -11,15 +11,16 @@ import static org.assertj.core.api.Assertions.assertThat;
class RegistrationCertificateStoreTest {
+ private static final String TEST_DOMAIN = "test.locqr.dev";
+
@Test
void certFor_returnsTheFileContents_forTheConfiguredTestDomain(@TempDir Path dir) throws Exception {
Path certFile = dir.resolve("test-registration.b64");
Files.writeString(certFile, "dummy-envelope\n");
- RegistrationCertificateStore store =
- new RegistrationCertificateStore(properties("test.locqr.dev", certFile.toString()));
+ RegistrationCertificateStore store = new RegistrationCertificateStore(properties(certFile.toString()));
- assertThat(store.certFor("test.locqr.dev")).contains("dummy-envelope");
+ assertThat(store.certFor(TEST_DOMAIN)).contains("dummy-envelope");
}
@Test
@@ -27,8 +28,7 @@ class RegistrationCertificateStoreTest {
Path certFile = dir.resolve("test-registration.b64");
Files.writeString(certFile, "dummy-envelope");
- RegistrationCertificateStore store =
- new RegistrationCertificateStore(properties("test.locqr.dev", certFile.toString()));
+ RegistrationCertificateStore store = new RegistrationCertificateStore(properties(certFile.toString()));
assertThat(store.certFor("evil.example.com")).isEmpty();
}
@@ -36,14 +36,14 @@ class RegistrationCertificateStoreTest {
@Test
void certFor_isEmpty_whenTheCertFileDoesNotExist_ratherThanThrowingAtStartup() {
RegistrationCertificateStore store =
- new RegistrationCertificateStore(properties("test.locqr.dev", "/definitely/does/not/exist.b64"));
+ new RegistrationCertificateStore(properties("/definitely/does/not/exist.b64"));
- assertThat(store.certFor("test.locqr.dev")).isEmpty();
+ assertThat(store.certFor(TEST_DOMAIN)).isEmpty();
}
- private static LocqrProperties properties(String testDomain, String certPath) {
+ private static LocqrProperties properties(String certPath) {
return new LocqrProperties(
- testDomain,
+ TEST_DOMAIN,
"unused",
certPath,
new LocqrProperties.Run(90, 3, 120),