Merge pull request 'fix(realtime): resolve the device on the authorized upgrade (lowercase headers); 1.1.1' (#30) from fix/realtime-header-case-20261106 into main
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 7s
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 31s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 4m8s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 13s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 4m28s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
Shared Java Maven CI / secrets (push) Successful in 38s
Shared Java Maven CI / verify (push) Successful in 6m56s
Shared Java Maven CI / gate (push) Successful in 23s
qrun-release / release (push) Successful in 7m28s
Shared QRun source version / version (push) Successful in 43s
qrun-release / postpublish (push) Successful in 43s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 7s
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 31s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 4m8s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 13s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 4m28s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
Shared Java Maven CI / secrets (push) Successful in 38s
Shared Java Maven CI / verify (push) Successful in 6m56s
Shared Java Maven CI / gate (push) Successful in 23s
qrun-release / release (push) Successful in 7m28s
Shared QRun source version / version (push) Successful in 43s
qrun-release / postpublish (push) Successful in 43s
This commit was merged in pull request #30.
This commit is contained in:
7 files changed
+87
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.0.0</version><relativePath/></parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-printing</artifactId>
|
||||
<version>1.1.0</version>
|
||||
<version>1.1.1</version>
|
||||
<name>QQQ Printing</name>
|
||||
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url></license></licenses>
|
||||
<properties>
|
||||
|
||||
@@ -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<String, Channel> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Javalin> 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user