Files
qbit-printing/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java
T
james.maesandClaude Fable 5.1 e7721bdf90
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 20s
Shared Java Maven CI / verify (push) Successful in 4m14s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m51s
Shared Java Maven CI / gate (push) Successful in 15s
qrun-release / release (push) Successful in 4m32s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m8s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 8s
Shared QRun source version / version (push) Successful in 27s
qrun-release / postpublish (push) Successful in 27s
fix(realtime): resolve the device on the authorized upgrade so lowercase headers connect; 1.1.1
The WebSocket callbacks read X-QQQ-Device-Id from the socket context, whose
header lookup is case-sensitive, while the HTTP upgrade matches names
case-insensitively. Behind an ingress that lowercases header names the device
resolved to null on connect and channels.put threw before WELCOME (Forge's
adoption test of released 1.1.0; Forge had carried a local patch on 1.0.0).
Resolve the device once after authorization on the upgrade and carry it as a
connection attribute for connect, message and close. The new
lowercaseUpgradeHeadersReachWelcomeAndFrames test fails on the previous code
with the same NullPointerException and passes here. No contract change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:24:44 -06:00

175 lines
6.5 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import org.json.JSONArray;
import org.json.JSONObject;
import org.jspecify.annotations.Nullable;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/** Real native WebSocket client shared by the HTTP and realtime integration fixtures. */
final class PrintingSocketFixture implements WebSocket.Listener, AutoCloseable
{
final BlockingQueue<JSONObject> received = new LinkedBlockingQueue<>();
final CompletableFuture<Integer> closed = new CompletableFuture<>();
private final StringBuilder text = new StringBuilder();
private @Nullable WebSocket socket;
/** Authenticate with the normal session cookie and secondary proof headers. */
static PrintingSocketFixture connect(HttpClient client, String base, String cookie, String device, String proof) throws Exception
{
PrintingSocketFixture fixture = open(client, base, cookie, device, proof);
fixture.initialize();
return fixture;
}
/** Separate upgrade from HELLO so a commit in that interval can be tested deterministically. */
static PrintingSocketFixture open(HttpClient client, String base, String cookie, String device, String proof) throws Exception
{
return open(client, base, cookie, device, proof, false);
}
/** Lowercase header names model an ingress that normalizes them, which the device lookup must survive. */
static PrintingSocketFixture open(HttpClient client, String base, String cookie, String device, String proof, boolean lowercaseHeaders) throws Exception
{
PrintingSocketFixture fixture = new PrintingSocketFixture();
String deviceHeader = lowercaseHeaders ? PrintingAgentAuthorization.DEVICE_HEADER.toLowerCase(java.util.Locale.ROOT) : PrintingAgentAuthorization.DEVICE_HEADER;
String tokenHeader = lowercaseHeaders ? PrintingAgentAuthorization.TOKEN_HEADER.toLowerCase(java.util.Locale.ROOT) : PrintingAgentAuthorization.TOKEN_HEADER;
fixture.socket = client.newWebSocketBuilder().subprotocols(PrintingRealtimeRoutes.SUBPROTOCOL).header("Origin", base)
.header(lowercaseHeaders ? "x-qqq-client" : "X-QQQ-Client", "api/qqq-print-agent")
.header("Cookie", cookie).header(deviceHeader, device).header(tokenHeader, proof)
.buildAsync(URI.create(base.replace("http:", "ws:") + PrintingRealtimeRoutes.PATH), fixture).get(10, TimeUnit.SECONDS);
return fixture;
}
/** Reconcile after the connection's subscription is already registered. */
void initialize() throws Exception
{
initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL"));
}
/** Explicit capabilities permit downgrade/reconnect qualification without changing old clients. */
void initialize(JSONArray capabilities) throws Exception
{
send("HELLO", "hello", new JSONObject().put("agentVersion", "synthetic-test").put("capabilities", capabilities)
.put("lastAppliedRevision", 0).put("lastSettingsRevision", 0).put("limits", PrintingRealtimePolicy.defaults().toJson(0)));
JSONObject welcome = take("WELCOME").getJSONObject("payload");
apply(welcome.getJSONObject("desiredState").getLong("revision"), welcome.getJSONObject("settings").getLong("revision"));
}
/** Observe application ACK before allowing a dependent HTTP claim. */
void apply(long state, long settings) throws Exception
{
send("STATE_APPLIED", "applied", new JSONObject().put("lastAppliedRevision", state).put("lastSettingsRevision", settings));
take("ACK");
}
/** Send one explicit protocol operation, never a timed query. */
void send(String type, String id, JSONObject payload)
{
java.util.Objects.requireNonNull(socket, "Test socket was not opened").sendText(new JSONObject().put("type", type).put("messageId", id).put("payload", payload).toString(), true).join();
}
/** Bounded waiting diagnoses absent pushes without introducing a polling loop. */
JSONObject take(String type) throws Exception
{
long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
while(System.nanoTime() < end)
{
JSONObject next = received.poll(Math.max(1, end - System.nanoTime()), TimeUnit.NANOSECONDS);
assertNotNull(next, "Expected realtime frame " + type);
if("ERROR".equals(next.getString("type")) && !"ERROR".equals(type))
{
throw new AssertionError("Realtime rejected synthetic test message: " + next.getJSONObject("payload"));
}
if(type.equals(next.getString("type")))
{
return next;
}
}
throw new AssertionError("No frame " + type);
}
@Override
public void onOpen(WebSocket webSocket)
{
webSocket.request(1);
}
@Override
public @Nullable CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last)
{
text.append(data);
if(last)
{
received.add(new JSONObject(text.toString()));
text.setLength(0);
}
webSocket.request(1);
return null;
}
@Override
public @Nullable CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason)
{
closed.complete(statusCode);
return null;
}
@Override
public void onError(WebSocket webSocket, Throwable error)
{
closed.completeExceptionally(error);
}
@Override
public void close()
{
if(socket != null)
{
socket.abort();
}
}
}