From e7721bdf90349a94e9badf6cb91c6c1e9f782cc2 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 08:24:44 -0600 Subject: [PATCH] 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 Signed-off-by: James Maes --- CHANGELOG.md | 10 ++++ README.md | 2 +- docs/SESSION-STATE.md | 6 ++- pom.xml | 2 +- .../printing/PrintingRealtimeRoutes.java | 12 ++++- .../qbits/printing/PrintingRealtimeTest.java | 49 +++++++++++++++++++ .../qbits/printing/PrintingSocketFixture.java | 13 ++++- 7 files changed, 87 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f0778..6110d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.1.1 + +Realtime upgrade fix; no contract change. The WebSocket callbacks read the device +identifier from the socket context's headers, which are matched case-sensitively, while the +HTTP upgrade matches them case-insensitively. Behind an ingress that lowercases header names +the device resolved to null on connect and the connection failed before WELCOME (Forge had +carried a local patch for this on 1.0.0). The device is now resolved once on the authorized +upgrade and carried on the connection; a regression test connects with lowercase header +names and verifies WELCOME, an acknowledged inventory, the PRINTERS push and socket replacement. + ## 1.1.0 Native printing extension **2.4.0** (additive; wire payload v1 and the agent diff --git a/README.md b/README.md index 3d6b2ac..4ea7a87 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https:// **Released:** [QQQ Printing 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0), with a library JAR, matching POM and `SHA256SUMS` ([1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) remains available for rollback). The same JAR/POM are published in the QRun-IO Gitea Maven registry. Gitea requires sign-in for downloads. The QBit itself is not published to Maven Central and is not a standalone executable/fat JAR. See [installation and release access](docs/RELEASES.md). -**Runtime:** Java 21 and QQQ **4.1.0-RC.21**, published on Maven Central with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. Product version 1.1.0, native extension 2.4.0 and wire payload v1 are separate version contracts. +**Runtime:** Java 21 and QQQ **4.1.0-RC.21**, published on Maven Central with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. Product version 1.1.1, native extension 2.4.0 and wire payload v1 are separate version contracts. Implemented: single-use enrollment, device credentials, inventory/approval, PDF/ZPL submission, durable JDBC queue, idempotency, claim/content/events, native QQQ setup/test-print processes, read-only device/printer/job tables and a durable enqueue effect target. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index daad597..736d178 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,4 +1,8 @@ -# Session state — 2026-11-05 +# Session state — 2026-11-06 + +## In progress — 1.1.1: realtime upgrade header-case fix + +Forge's adoption qualification of released 1.1.0 (`~/.cache/gg-forge-printing-cohort-20261106/HANDOFF.md`) reproduced a connection failure before WELCOME when the WebSocket upgrade headers arrive lowercase: the socket callbacks read `X-QQQ-Device-Id` from the WebSocket context, whose header lookup is case-sensitive, so the device was null and `channels.put` threw. Forge had carried a local patch on 1.0.0. 1.1.1 resolves the device once on the authorized HTTP upgrade (case-insensitive) and carries it as a connection attribute; `PrintingRealtimeTest.lowercaseUpgradeHeadersReachWelcomeAndFrames` fails on the old code (same NullPointerException) and passes on the fix. No contract change. Release 1.1.1 follows the usual path; 1.1.0 must not be adopted. ## Current — 1.1.0 released (2026-11-05) diff --git a/pom.xml b/pom.xml index b4f759b..2fd0766 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ com.kingsrookqbit-build-parent2.0.0 com.kingsrook.qbits qbit-printing - 1.1.0 + 1.1.1 QQQ Printing Apache License, Version 2.0https://www.apache.org/licenses/LICENSE-2.0 diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java index d8b9b18..60ad0d5 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java @@ -27,6 +27,7 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayDeque; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -59,6 +60,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf { public static final String PATH = "/qqq/v1/qbit/printing/device/realtime"; public static final String SUBPROTOCOL = "qqq-printing.v1"; + /** Device identifier resolved on the authorized HTTP upgrade and carried to the WebSocket callbacks. */ + static final String DEVICE_ATTRIBUTE = "printingRealtimeDevice"; private final PrintingService service; private final Map channels = new ConcurrentHashMap<>(); private final ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor(); @@ -97,6 +100,10 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf { validateUpgrade(context); authorize(context); + // The HTTP upgrade context resolves header names case-insensitively; the WebSocket context does not, and an ingress + // that lowercases header names would otherwise leave the device null on connect. Resolve it once here, after + // authorization, and carry it on the connection. + context.attribute(DEVICE_ATTRIBUTE, Objects.requireNonNull(context.header(PrintingAgentAuthorization.DEVICE_HEADER), "authorized upgrade without a device header")); String cookieName = context.cookie("sessionId") != null && !context.cookie("sessionId").isBlank() ? "sessionId" : "sessionUUID"; context.attribute("printingRealtimeCredential", new Credential(cookieName, context.cookie(cookieName), context.header(PrintingAgentAuthorization.TOKEN_HEADER), context.header("X-QQQ-Client"))); } @@ -113,7 +120,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf { socket.onConnect(context -> { - String device = context.header(PrintingAgentAuthorization.DEVICE_HEADER); + String device = Objects.requireNonNull(context.attribute(DEVICE_ATTRIBUTE), "WebSocket connected without the authorized device attribute"); Channel next = new Channel(context, device, context.attribute("printingRealtimeCredential")); next.policy = service.realtimePolicy(); Channel previous = channels.put(device, next); @@ -149,7 +156,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf @SuppressWarnings("ReferenceEquality") private @Nullable Channel current(WsContext context) { - Channel channel = channels.get(context.header(PrintingAgentAuthorization.DEVICE_HEADER)); + String device = context.attribute(DEVICE_ATTRIBUTE); + Channel channel = device == null ? null : channels.get(device); return channel != null && channel.socket.session == context.session ? channel : null; } diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java index 48d0f78..c6ebdf2 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java @@ -57,6 +57,55 @@ public class PrintingRealtimeTest Path directory; + /** Lowercase upgrade header names (an ingress that normalizes them) must still resolve the device for every socket callback. */ + @Test + void lowercaseUpgradeHeadersReachWelcomeAndFrames() throws Exception + { + String jdbc = "jdbc:h2:file:" + directory.resolve("lowercase-realtime").toString().replace('\\', '/'); + PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", "synthetic-realtime-password-1234")); + AtomicReference app = new AtomicReference<>(); + QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server -> + { + host.configure(server, false); + server.withJavalinConfigurationCustomizer(app::set); + })); + PrintingService service = Objects.requireNonNull(launcher.getQInstance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance) + .map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService(), "The printing service was not configured"); + String base = "http://127.0.0.1:" + Objects.requireNonNull(app.get(), "Test HTTP server was not started").port(); + JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Lowercase fixture").put("deviceName", "Lowercase fixture").put("agentVersion", "test"), "operator"); + String device = enrolled.getString("deviceId"); + String proof = enrolled.getString("deviceToken"); + try(HttpClient http = HttpClient.newHttpClient()) + { + String cookie = login(http, base); + // An ingress that lowercases header names must not leave the device unresolved on connect: WELCOME, an acknowledged + // inventory and the capability-gated PRINTERS push all work exactly as with the canonical spelling. + try(PrintingSocketFixture lowercase = PrintingSocketFixture.open(http, base, cookie, device, proof, true)) + { + lowercase.initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL").put("printer-approval-v1")); + lowercase.send("INVENTORY", "inventory-lower", new JSONObject().put("printers", new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue") + .put("driverName", "Synthetic").put("portName", "SYNTHETIC").put("status", "READY").put("formats", new JSONArray().put("PDF"))))); + assertEquals("inventory-lower", lowercase.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId")); + JSONObject printers = lowercase.take("PRINTERS").getJSONObject("payload"); + assertEquals("queue", printers.getJSONArray("printers").getJSONObject(0).getString("printerId")); + assertFalse(printers.getJSONArray("printers").getJSONObject(0).getBoolean("enabled")); + // A replacement socket with canonical spelling supersedes the lowercase one through the same device key. + try(PrintingSocketFixture canonical = PrintingSocketFixture.connect(http, base, cookie, device, proof)) + { + assertTrue(lowercase.closed.get(10, TimeUnit.SECONDS) > 0); + canonical.send("PING", "ping-1", new JSONObject()); + canonical.take("PONG"); + } + } + } + finally + { + launcher.stop(); + } + } + + + /** An old client cannot ignore media intent, including after replacing a capable connection. */ @Test void mediaCapabilityIsReplacedOnEveryAuthenticatedHello() throws Exception diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java index 27d5c8d..726572c 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java @@ -55,11 +55,20 @@ final class PrintingSocketFixture implements WebSocket.Listener, AutoCloseable /** 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("X-QQQ-Client", "api/qqq-print-agent") - .header("Cookie", cookie).header(PrintingAgentAuthorization.DEVICE_HEADER, device).header(PrintingAgentAuthorization.TOKEN_HEADER, proof) + .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; } -- 2.54.0