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> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <version>4.1.0</version>
<relativePath/> <relativePath/>
</parent> </parent>

View File

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

View File

@ -5,5 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
public record DomainStatusResponse(@JsonProperty("status") String status) { public record DomainStatusResponse(@JsonProperty("status") String status) {
public static final String VALID = "valid"; public static final String VALID = "valid";
public static final String REJECTED = "rejected"; 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; package dev.locqr.server.relay;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.locqr.server.dto.RelayMessage; import dev.locqr.server.dto.RelayMessage;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.HttpRequestHandler; import org.springframework.web.HttpRequestHandler;
import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler; import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException; import java.io.IOException;
@ -26,7 +28,7 @@ public class RelayHttpRequestHandler implements HttpRequestHandler {
private final WebSocketHttpRequestHandler webSocketHandler; private final WebSocketHttpRequestHandler webSocketHandler;
private final RelaySessionRegistry registry; private final RelaySessionRegistry registry;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new JsonMapper();
public RelayHttpRequestHandler(RelayWebSocketHandler relayWebSocketHandler, RelaySessionRegistry registry) { public RelayHttpRequestHandler(RelayWebSocketHandler relayWebSocketHandler, RelaySessionRegistry registry) {
this.webSocketHandler = new WebSocketHttpRequestHandler(relayWebSocketHandler); this.webSocketHandler = new WebSocketHttpRequestHandler(relayWebSocketHandler);
@ -34,7 +36,7 @@ public class RelayHttpRequestHandler implements HttpRequestHandler {
} }
@Override @Override
public void handleRequest(HttpServletRequest request, HttpServletResponse response) public void handleRequest(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response)
throws ServletException, IOException { throws ServletException, IOException {
switch (request.getMethod()) { switch (request.getMethod()) {
case "GET" -> webSocketHandler.handleRequest(request, response); case "GET" -> webSocketHandler.handleRequest(request, response);

View File

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

View File

@ -24,20 +24,20 @@ public class RegistrationCertificateStore {
private static final Logger log = LoggerFactory.getLogger(RegistrationCertificateStore.class); private static final Logger log = LoggerFactory.getLogger(RegistrationCertificateStore.class);
private final String testDomain; 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) { public RegistrationCertificateStore(LocqrProperties properties) {
this.testDomain = properties.testDomain(); this.testDomain = properties.testDomain();
this.cert = load(properties.registrationCertPath()); this.cert = load(properties.registrationCertPath());
} }
private static Optional<String> load(String path) { private static String load(String path) {
try { try {
return Optional.of(Files.readString(Path.of(path)).trim()); return Files.readString(Path.of(path)).trim();
} catch (IOException e) { } catch (IOException e) {
log.warn("No registration certificate at {} — GET /domain/registration will 404 " log.warn("No registration certificate at {} — GET /domain/registration will 404 "
+ "until the dev setup script (dev.md) generates it.", path); + "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)) { if (!testDomain.equals(domain)) {
return Optional.empty(); return Optional.empty();
} }
return cert; return Optional.ofNullable(cert);
} }
} }

View File

@ -1,6 +1,5 @@
package dev.locqr.server.util; package dev.locqr.server.util;
import java.nio.charset.StandardCharsets;
import java.util.Base64; import java.util.Base64;
/** /**
@ -18,8 +17,4 @@ public final class Base64Url {
public static byte[] decode(String s) { public static byte[] decode(String s) {
return Base64.getUrlDecoder().decode(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; package dev.locqr.server;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.locqr.server.dto.*; import dev.locqr.server.dto.*;
import dev.locqr.server.support.TestEd25519Keys; 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.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; 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.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource; 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.TextMessage;
import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler; 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.Files;
import java.nio.file.Path; 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 * WebSocket stack rather than by inspecting code. A hermetic, generated
* Ed25519 key is used these tests never depend on the committed * Ed25519 key is used these tests never depend on the committed
* {@code dev/keys/} material, so they're unaffected by dev key rotation. * {@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) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RunFlowIntegrationTest { class RunFlowIntegrationTest {
@ -39,10 +45,13 @@ class RunFlowIntegrationTest {
@LocalServerPort @LocalServerPort
int port; int port;
@org.springframework.beans.factory.annotation.Autowired private RestTestClient client;
TestRestTemplate restTemplate; private final ObjectMapper objectMapper = new JsonMapper();
private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach
void setUpClient() {
client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
}
@DynamicPropertySource @DynamicPropertySource
static void dynamicKeyAndCert(DynamicPropertyRegistry registry) throws Exception { static void dynamicKeyAndCert(DynamicPropertyRegistry registry) throws Exception {
@ -65,62 +74,82 @@ class RunFlowIntegrationTest {
var upload = new RunBundleUploadRequest( var upload = new RunBundleUploadRequest(
runId, "https://test.locqr.dev", randomBase64(32), randomBase64(1184)); runId, "https://test.locqr.dev", randomBase64(32), randomBase64(1184));
ResponseEntity<RunBundleUploadResponse> uploadResponse = RunBundleUploadResponse body = client.post().uri("/run/bundle")
restTemplate.postForEntity("/run/bundle", upload, RunBundleUploadResponse.class); .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).isNotNull();
assertThat(body.qrTtl()).isEqualTo(90); assertThat(body.qrTtl()).isEqualTo(90);
assertThat(body.maxAutoRefresh()).isEqualTo(3); assertThat(body.maxAutoRefresh()).isEqualTo(3);
assertThat(body.pinTtl()).isEqualTo(120); assertThat(body.pinTtl()).isEqualTo(120);
assertThat(body.signedToken()).isNotBlank(); assertThat(body.signedToken()).isNotBlank();
ResponseEntity<RunBundleFetchResponse> firstFetch = RunBundleFetchResponse firstFetch = client.get().uri("/run/bundle/{runId}", runId)
restTemplate.getForEntity("/run/bundle/{runId}", RunBundleFetchResponse.class, runId); .exchange()
assertThat(firstFetch.getStatusCode()).isEqualTo(HttpStatus.OK); .expectStatus().isOk()
assertThat(firstFetch.getBody()).isNotNull(); .expectBody(RunBundleFetchResponse.class)
assertThat(firstFetch.getBody().x25519Pubkey()).isEqualTo(upload.x25519Pubkey()); .returnResult()
assertThat(firstFetch.getBody().kemPubkey()).isEqualTo(upload.kemPubkey()); .getResponseBody();
ResponseEntity<RunBundleFetchResponse> secondFetch = assertThat(firstFetch).isNotNull();
restTemplate.getForEntity("/run/bundle/{runId}", RunBundleFetchResponse.class, runId); assertThat(firstFetch.x25519Pubkey()).isEqualTo(upload.x25519Pubkey());
assertThat(secondFetch.getStatusCode()) assertThat(firstFetch.kemPubkey()).isEqualTo(upload.kemPubkey());
.as("at-most-once: the bundle must be gone after the first fetch")
.isEqualTo(HttpStatus.NOT_FOUND); // at-most-once: the bundle must be gone after the first fetch
client.get().uri("/run/bundle/{runId}", runId)
.exchange()
.expectStatus().isNotFound();
} }
@Test @Test
void fetchingAnUnknownRunId_returnsNotFound() { void fetchingAnUnknownRunId_returnsNotFound() {
ResponseEntity<RunBundleFetchResponse> response = restTemplate.getForEntity( client.get().uri("/run/bundle/{runId}", "never-uploaded-" + UUID.randomUUID())
"/run/bundle/{runId}", RunBundleFetchResponse.class, "never-uploaded-" + UUID.randomUUID()); .exchange()
.expectStatus().isNotFound();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
} }
// --- Domain status / registration (interfaces.md) --- // --- Domain status / registration (interfaces.md) ---
@Test @Test
void domainStatus_isValidForTheTestDomain_andRejectedForAnyOther() { void domainStatus_isValidForTheTestDomain_andRejectedForAnyOther() {
ResponseEntity<DomainStatusResponse> valid = restTemplate.getForEntity( DomainStatusResponse valid = client.get().uri("/domain/status?domain={d}", "test.locqr.dev")
"/domain/status?domain={d}", DomainStatusResponse.class, "test.locqr.dev"); .exchange()
assertThat(valid.getBody().status()).isEqualTo(DomainStatusResponse.VALID); .expectStatus().isOk()
.expectBody(DomainStatusResponse.class)
.returnResult()
.getResponseBody();
assertThat(valid).isNotNull();
assertThat(valid.status()).isEqualTo(DomainStatusResponse.VALID);
ResponseEntity<DomainStatusResponse> rejected = restTemplate.getForEntity( DomainStatusResponse rejected = client.get().uri("/domain/status?domain={d}", "evil.example.com")
"/domain/status?domain={d}", DomainStatusResponse.class, "evil.example.com"); .exchange()
assertThat(rejected.getBody().status()).isEqualTo(DomainStatusResponse.REJECTED); .expectStatus().isOk()
.expectBody(DomainStatusResponse.class)
.returnResult()
.getResponseBody();
assertThat(rejected).isNotNull();
assertThat(rejected.status()).isEqualTo(DomainStatusResponse.REJECTED);
} }
@Test @Test
void registration_returnsTheCert_forTheTestDomain_andNotFoundForAnyOther() { void registration_returnsTheCert_forTheTestDomain_andNotFoundForAnyOther() {
ResponseEntity<RegistrationResponse> found = restTemplate.getForEntity( RegistrationResponse found = client.get().uri("/domain/registration?domain={d}", "test.locqr.dev")
"/domain/registration?domain={d}", RegistrationResponse.class, "test.locqr.dev"); .exchange()
assertThat(found.getStatusCode()).isEqualTo(HttpStatus.OK); .expectStatus().isOk()
assertThat(found.getBody().cert()).isEqualTo("dummy-registration-envelope"); .expectBody(RegistrationResponse.class)
.returnResult()
.getResponseBody();
assertThat(found).isNotNull();
assertThat(found.cert()).isEqualTo("dummy-registration-envelope");
ResponseEntity<RegistrationResponse> notFound = restTemplate.getForEntity( client.get().uri("/domain/registration?domain={d}", "evil.example.com")
"/domain/registration?domain={d}", RegistrationResponse.class, "evil.example.com"); .exchange()
assertThat(notFound.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); .expectStatus().isNotFound();
} }
// --- Security report (interfaces.md) --- // --- Security report (interfaces.md) ---
@ -129,9 +158,11 @@ class RunFlowIntegrationTest {
void securityReport_isAccepted_unauthenticated() { void securityReport_isAccepted_unauthenticated() {
var report = new SecurityReportRequest(UUID.randomUUID().toString(), "pin_mismatch", 1748390461L); var report = new SecurityReportRequest(UUID.randomUUID().toString(), "pin_mismatch", 1748390461L);
ResponseEntity<Void> response = restTemplate.postForEntity("/security/report", report, Void.class); client.post().uri("/security/report")
.contentType(MediaType.APPLICATION_JSON)
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); .body(report)
.exchange()
.expectStatus().isOk();
} }
// --- Relay: WebSocket + POST both live at /run/relay/:runId (server/claude.md) --- // --- 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"); var buffered = new RelayMessage(RelayMessage.KEM_CIPHERTEXT, "QlVGRkVSRUQ");
// POSTed before any socket is open -> must be buffered, not dropped or rejected. // POSTed before any socket is open -> must be buffered, not dropped or rejected.
ResponseEntity<Void> bufferedPost = client.post().uri("/run/relay/{runId}", runId)
restTemplate.postForEntity("/run/relay/{runId}", buffered, Void.class, runId); .contentType(MediaType.APPLICATION_JSON)
assertThat(bufferedPost.getStatusCode()).isEqualTo(HttpStatus.OK); .body(buffered)
.exchange()
.expectStatus().isOk();
BlockingQueue<String> received = new LinkedBlockingQueue<>(); BlockingQueue<String> received = new LinkedBlockingQueue<>();
StandardWebSocketClient client = new StandardWebSocketClient(); StandardWebSocketClient wsClient = new StandardWebSocketClient();
WebSocketSession session = client.execute(
try (WebSocketSession session = wsClient.execute(
new TextWebSocketHandler() { new TextWebSocketHandler() {
@Override @Override
protected void handleTextMessage(WebSocketSession s, TextMessage message) { protected void handleTextMessage(@NonNull WebSocketSession s, @NonNull TextMessage message) {
received.add(message.getPayload()); received.add(message.getPayload());
} }
}, },
"ws://localhost:{port}/run/relay/{runId}", port, runId) "ws://localhost:{port}/run/relay/{runId}", port, runId)
.get(5, TimeUnit.SECONDS); .get(5, TimeUnit.SECONDS)) {
try {
String flushedJson = received.poll(5, TimeUnit.SECONDS); String flushedJson = received.poll(5, TimeUnit.SECONDS);
assertThat(flushedJson).isNotNull(); assertThat(flushedJson).isNotNull();
assertThat(objectMapper.readValue(flushedJson, RelayMessage.class)).isEqualTo(buffered); assertThat(objectMapper.readValue(flushedJson, RelayMessage.class)).isEqualTo(buffered);
var live = new RelayMessage(RelayMessage.CREDENTIAL, "TElWRQ"); var live = new RelayMessage(RelayMessage.CREDENTIAL, "TElWRQ");
ResponseEntity<Void> livePost = client.post().uri("/run/relay/{runId}", runId)
restTemplate.postForEntity("/run/relay/{runId}", live, Void.class, runId); .contentType(MediaType.APPLICATION_JSON)
assertThat(livePost.getStatusCode()).isEqualTo(HttpStatus.OK); .body(live)
.exchange()
.expectStatus().isOk();
String liveJson = received.poll(5, TimeUnit.SECONDS); String liveJson = received.poll(5, TimeUnit.SECONDS);
assertThat(liveJson).isNotNull(); assertThat(liveJson).isNotNull();
assertThat(objectMapper.readValue(liveJson, RelayMessage.class)).isEqualTo(live); assertThat(objectMapper.readValue(liveJson, RelayMessage.class)).isEqualTo(live);
} finally {
session.close();
} }
} }
@ -188,10 +221,11 @@ class RunFlowIntegrationTest {
String runId = "ws-it-invalid-" + UUID.randomUUID(); String runId = "ws-it-invalid-" + UUID.randomUUID();
var invalid = new RelayMessage("not_a_real_type", "AAAA"); var invalid = new RelayMessage("not_a_real_type", "AAAA");
ResponseEntity<Void> response = client.post().uri("/run/relay/{runId}", runId)
restTemplate.postForEntity("/run/relay/{runId}", invalid, Void.class, runId); .contentType(MediaType.APPLICATION_JSON)
.body(invalid)
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); .exchange()
.expectStatus().isBadRequest();
} }
private static String randomBase64(int numBytes) { 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.ArrayList;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;

View File

@ -41,10 +41,10 @@ class InMemoryKeyBundleStoreTest {
store.put(bundle); store.put(bundle);
int attempts = 50; int attempts = 50;
var pool = java.util.concurrent.Executors.newFixedThreadPool(attempts);
var successes = new java.util.concurrent.atomic.AtomicInteger(); var successes = new java.util.concurrent.atomic.AtomicInteger();
var latch = new java.util.concurrent.CountDownLatch(attempts); var latch = new java.util.concurrent.CountDownLatch(attempts);
try (var pool = java.util.concurrent.Executors.newFixedThreadPool(attempts)) {
for (int i = 0; i < attempts; i++) { for (int i = 0; i < attempts; i++) {
pool.submit(() -> { pool.submit(() -> {
try { try {
@ -57,13 +57,16 @@ class InMemoryKeyBundleStoreTest {
}); });
} }
latch.await(); latch.await();
pool.shutdown(); }
assertThat(successes.get()) assertThat(successes.get())
.as("ConcurrentHashMap.remove() must be atomic: exactly one concurrent fetch wins") .as("ConcurrentHashMap.remove() must be atomic: exactly one concurrent fetch wins")
.isEqualTo(1); .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) { private static KeyBundle bundleExpiringIn(long seconds) {
return new KeyBundle( return new KeyBundle(
"run-1", "https://test.locqr.dev", "x25519pub", "kempub", Instant.now().plusSeconds(seconds)); "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 { class RegistrationCertificateStoreTest {
private static final String TEST_DOMAIN = "test.locqr.dev";
@Test @Test
void certFor_returnsTheFileContents_forTheConfiguredTestDomain(@TempDir Path dir) throws Exception { void certFor_returnsTheFileContents_forTheConfiguredTestDomain(@TempDir Path dir) throws Exception {
Path certFile = dir.resolve("test-registration.b64"); Path certFile = dir.resolve("test-registration.b64");
Files.writeString(certFile, "dummy-envelope\n"); Files.writeString(certFile, "dummy-envelope\n");
RegistrationCertificateStore store = RegistrationCertificateStore store = new RegistrationCertificateStore(properties(certFile.toString()));
new RegistrationCertificateStore(properties("test.locqr.dev", certFile.toString()));
assertThat(store.certFor("test.locqr.dev")).contains("dummy-envelope"); assertThat(store.certFor(TEST_DOMAIN)).contains("dummy-envelope");
} }
@Test @Test
@ -27,8 +28,7 @@ class RegistrationCertificateStoreTest {
Path certFile = dir.resolve("test-registration.b64"); Path certFile = dir.resolve("test-registration.b64");
Files.writeString(certFile, "dummy-envelope"); Files.writeString(certFile, "dummy-envelope");
RegistrationCertificateStore store = RegistrationCertificateStore store = new RegistrationCertificateStore(properties(certFile.toString()));
new RegistrationCertificateStore(properties("test.locqr.dev", certFile.toString()));
assertThat(store.certFor("evil.example.com")).isEmpty(); assertThat(store.certFor("evil.example.com")).isEmpty();
} }
@ -36,14 +36,14 @@ class RegistrationCertificateStoreTest {
@Test @Test
void certFor_isEmpty_whenTheCertFileDoesNotExist_ratherThanThrowingAtStartup() { void certFor_isEmpty_whenTheCertFileDoesNotExist_ratherThanThrowingAtStartup() {
RegistrationCertificateStore store = 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( return new LocqrProperties(
testDomain, TEST_DOMAIN,
"unused", "unused",
certPath, certPath,
new LocqrProperties.Run(90, 3, 120), new LocqrProperties.Run(90, 3, 120),