Add the sse-websocket module
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.web.servlet.client.RestTestClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** The property overrides the container default, in milliseconds, exactly as written. */
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"dashboard.broadcast.enabled=false", "spring.mvc.async.request-timeout=5s"})
|
||||
class AsyncTimeoutPropertyTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void propertyOverridesTheContainer() {
|
||||
RestTestClient client = RestTestClient.bindToServer()
|
||||
.baseUrl("http://localhost:" + port).build();
|
||||
client.get().uri("/diag/async-start").exchange().expectStatus().isOk();
|
||||
String body = client.get().uri("/diag/async-timeout").exchange()
|
||||
.expectStatus().isOk().expectBody(String.class).returnResult().getResponseBody();
|
||||
|
||||
System.out.println("=== spring.mvc.async.request-timeout=5s ===");
|
||||
System.out.println(body);
|
||||
assertThat(body).contains("\"effectiveAsyncTimeoutMs\":5000");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.web.servlet.client.RestTestClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* With {@code spring.mvc.async.request-timeout} absent, the servlet container's default decides
|
||||
* how long an SSE stream lives. This reads that number off the live {@code AsyncContext} rather
|
||||
* than quoting it.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class AsyncTimeoutUnsetTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void containerDefaultApplies() {
|
||||
RestTestClient client = RestTestClient.bindToServer()
|
||||
.baseUrl("http://localhost:" + port).build();
|
||||
client.get().uri("/diag/async-start").exchange().expectStatus().isOk();
|
||||
String body = client.get().uri("/diag/async-timeout").exchange()
|
||||
.expectStatus().isOk().expectBody(String.class).returnResult().getResponseBody();
|
||||
|
||||
System.out.println("=== spring.mvc.async.request-timeout ABSENT ===");
|
||||
System.out.println(body);
|
||||
assertThat(body).contains("\"spring.mvc.async.request-timeout\":\"<unset>\"");
|
||||
assertThat(body).contains("\"effectiveAsyncTimeoutMs\":30000");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import jakarta.websocket.ContainerProvider;
|
||||
import jakarta.websocket.WebSocketContainer;
|
||||
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
|
||||
import org.springframework.messaging.simp.stomp.StompSession;
|
||||
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
|
||||
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||
import org.springframework.web.socket.messaging.WebSocketStompClient;
|
||||
|
||||
/**
|
||||
* Sends one STOMP message of a given body size and reports whether it arrived.
|
||||
*
|
||||
* <p>The client's own buffers are raised to 1 MB first. Without that the client hits the
|
||||
* identical 8 KB default and closes its own session, which looks exactly like the server
|
||||
* rejecting the message — a false positive that cost a debugging round while writing this.
|
||||
*/
|
||||
final class SizeProbe {
|
||||
|
||||
private SizeProbe() {
|
||||
}
|
||||
|
||||
static String attempt(int port, int size) throws Exception {
|
||||
WebSocketContainer container = ContainerProvider.getWebSocketContainer();
|
||||
container.setDefaultMaxTextMessageBufferSize(1024 * 1024);
|
||||
WebSocketStompClient client = new WebSocketStompClient(new StandardWebSocketClient(container));
|
||||
client.setMessageConverter(new JacksonJsonMessageConverter());
|
||||
client.setInboundMessageSizeLimit(1024 * 1024);
|
||||
|
||||
StompSession session = client.connectAsync("ws://localhost:" + port + "/ws",
|
||||
new StompSessionHandlerAdapter() { }).get();
|
||||
BlockingQueue<ChatMessage> inbox =
|
||||
StompTestSupport.subscribe(session, "/topic/room", ChatMessage.class);
|
||||
Thread.sleep(200);
|
||||
|
||||
String result;
|
||||
try {
|
||||
session.send("/app/chat.send", new ChatMessage("bulk", "x".repeat(size), Instant.now()));
|
||||
ChatMessage received = inbox.poll(4, TimeUnit.SECONDS);
|
||||
result = received != null
|
||||
? "delivered, body length " + received.text().length()
|
||||
: "NOT delivered, connection dropped";
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Throwable root = ex;
|
||||
while (root.getCause() != null) {
|
||||
root = root.getCause();
|
||||
}
|
||||
result = "NOT delivered: " + root.getMessage();
|
||||
}
|
||||
client.stop();
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Binary search for the largest body that still arrives. */
|
||||
static int[] ceiling(int port, int lo, int hi) throws Exception {
|
||||
while (hi - lo > 64) {
|
||||
int mid = (lo + hi) / 2;
|
||||
if (attempt(port, mid).startsWith("delivered")) { lo = mid; } else { hi = mid; }
|
||||
}
|
||||
return new int[] {lo, hi};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A raw-socket SSE client.
|
||||
*
|
||||
* <p>Deliberately not an HTTP client library: the article quotes the bytes on the wire, and any
|
||||
* client that parses events for you hides exactly the thing being shown. It also lets a test
|
||||
* abandon the socket mid-stream, which is how the disconnect case is reproduced.
|
||||
*/
|
||||
final class SseClient implements AutoCloseable {
|
||||
|
||||
private final Socket socket;
|
||||
private final BufferedReader reader;
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
|
||||
SseClient(int port, String path) throws Exception {
|
||||
this(port, path, null);
|
||||
}
|
||||
|
||||
SseClient(int port, String path, String lastEventId) throws Exception {
|
||||
this.socket = new Socket("127.0.0.1", port);
|
||||
this.socket.setSoTimeout(15_000);
|
||||
StringBuilder req = new StringBuilder()
|
||||
.append("GET ").append(path).append(" HTTP/1.1\r\n")
|
||||
.append("Host: 127.0.0.1:").append(port).append("\r\n")
|
||||
.append("Accept: text/event-stream\r\n");
|
||||
if (lastEventId != null) {
|
||||
req.append("Last-Event-ID: ").append(lastEventId).append("\r\n");
|
||||
}
|
||||
req.append("Connection: close\r\n\r\n");
|
||||
OutputStream out = socket.getOutputStream();
|
||||
out.write(req.toString().getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
this.reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** Read until the stream closes or {@code max} lines have arrived. Records everything. */
|
||||
List<String> read(int max) throws Exception {
|
||||
String line;
|
||||
while (lines.size() < max && (line = reader.readLine()) != null) {
|
||||
lines.add(line);
|
||||
}
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
|
||||
/** Read everything until the server closes the connection. */
|
||||
List<String> readToEnd() throws Exception {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
lines.add(line);
|
||||
}
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
|
||||
List<String> lines() {
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.apache.tomcat.util.threads.ThreadPoolExecutor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.boot.tomcat.TomcatWebServer;
|
||||
import org.springframework.boot.web.server.context.WebServerApplicationContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Forty open SSE streams on a ten-thread Tomcat.
|
||||
*
|
||||
* <p>This is the test that answers the objection people raise first — "doesn't SSE pin a
|
||||
* thread per client?" It does not. {@code SseEmitter} puts the request into asynchronous mode,
|
||||
* the request thread returns to the pool, and the response stays open with no thread attached to
|
||||
* it. Forty concurrent streams on a pool of ten is the cheapest way to show that; the same test
|
||||
* with a blocking handler deadlocks at the eleventh client.
|
||||
*
|
||||
* <p>What SSE does cost is a <em>socket</em> per stream, plus one serialise-and-write per stream
|
||||
* per broadcast, both of which the transcript makes visible.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"dashboard.broadcast.enabled=false", "server.tomcat.threads.max=10"})
|
||||
class SseConcurrencyTest {
|
||||
|
||||
private static final int CLIENTS = 40;
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Autowired
|
||||
EmitterRegistry registry;
|
||||
|
||||
@Autowired
|
||||
WebServerApplicationContext context;
|
||||
|
||||
@Test
|
||||
void fortyStreamsOnTenThreads() throws Exception {
|
||||
List<SseClient> clients = new ArrayList<>();
|
||||
try {
|
||||
for (int i = 0; i < CLIENTS; i++) {
|
||||
clients.add(new SseClient(port, "/sse/metrics"));
|
||||
}
|
||||
for (int i = 0; i < 200 && registry.open() < CLIENTS; i++) {
|
||||
Thread.sleep(25);
|
||||
}
|
||||
|
||||
// Read the pool size off THIS context's own connector. Counting threads by name
|
||||
// does not work here: other @SpringBootTest contexts in the same JVM have their own
|
||||
// http-nio pools, and with a random port Tomcat names its threads http-nio-auto-N,
|
||||
// not http-nio-<actual port>. That mismatch made this assertion pass alone and fail
|
||||
// in the full suite.
|
||||
TomcatWebServer server = (TomcatWebServer) context.getWebServer();
|
||||
ThreadPoolExecutor pool = (ThreadPoolExecutor) server.getTomcat().getConnector()
|
||||
.getProtocolHandler().getExecutor();
|
||||
|
||||
System.out.println("=== server.tomcat.threads.max=10, clients=" + CLIENTS + " ===");
|
||||
System.out.println("emitters open : " + registry.open());
|
||||
System.out.println("connector pool size : " + pool.getPoolSize()
|
||||
+ " (max " + pool.getMaximumPoolSize() + ")");
|
||||
System.out.println("connector active : " + pool.getActiveCount());
|
||||
|
||||
assertThat(registry.open()).isEqualTo(CLIENTS);
|
||||
assertThat(pool.getMaximumPoolSize()).isEqualTo(10);
|
||||
assertThat(pool.getPoolSize()).isLessThanOrEqualTo(10);
|
||||
|
||||
int delivered = registry.broadcast("metric",
|
||||
new MetricSnapshot(1, "node-a", 0.5, 300, Instant.now()));
|
||||
System.out.println("one broadcast reached: " + delivered + " streams");
|
||||
assertThat(delivered).isEqualTo(CLIENTS);
|
||||
|
||||
int received = 0;
|
||||
for (SseClient c : clients) {
|
||||
if (String.join("\n", c.read(12)).contains("event:metric")) {
|
||||
received++;
|
||||
}
|
||||
}
|
||||
System.out.println("clients that read it : " + received);
|
||||
assertThat(received).isEqualTo(CLIENTS);
|
||||
}
|
||||
finally {
|
||||
for (SseClient c : clients) {
|
||||
try { c.close(); } catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* {@code .data(object)} and {@code .data(object, APPLICATION_JSON)} are not the same call.
|
||||
* This test prints both results side by side.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class SseDataConversionTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void mediaTypeDecidesTheConverter() throws Exception {
|
||||
List<String> lines;
|
||||
try (SseClient client = new SseClient(port, "/sse/payload")) {
|
||||
lines = client.readToEnd();
|
||||
}
|
||||
|
||||
String withJson = dataAfter(lines, "event:with-json");
|
||||
String noMediaType = dataAfter(lines, "event:no-media-type");
|
||||
|
||||
System.out.println("=== SseEmitter.event().data(record, APPLICATION_JSON) ===");
|
||||
System.out.println(withJson);
|
||||
System.out.println("=== SseEmitter.event().data(record) [no media type] ===");
|
||||
System.out.println(noMediaType);
|
||||
|
||||
assertThat(withJson).startsWith("{").contains("\"seq\":7").contains("\"host\":\"node-a\"");
|
||||
|
||||
// The finding: for a POJO they are IDENTICAL. No media type does not mean toString().
|
||||
// send() asks the configured HttpMessageConverters which one canWrite(type, null), and
|
||||
// for a record the first (and only) answer is the Jackson converter.
|
||||
System.out.println("=== POJO: identical? " + withJson.equals(noMediaType) + " ===");
|
||||
assertThat(noMediaType).isEqualTo(withJson);
|
||||
|
||||
String stringPlain = dataAfter(lines, "event:string-plain");
|
||||
String stringJson = dataAfter(lines, "event:string-json");
|
||||
System.out.println("=== String payload, no media type -> " + stringPlain);
|
||||
System.out.println("=== String payload, APPLICATION_JSON -> " + stringJson);
|
||||
|
||||
// The finding, and it is the opposite of what I expected when writing this test: for a
|
||||
// String the media type argument changes NOTHING. StringHttpMessageConverter supports
|
||||
// MediaType.ALL and sits ahead of the Jackson converter in the list, so it claims the
|
||||
// write even when you ask for application/json. There is no way to make send() quote a
|
||||
// String -- which is good news if you are streaming pre-rendered JSON, and a trap if you
|
||||
// expected a String field to be escaped for you.
|
||||
assertThat(stringPlain).isEqualTo("{\"already\":\"json\"}");
|
||||
assertThat(stringJson).isEqualTo(stringPlain);
|
||||
}
|
||||
|
||||
private static String dataAfter(List<String> lines, String eventLine) {
|
||||
int i = lines.indexOf(eventLine);
|
||||
assertThat(i).as("event line %s present", eventLine).isGreaterThanOrEqualTo(0);
|
||||
for (int j = i; j < lines.size(); j++) {
|
||||
if (lines.get(j).startsWith("data:")) {
|
||||
return lines.get(j).substring("data:".length());
|
||||
}
|
||||
}
|
||||
throw new AssertionError("no data line after " + eventLine);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* A disconnected SSE client is discovered by a failed write, not by an event.
|
||||
*
|
||||
* <p>This test opens a stream, walks away from the socket, and then counts how many broadcasts
|
||||
* it takes before the server notices. The number is not one, and that is the whole problem: for
|
||||
* that many ticks the server serialised a payload and wrote it into a socket nobody was reading.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class SseDisconnectTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Autowired
|
||||
EmitterRegistry registry;
|
||||
|
||||
@Test
|
||||
void deadClientIsFoundOnTheNextWriteOrTheOneAfter() throws Exception {
|
||||
SseClient client = new SseClient(port, "/sse/metrics");
|
||||
// Wait for the emitter to be registered.
|
||||
for (int i = 0; i < 100 && registry.open() == 0; i++) {
|
||||
Thread.sleep(20);
|
||||
}
|
||||
assertThat(registry.open()).isEqualTo(1);
|
||||
|
||||
long failedBefore = registry.sendsFailed();
|
||||
client.close(); // the browser tab closes
|
||||
|
||||
int broadcastsUntilNoticed = 0;
|
||||
for (int i = 1; i <= 50 && registry.open() > 0; i++) {
|
||||
registry.broadcast("metric", new MetricSnapshot(i, "node-a", 0.1, 256, Instant.now()));
|
||||
broadcastsUntilNoticed = i;
|
||||
Thread.sleep(50);
|
||||
}
|
||||
|
||||
System.out.println("=== client closed the socket, then: ===");
|
||||
System.out.println("broadcasts before the write failed : " + broadcastsUntilNoticed);
|
||||
System.out.println("registry.sendsOk : " + registry.sendsOk());
|
||||
System.out.println("registry.sendsFailed : " + failedBefore + " -> " + registry.sendsFailed());
|
||||
System.out.println("registry.open : " + registry.open());
|
||||
|
||||
assertThat(registry.open()).isZero();
|
||||
assertThat(registry.sendsFailed()).isGreaterThan(failedBefore);
|
||||
// The interesting assertion: it took more than one write. TCP accepted the first
|
||||
// payloads into the socket buffer and only reported the reset afterwards.
|
||||
System.out.println("=== writes that appeared to succeed after the client was gone: "
|
||||
+ (broadcastsUntilNoticed - 1) + " ===");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* What a client sees when the async request times out mid-stream.
|
||||
*
|
||||
* <p>The answer is the point: nothing. No error event, no status code, no trailer — the
|
||||
* response was committed with 200 when the first byte went out, so the only thing left to do is
|
||||
* close the socket. A browser's EventSource treats that as a normal disconnect and reconnects,
|
||||
* which is why an SSE endpoint with a 30-second container timeout looks like it works.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class SseTimeoutTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Autowired
|
||||
EmitterRegistry registry;
|
||||
|
||||
@Test
|
||||
void timeoutIsInvisibleToTheClient() throws Exception {
|
||||
long before = registry.timedOut();
|
||||
|
||||
List<String> lines;
|
||||
long start = System.nanoTime();
|
||||
try (SseClient client = new SseClient(port, "/sse/silent?timeoutMs=1200")) {
|
||||
lines = client.readToEnd();
|
||||
}
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
System.out.println("=== GET /sse/silent?timeoutMs=1200 : whole response ===");
|
||||
lines.forEach(l -> System.out.println("| " + l));
|
||||
System.out.println("=== stream ended after ~" + elapsedMs + " ms ===");
|
||||
|
||||
String whole = String.join("\n", lines);
|
||||
assertThat(whole).contains("HTTP/1.1 200");
|
||||
assertThat(whole).contains("data:then silence");
|
||||
|
||||
// Nothing marks the timeout. No 503, no error event, no "AsyncRequestTimeoutException".
|
||||
assertThat(whole).doesNotContain("503");
|
||||
assertThat(whole).doesNotContain("event:error");
|
||||
assertThat(whole).doesNotContainIgnoringCase("timeout");
|
||||
|
||||
// Server-side it is not invisible: onTimeout ran.
|
||||
Thread.sleep(300);
|
||||
System.out.println("=== registry.timedOut(): " + before + " -> " + registry.timedOut() + " ===");
|
||||
assertThat(registry.timedOut()).isGreaterThan(before);
|
||||
assertThat(elapsedMs).isBetween(900L, 6000L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* What SSE actually looks like on the wire. Everything in the article's "the format" section is
|
||||
* a quote from this test's output.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class SseWireFormatTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void wireFormat() throws Exception {
|
||||
List<String> lines;
|
||||
try (SseClient client = new SseClient(port, "/sse/raw")) {
|
||||
lines = client.readToEnd();
|
||||
}
|
||||
|
||||
System.out.println("=== GET /sse/raw : raw bytes, one line per row ===");
|
||||
lines.forEach(l -> System.out.println("| " + l));
|
||||
|
||||
String head = String.join("\n", lines);
|
||||
|
||||
// Content-Type is text/event-stream, and there is NO Content-Length: the response is
|
||||
// chunked because its length is unknowable when the headers are written.
|
||||
assertThat(head).contains("HTTP/1.1 200");
|
||||
assertThat(head.toLowerCase()).contains("content-type: text/event-stream");
|
||||
assertThat(head.toLowerCase()).doesNotContain("content-length:");
|
||||
assertThat(head.toLowerCase()).contains("transfer-encoding: chunked");
|
||||
|
||||
// The four field names, and the comment line that keeps proxies from reaping an idle
|
||||
// stream. A line starting with ':' is a comment and is discarded by the client.
|
||||
assertThat(lines).anyMatch(l -> l.equals(":stream open"));
|
||||
assertThat(lines).anyMatch(l -> l.equals("id:1"));
|
||||
assertThat(lines).anyMatch(l -> l.equals("event:metric"));
|
||||
assertThat(lines).anyMatch(l -> l.equals("retry:3000"));
|
||||
assertThat(lines).anyMatch(l -> l.startsWith("data:{"));
|
||||
|
||||
// Multi-line data becomes SEVERAL data: lines; the client rejoins them with "\n".
|
||||
assertThat(lines).contains("data:line one");
|
||||
assertThat(lines).contains("data:line two");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.messaging.simp.stomp.StompSession;
|
||||
import org.springframework.web.socket.messaging.WebSocketStompClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Two clients, one room. Everything a chat needs that SSE cannot do: the client speaks, and the
|
||||
* server knows who is connected.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class StompChatTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Autowired
|
||||
PresenceListener presence;
|
||||
|
||||
@Test
|
||||
void twoClientsInOneRoom() throws Exception {
|
||||
presence.clear();
|
||||
WebSocketStompClient client = StompTestSupport.client();
|
||||
|
||||
StompSession alice = StompTestSupport.connect(client, port);
|
||||
StompSession bob = StompTestSupport.connect(client, port);
|
||||
|
||||
BlockingQueue<ChatMessage> aliceInbox =
|
||||
StompTestSupport.subscribe(alice, "/topic/room", ChatMessage.class);
|
||||
BlockingQueue<ChatMessage> bobInbox =
|
||||
StompTestSupport.subscribe(bob, "/topic/room", ChatMessage.class);
|
||||
Thread.sleep(300);
|
||||
|
||||
alice.send("/app/chat.send", new ChatMessage("alice", "is this thing on?", Instant.now()));
|
||||
|
||||
ChatMessage atAlice = aliceInbox.poll(5, TimeUnit.SECONDS);
|
||||
ChatMessage atBob = bobInbox.poll(5, TimeUnit.SECONDS);
|
||||
|
||||
System.out.println("=== SEND /app/chat.send -> @SendTo(\"/topic/room\") ===");
|
||||
System.out.println("alice received : " + atAlice);
|
||||
System.out.println("bob received : " + atBob);
|
||||
|
||||
// The sender gets its own message back. There is no echo suppression: a topic is a fan-out
|
||||
// to every subscriber, and the sender is one of them.
|
||||
assertThat(atAlice).isNotNull();
|
||||
assertThat(atBob).isNotNull();
|
||||
assertThat(atAlice.text()).isEqualTo("is this thing on?");
|
||||
assertThat(atAlice).isEqualTo(atBob);
|
||||
|
||||
Thread.sleep(300);
|
||||
System.out.println("=== presence events the server saw ===");
|
||||
presence.events().forEach(e -> System.out.println("| " + e));
|
||||
assertThat(presence.events()).anyMatch(e -> e.startsWith("CONNECTED"));
|
||||
assertThat(presence.events()).anyMatch(e -> e.contains("SUBSCRIBE") && e.contains("/topic/room"));
|
||||
|
||||
alice.disconnect();
|
||||
bob.disconnect();
|
||||
Thread.sleep(500);
|
||||
presence.events().stream().filter(e -> e.startsWith("DISCONNECT"))
|
||||
.forEach(e -> System.out.println("| " + e));
|
||||
assertThat(presence.events()).anyMatch(e -> e.startsWith("DISCONNECT"));
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.messaging.simp.stomp.StompSession;
|
||||
import org.springframework.web.socket.messaging.WebSocketStompClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The two routing rules that eat an afternoon each.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class StompRoutingTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
/**
|
||||
* A handler with no {@code @SendTo} is not private. Its return value goes to the broker
|
||||
* prefix plus the mapping.
|
||||
*/
|
||||
@Test
|
||||
void defaultDestinationIsTopicPlusMapping() throws Exception {
|
||||
WebSocketStompClient client = StompTestSupport.client();
|
||||
StompSession session = StompTestSupport.connect(client, port);
|
||||
|
||||
BlockingQueue<ChatMessage> guessed =
|
||||
StompTestSupport.subscribe(session, "/topic/chat.echo", ChatMessage.class);
|
||||
Thread.sleep(300);
|
||||
|
||||
session.send("/app/chat.echo", new ChatMessage("mallory", "who can see this?", Instant.now()));
|
||||
|
||||
ChatMessage received = guessed.poll(5, TimeUnit.SECONDS);
|
||||
System.out.println("=== @MessageMapping(\"/chat.echo\") with NO @SendTo ===");
|
||||
System.out.println("subscribed to /topic/chat.echo, received: " + received);
|
||||
assertThat(received).isNotNull();
|
||||
assertThat(received.text()).isEqualTo("who can see this?");
|
||||
client.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sending straight to a broker destination bypasses every controller. No handler runs, no
|
||||
* validation runs, and nothing reports that anything was skipped — subscribers just get
|
||||
* whatever the client sent.
|
||||
*/
|
||||
@Test
|
||||
void sendingToTopicSkipsTheController() throws Exception {
|
||||
WebSocketStompClient client = StompTestSupport.client();
|
||||
StompSession session = StompTestSupport.connect(client, port);
|
||||
|
||||
BlockingQueue<ChatMessage> inbox =
|
||||
StompTestSupport.subscribe(session, "/topic/room", ChatMessage.class);
|
||||
Thread.sleep(300);
|
||||
|
||||
// Note the destination: /topic/room, NOT /app/chat.send. The controller never sees it.
|
||||
ChatMessage forged = new ChatMessage("not-checked-by-anyone", "unvalidated", Instant.parse("2000-01-01T00:00:00Z"));
|
||||
session.send("/topic/room", forged);
|
||||
|
||||
ChatMessage received = inbox.poll(5, TimeUnit.SECONDS);
|
||||
System.out.println("=== SEND straight to /topic/room ===");
|
||||
System.out.println("received: " + received);
|
||||
assertThat(received).isNotNull();
|
||||
// The controller would have replaced the timestamp with Instant.now(). It did not run.
|
||||
assertThat(received.at()).isEqualTo(Instant.parse("2000-01-01T00:00:00Z"));
|
||||
assertThat(received.from()).isEqualTo("not-checked-by-anyone");
|
||||
System.out.println("=== the controller's Instant.now() rewrite did NOT happen ===");
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Where a STOMP message really stops being deliverable, with nothing configured.
|
||||
*
|
||||
* <p>The documented knob is {@code WebSocketTransportRegistration.setMessageSizeLimit}, 64 KB
|
||||
* by default and set explicitly in {@link WebSocketConfig}. The limit that actually fires is the
|
||||
* servlet container's WebSocket buffer, and it fires as a connection close (WebSocket status
|
||||
* 1009) rather than as an error the application can see — so raising Spring's limit alone
|
||||
* changes nothing at all.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class StompSizeLimitDefaultBufferTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void theCeilingIsWellUnderSpringsSixtyFourKilobytes() throws Exception {
|
||||
System.out.println("=== container buffer: Tomcat default (8192, see /diag) ===");
|
||||
System.out.println(" 8 KB body -> " + SizeProbe.attempt(port, 8 * 1024));
|
||||
System.out.println(" 16 KB body -> " + SizeProbe.attempt(port, 16 * 1024));
|
||||
System.out.println(" 60 KB body -> " + SizeProbe.attempt(port, 60 * 1024));
|
||||
|
||||
int[] edge = SizeProbe.ceiling(port, 1024, 64 * 1024);
|
||||
System.out.println("largest body delivered : " + edge[0] + " bytes");
|
||||
System.out.println("smallest body rejected : " + edge[1] + " bytes");
|
||||
|
||||
// Measured, not remembered. Spring's messageSizeLimit is 65 536 and is never reached.
|
||||
assertThat(edge[0]).isBetween(15_000, 17_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* With the container buffer at 256 KB, Spring's own 64 KB limit is finally the one that
|
||||
* decides — and it fails differently. The container's refusal is a bare close; Spring's is
|
||||
* a STOMP {@code ERROR} frame, logged, with the byte count in it. Two limits, two symptoms, and
|
||||
* only the second one tells you what happened.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"dashboard.broadcast.enabled=false", "spring.profiles.active=bigframes"})
|
||||
class StompSizeLimitSpringLimitTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void springsLimitDecidesOnceTheContainerIsOutOfTheWay() throws Exception {
|
||||
System.out.println("=== bigframes profile: container buffer 256 KB, Spring limit 64 KB ===");
|
||||
System.out.println(" 16 KB body -> " + SizeProbe.attempt(port, 16 * 1024));
|
||||
System.out.println(" 60 KB body -> " + SizeProbe.attempt(port, 60 * 1024));
|
||||
System.out.println(" 80 KB body -> " + SizeProbe.attempt(port, 80 * 1024));
|
||||
System.out.println("200 KB body -> " + SizeProbe.attempt(port, 200 * 1024));
|
||||
|
||||
assertThat(SizeProbe.attempt(port, 60 * 1024)).startsWith("delivered");
|
||||
assertThat(SizeProbe.attempt(port, 80 * 1024)).doesNotStartWith("delivered");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The same binary search with the container buffer raised, which is what turns "raise the
|
||||
* buffer" into a rule rather than folklore. Run with {@code -Dwsbuf=<bytes>} to move it; the
|
||||
* committed transcript covers 8 KB (untouched), 16 KB, 32 KB and 64 KB.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"dashboard.broadcast.enabled=false",
|
||||
"spring.profiles.active=bigframes", "ws.buffer-bytes=${wsbuf:32768}"})
|
||||
class StompSizeLimitTunedBufferTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void ceilingTracksTheContainerBuffer() throws Exception {
|
||||
String buf = System.getProperty("wsbuf", "32768");
|
||||
System.out.println("=== container buffer set to " + buf + " ===");
|
||||
int[] edge = SizeProbe.ceiling(port, 1024, 200 * 1024);
|
||||
System.out.println("largest body delivered : " + edge[0] + " bytes");
|
||||
System.out.println("smallest body rejected : " + edge[1] + " bytes");
|
||||
assertThat(edge[0]).isGreaterThan(30_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
|
||||
import org.springframework.messaging.simp.stomp.StompFrameHandler;
|
||||
import org.springframework.messaging.simp.stomp.StompHeaders;
|
||||
import org.springframework.messaging.simp.stomp.StompSession;
|
||||
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
|
||||
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||
import org.springframework.web.socket.messaging.WebSocketStompClient;
|
||||
|
||||
/**
|
||||
* A real STOMP-over-WebSocket client, so the chat tests exercise the same code path a browser
|
||||
* does — handshake, CONNECT/CONNECTED, SUBSCRIBE, SEND, MESSAGE.
|
||||
*
|
||||
* <p>Note the converter: {@link JacksonJsonMessageConverter} is the Jackson 3 one.
|
||||
* {@code MappingJackson2MessageConverter} is still on the classpath and is the Jackson 2
|
||||
* one; under Boot 4 a "2" in a Spring class name means the <em>previous</em> Jackson. Picking
|
||||
* the wrong one here fails on the {@code Instant} field, exactly as it does for Kafka and AMQP
|
||||
* in the sibling modules.
|
||||
*/
|
||||
final class StompTestSupport {
|
||||
|
||||
private StompTestSupport() {
|
||||
}
|
||||
|
||||
static WebSocketStompClient client() {
|
||||
WebSocketStompClient client = new WebSocketStompClient(new StandardWebSocketClient());
|
||||
client.setMessageConverter(new JacksonJsonMessageConverter());
|
||||
return client;
|
||||
}
|
||||
|
||||
static StompSession connect(WebSocketStompClient client, int port) throws Exception {
|
||||
return client.connectAsync("ws://localhost:" + port + "/ws",
|
||||
new StompSessionHandlerAdapter() { }).get();
|
||||
}
|
||||
|
||||
/** Subscribe and collect frames of one payload type into a queue the test can poll. */
|
||||
static <T> BlockingQueue<T> subscribe(StompSession session, String destination, Class<T> type) {
|
||||
BlockingQueue<T> queue = new LinkedBlockingQueue<>();
|
||||
session.subscribe(destination, new StompFrameHandler() {
|
||||
@Override
|
||||
public Type getPayloadType(StompHeaders headers) {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void handleFrame(StompHeaders headers, Object payload) {
|
||||
queue.add((T) payload);
|
||||
}
|
||||
});
|
||||
return queue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.messaging.simp.stomp.StompSession;
|
||||
import org.springframework.web.socket.messaging.WebSocketStompClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Point-to-point delivery, and the thing that makes it work: {@code /user/**} is not a real
|
||||
* destination. The broker rewrites it per session, so two clients subscribed to the same string
|
||||
* are subscribed to different queues.
|
||||
*
|
||||
* <p>Without Spring Security in the picture, the "user" is the STOMP session id. That is enough
|
||||
* to demonstrate the routing and is also a trap worth naming: with no {@code Principal}, a user
|
||||
* destination is session-scoped, so a second browser tab is a different user and a reconnect
|
||||
* loses the mailbox.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class StompUserDestinationTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void onlyTheAddressedSessionReceives() throws Exception {
|
||||
WebSocketStompClient client = StompTestSupport.client();
|
||||
|
||||
StompSession alice = StompTestSupport.connect(client, port);
|
||||
StompSession bob = StompTestSupport.connect(client, port);
|
||||
|
||||
BlockingQueue<ChatMessage> aliceInbox =
|
||||
StompTestSupport.subscribe(alice, "/user/queue/whisper", ChatMessage.class);
|
||||
BlockingQueue<ChatMessage> bobInbox =
|
||||
StompTestSupport.subscribe(bob, "/user/queue/whisper", ChatMessage.class);
|
||||
Thread.sleep(400);
|
||||
|
||||
// Discovering Alice's session id is itself the lesson. StompSession.getSessionId() on
|
||||
// the CLIENT returns the client's own identifier, which is NOT the id the server keeps
|
||||
// in SimpMessageHeaderAccessor and NOT the one the user destination is built from.
|
||||
// Whispering to it delivers nothing, silently. The server-side id has to come from the
|
||||
// server: /app/chat.send echoes it back when "from" is null.
|
||||
BlockingQueue<ChatMessage> aliceRoom =
|
||||
StompTestSupport.subscribe(alice, "/topic/room", ChatMessage.class);
|
||||
Thread.sleep(200);
|
||||
alice.send("/app/chat.send", new ChatMessage(null, "who am i?", Instant.now()));
|
||||
ChatMessage identity = aliceRoom.poll(5, TimeUnit.SECONDS);
|
||||
assertThat(identity).isNotNull();
|
||||
String aliceServerSession = identity.from();
|
||||
|
||||
System.out.println("alice.getSessionId() [client side] : " + alice.getSessionId());
|
||||
System.out.println("server-side session id : " + aliceServerSession);
|
||||
assertThat(aliceServerSession).isNotEqualTo(alice.getSessionId());
|
||||
|
||||
bob.send("/app/chat.whisper",
|
||||
new ChatMessage(aliceServerSession, "just for you", Instant.now()));
|
||||
|
||||
ChatMessage atAlice = aliceInbox.poll(5, TimeUnit.SECONDS);
|
||||
ChatMessage atBob = bobInbox.poll(1500, TimeUnit.MILLISECONDS);
|
||||
|
||||
System.out.println("=== convertAndSendToUser(aliceSession, \"/queue/whisper\", ..) ===");
|
||||
System.out.println("both subscribed to the SAME string : /user/queue/whisper");
|
||||
System.out.println("alice received : " + atAlice);
|
||||
System.out.println("bob received : " + atBob);
|
||||
|
||||
assertThat(atAlice).isNotNull();
|
||||
assertThat(atAlice.text()).isEqualTo("just for you");
|
||||
assertThat(atBob).as("the other session must not see it").isNull();
|
||||
|
||||
alice.disconnect();
|
||||
bob.disconnect();
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.web.servlet.client.RestTestClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Reads the container's own WebSocket buffer sizes rather than quoting a default from memory.
|
||||
* Nothing in this application sets them.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class WebSocketContainerDefaultsTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void printThem() {
|
||||
String body = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build()
|
||||
.get().uri("/diag").exchange().expectStatus().isOk()
|
||||
.expectBody(String.class).returnResult().getResponseBody();
|
||||
System.out.println("=== /diag with no bigframes profile ===");
|
||||
System.out.println(body);
|
||||
assertThat(body).contains("\"ws.container.defaultMaxTextMessageBufferSize\":8192");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.web.servlet.client.RestTestClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** The {@code bigframes} profile's ServletServerContainerFactoryBean, verified to have landed. */
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"dashboard.broadcast.enabled=false", "spring.profiles.active=bigframes"})
|
||||
class WebSocketContainerRaisedTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void printThem() {
|
||||
String body = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build()
|
||||
.get().uri("/diag").exchange().expectStatus().isOk()
|
||||
.expectBody(String.class).returnResult().getResponseBody();
|
||||
System.out.println("=== /diag with the bigframes profile ===");
|
||||
System.out.println(body);
|
||||
assertThat(body).contains("\"ws.container.defaultMaxTextMessageBufferSize\":262144");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.ankurm.ssews;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* What {@code registry.addEndpoint("/ws")} with no {@code setAllowedOrigins} actually permits.
|
||||
*
|
||||
* <p>This matters because the WebSocket handshake is a plain HTTP GET, and the browser's
|
||||
* same-origin policy does <em>not</em> apply to it: a page on any site can open a WebSocket to
|
||||
* your server, with the user's cookies attached, unless the server checks {@code Origin}. Spring
|
||||
* checks it by default. A non-browser client sends no {@code Origin} at all and is allowed
|
||||
* through, which is why the Java tests in this module connect without ceremony.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "dashboard.broadcast.enabled=false")
|
||||
class WebSocketHandshakeOriginTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
@Test
|
||||
void originIsCheckedByDefault() throws Exception {
|
||||
System.out.println("=== GET /ws handshake, varying only the Origin header ===");
|
||||
String noOrigin = handshake(null);
|
||||
String sameOrigin = handshake("http://localhost:" + port);
|
||||
String foreignOrigin = handshake("https://evil.example.com");
|
||||
|
||||
System.out.println("no Origin header -> " + noOrigin);
|
||||
System.out.println("Origin: http://localhost -> " + sameOrigin);
|
||||
System.out.println("Origin: https://evil... -> " + foreignOrigin);
|
||||
|
||||
assertThat(noOrigin).startsWith("HTTP/1.1 101");
|
||||
assertThat(sameOrigin).startsWith("HTTP/1.1 101");
|
||||
assertThat(foreignOrigin).startsWith("HTTP/1.1 403");
|
||||
}
|
||||
|
||||
private String handshake(String origin) throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(10_000);
|
||||
StringBuilder req = new StringBuilder()
|
||||
.append("GET /ws HTTP/1.1\r\n")
|
||||
.append("Host: localhost:").append(port).append("\r\n")
|
||||
.append("Upgrade: websocket\r\n")
|
||||
.append("Connection: Upgrade\r\n")
|
||||
.append("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n")
|
||||
.append("Sec-WebSocket-Version: 13\r\n");
|
||||
if (origin != null) {
|
||||
req.append("Origin: ").append(origin).append("\r\n");
|
||||
}
|
||||
req.append("\r\n");
|
||||
OutputStream out = socket.getOutputStream();
|
||||
out.write(req.toString().getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
BufferedReader in = new BufferedReader(
|
||||
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||
List<String> lines = new ArrayList<>();
|
||||
String line;
|
||||
while ((line = in.readLine()) != null && !line.isEmpty()) {
|
||||
lines.add(line);
|
||||
}
|
||||
return lines.isEmpty() ? "<no response>" : lines.get(0).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user