spring update 4
This commit is contained in:
parent
22077d40a8
commit
a914ef1ec4
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
// 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 {
|
try {
|
||||||
String json = objectMapper.writeValueAsString(payload);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -41,29 +41,32 @@ 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);
|
||||||
|
|
||||||
for (int i = 0; i < attempts; i++) {
|
try (var pool = java.util.concurrent.Executors.newFixedThreadPool(attempts)) {
|
||||||
pool.submit(() -> {
|
for (int i = 0; i < attempts; i++) {
|
||||||
try {
|
pool.submit(() -> {
|
||||||
if (store.takeIfPresent(bundle.runId()).isPresent()) {
|
try {
|
||||||
successes.incrementAndGet();
|
if (store.takeIfPresent(bundle.runId()).isPresent()) {
|
||||||
|
successes.incrementAndGet();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
}
|
}
|
||||||
} finally {
|
});
|
||||||
latch.countDown();
|
}
|
||||||
}
|
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));
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user