spring update 4

This commit is contained in:
Rasmus Neikes 2026-08-16 14:24:03 +02:00
parent 22077d40a8
commit a914ef1ec4
11 changed files with 141 additions and 106 deletions

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<version>4.1.0</version>
<relativePath/>
</parent>

View File

@ -1,10 +1,11 @@
package dev.locqr.server.crypto;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.locqr.server.config.LocqrProperties;
import dev.locqr.server.util.Base64Url;
import org.erdtman.jcs.JsonCanonicalizer;
import org.springframework.stereotype.Service;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.io.UncheckedIOException;
@ -34,7 +35,7 @@ import java.util.Map;
public class SigningService {
private final PrivateKey ed25519SecretKey;
private final ObjectMapper objectMapper = new ObjectMapper();
private final ObjectMapper objectMapper = new JsonMapper();
public SigningService(LocqrProperties properties) {
this.ed25519SecretKey = loadEd25519SecretKey(properties.ed25519SecretKeyPath());
@ -62,8 +63,10 @@ public class SigningService {
/** RFC 8785 (JCS) canonicalization — mandatory, no bespoke implementation. */
public byte[] canonicalize(Map<String, Object> payload) {
// writeValueAsString throws the unchecked JacksonException (Jackson 3); only
// JsonCanonicalizer's own IOException (io.github.erdtman, unrelated library) needs catching.
String json = objectMapper.writeValueAsString(payload);
try {
String json = objectMapper.writeValueAsString(payload);
return new JsonCanonicalizer(json).getEncodedUTF8();
} catch (IOException e) {
throw new UncheckedIOException("Failed to JCS-canonicalize signed payload", e);
@ -114,10 +117,6 @@ public class SigningService {
}
public String toBase64UrlJson(Map<String, Object> envelope) {
try {
return Base64Url.encode(objectMapper.writeValueAsBytes(envelope));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return Base64Url.encode(objectMapper.writeValueAsBytes(envelope));
}
}

View File

@ -5,5 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
public record DomainStatusResponse(@JsonProperty("status") String status) {
public static final String VALID = "valid";
public static final String REJECTED = "rejected";
public static final String SUSPENDED = "suspended";
// "suspended" (interfaces.md) is a valid wire value but nothing in the
// current (PoC) scope ever computes it no account-suspension logic
// exists yet. Add the constant back when that logic is added.
}

View File

@ -1,13 +1,15 @@
package dev.locqr.server.relay;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.locqr.server.dto.RelayMessage;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.HttpRequestHandler;
import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
@ -26,7 +28,7 @@ public class RelayHttpRequestHandler implements HttpRequestHandler {
private final WebSocketHttpRequestHandler webSocketHandler;
private final RelaySessionRegistry registry;
private final ObjectMapper objectMapper = new ObjectMapper();
private final ObjectMapper objectMapper = new JsonMapper();
public RelayHttpRequestHandler(RelayWebSocketHandler relayWebSocketHandler, RelaySessionRegistry registry) {
this.webSocketHandler = new WebSocketHttpRequestHandler(relayWebSocketHandler);
@ -34,7 +36,7 @@ public class RelayHttpRequestHandler implements HttpRequestHandler {
}
@Override
public void handleRequest(HttpServletRequest request, HttpServletResponse response)
public void handleRequest(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response)
throws ServletException, IOException {
switch (request.getMethod()) {
case "GET" -> webSocketHandler.handleRequest(request, response);

View File

@ -1,5 +1,6 @@
package dev.locqr.server.relay;
import org.jspecify.annotations.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketSession;
@ -24,14 +25,14 @@ public class RelayWebSocketHandler extends TextWebSocketHandler {
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
public void afterConnectionEstablished(@NonNull WebSocketSession session) {
String runId = runIdFrom(session);
session.getAttributes().put(RUN_ID_ATTR, runId);
registry.register(runId, session);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) {
String runId = (String) session.getAttributes().get(RUN_ID_ATTR);
if (runId != null) {
registry.unregister(runId, session);

View File

@ -24,20 +24,20 @@ public class RegistrationCertificateStore {
private static final Logger log = LoggerFactory.getLogger(RegistrationCertificateStore.class);
private final String testDomain;
private final Optional<String> cert;
private final String cert; // null if no cert file was found at startup
public RegistrationCertificateStore(LocqrProperties properties) {
this.testDomain = properties.testDomain();
this.cert = load(properties.registrationCertPath());
}
private static Optional<String> 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);
}
}

View File

@ -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));
}
}

View File

@ -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.
*
* <p>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<RunBundleUploadResponse> 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<RunBundleFetchResponse> 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<RunBundleFetchResponse> 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<RunBundleFetchResponse> 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<DomainStatusResponse> 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<DomainStatusResponse> 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<RegistrationResponse> 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<RegistrationResponse> 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<Void> 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<Void> 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<String> 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<Void> 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<Void> 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) {

View File

@ -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;

View File

@ -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));

View File

@ -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),