From 37f2ccddcba94a1afba7f7f9a9f2dd59a48e98b9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 16:18:17 -0500 Subject: [PATCH 01/35] docs: record profile and history release verification Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- docs/SESSION-STATE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index b5bc7ef..14a9677 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,6 +1,6 @@ -# Session state — current checkpoint 2026-10-29 +# Session state — current checkpoint 2026-10-30 -2026-10-30 source0.1.2-rc.1: implemented client-authoritative Name/Description/Location synchronization, preserved computer/queue technical names, read-only native QQQ profile fields and friendly selector labels. Added additive schema backfill/widening, dispatch/history indexes, one-row payload-free claims and metadata-only keyset history pages. Added long-text, explicit-clear, migration, tenant and synthetic 10,000-row pagination/claim tests. Root verified22tests with zeroCheckstyle violations using mvnw.cmd verify. Protocol28definitions/21fixtures passed and CI now pins a0de93b. Prior verification below predates these changes. No physical prints were requested by this work. +2026-10-30 source0.1.2-rc.1: implemented client-authoritative Name/Description/Location synchronization, preserved computer/queue technical names, read-only native QQQ profile fields and friendly selector labels. Added additive schema backfill/widening, dispatch/history indexes, one-row payload-free claims and metadata-only keyset history pages. Added long-text, explicit-clear, migration, tenant and synthetic 10,000-row pagination/claim tests. Root verified22tests with zeroCheckstyle violations using mvnw.cmd verify. Protocol28definitions/21fixtures passed and CI pins a0de93b. Exact-source GitHub build41126545760 and security41126545795 passed for cfe8a5f. Prior verification below predates these changes. No physical prints were requested by this work. Current source is prepared for **0.1.1-rc.1**. Latest Java verification passed **19 tests with zero Checkstyle violations**. The full audit follow-up implements device-scoped test-print/retry/cancel/queued-job routes and native **Print again** / **Cancel queued jobs** actions from job records and table selections. Native review uses named records, rechecks administrator/tenant/row access and current state, requires explicit duplicate-risk confirmation for uncertain/spooled output, and uses a stable request identity for retry replay. Retry creates a new job and preserves the original outcome. Queued cancellation is serialized against claim and cannot promise cancellation after dispatch. -- 2.54.0 From a6266fe2bb24853d8c223785d6620ca2e263545e Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 17:42:28 -0500 Subject: [PATCH 02/35] feat: add branded warehouse print diagnostics Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 8 + NOTICE | 6 + README.md | 8 + docs/SESSION-STATE.md | 6 + docs/TODO.md | 3 + pom.xml | 2 +- .../qbits/printing/PrintingProcesses.java | 96 +++-- .../qbits/printing/PrintingService.java | 7 +- .../qbits/printing/PrintingTestDocuments.java | 380 ++++++++++++++++++ .../qbits/printing/TestPrintProfile.java | 96 +++++ .../printing/fonts/JetBrainsMono-Bold.ttf | Bin 0 -> 277828 bytes .../kingsrook/qbits/printing/fonts/OFL.txt | 93 +++++ .../kingsrook/qbits/printing/fonts/README.md | 25 ++ .../qbits/printing/images/qqq-logo.png | Bin 0 -> 43481 bytes .../qbits/printing/PrintingProcessesTest.java | 47 +++ .../printing/PrintingTestDocumentsExport.java | 63 +++ .../printing/PrintingTestDocumentsTest.java | 209 ++++++++++ 18 files changed, 1017 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingTestDocuments.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/TestPrintProfile.java create mode 100644 src/main/resources/com/kingsrook/qbits/printing/fonts/JetBrainsMono-Bold.ttf create mode 100644 src/main/resources/com/kingsrook/qbits/printing/fonts/OFL.txt create mode 100644 src/main/resources/com/kingsrook/qbits/printing/fonts/README.md create mode 100644 src/main/resources/com/kingsrook/qbits/printing/images/qqq-logo.png create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingTestDocumentsExport.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingTestDocumentsTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d39e58..b4a2722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: QRun-IO/qqq-print-protocol - ref: a0de93b00e602abde9be77c1ec9103b670bf3c09 + ref: f4f977ca4aa6b749e6a52760eed5e53c99a7704d path: qqq-print-protocol persist-credentials: false - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d9d5f0..f66d039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.3-rc.1 — 2026-10-30 + +- Shared QQQ-branded warehouse test pages and labels across native record/table actions and device requests. +- Bundle licensed JetBrains Mono Bold; embed PDF glyphs and use bounded ZPL graphics for consistent identifier readability. +- Add Letter/A4 and 4 × 6 profile options with explicit 203/300/600-dpi label resolution, compatible defaults and validation. +- Add font/media, deterministic generation, ZPL bitmap reconstruction and operator action regression coverage. + + ## 0.1.2-rc.1 — 2026-10-30 - Synchronize client-managed device/printer display names, descriptions and locations; preserve physical computer/queue names and stable identifiers. diff --git a/NOTICE b/NOTICE index 8416ea2..3285275 100644 --- a/NOTICE +++ b/NOTICE @@ -4,3 +4,9 @@ Copyright 2026 Kingsrook, LLC This product contains QQQ 4.1+ printing metadata, queue services, device transport routes, demonstration hosts and compatibility tests. Licensed under the Apache License, Version 2.0. + +Built-in diagnostic documents bundle JetBrains Mono Bold v2.304. +Copyright 2020 The JetBrains Mono Project Authors. +The font is licensed under the SIL Open Font License, Version 1.1; +see src/main/resources/com/kingsrook/qbits/printing/fonts/OFL.txt. +The QQQ logo is supplied by the QQQ framework, Copyright Kingsrook, LLC. diff --git a/README.md b/README.md index 6057658..f6e6ef6 100644 --- a/README.md +++ b/README.md @@ -143,3 +143,11 @@ This RC implements the happy path of the [QQQ dashboard standard](https://github The Windows companion can request test prints and explicit reprints through device-scoped authenticated routes. The same printer approval/exposure rules apply. Upgrade the QBit and connector together for these additive protocol features. Reprints create a new job; uncertain/spooled originals require duplicate-output consent and remain unchanged. QQQ's **Print jobs** table and records expose **Print again** and **Cancel queued jobs** using native selection/review/result screens. Cancellation is allowed only before dispatch; later work must be inspected in the Windows queue. The actions require printing administration permission and the configured tenant context. + +## Warehouse diagnostic pages + +All built-in printer test actions share `PrintingTestDocuments`: QQQ branding, the existing QQQ logo, large warehouse-style identifiers and embedded JetBrains Mono Bold with a dotted zero and distinct 1/I/l. The font is included under SIL OFL 1.1; its license and provenance ship in the JAR. PDF embeds a font subset. ZPL renders the same glyphs into document-local, row-aligned graphics below Zebra's 99,999-byte per-field limit; it does not download or save a printer font. + +Native record and selected-table actions expose a possible-value stock/resolution selector. Letter and A4 are PDF-only. Label profiles are 4 × 6 inches at 203, 300 or 600 dpi; PDF label output is always 101.6 × 152.4 mm. Select matching stock and printer resolution. Bulk actions apply the chosen profile to every selected destination, so group compatible printers. The document does not reconfigure Windows stock, calibrate the printer or save device configuration; ZPL width/length are transient format settings. + +The device test-print API accepts optional `testProfile` with the same choices. Existing clients default to Letter PDF or 203-dpi 4 × 6 ZPL. Invalid profiles fail before enqueueing. Test actions use the ordinary durable queue and existing printer exposure/approval rules; discovery never automatically prints. A ZPL barcode, glyph comparisons and size/alignment marks support physical inspection. Rendering previews and spool acceptance do not prove paper or scanner quality. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 14a9677..2277a1a 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,11 @@ # Session state — current checkpoint 2026-10-30 +## Branded diagnostic documents — source ready, verification pending + +Built-in test paths now share PrintingTestDocuments and TestPrintProfile. Native QQQ forms and device test requests choose Letter, A4 or 4 × 6-inch label profiles at 203/300/600 dpi; omitted profiles retain PDF Letter/ZPL 203 defaults. ZPL rejects page profiles. Documents use the QQQ logo and wordmark, bundled OFL JetBrains Mono Bold, character-pair checks and a 25.4 mm reference. PDF embeds vector text; ZPL carries a monochrome bitmap in whole-row graphic strips of at most 99,999 bytes each and native Code 128. No persistent font download is required. Identical PDF generation uses stable document IDs for idempotency; changed request content still conflicts. + +Added generator/media/font/margin tests, per-strip byte/row/hex bounds and full bitmap reconstruction checks at every density and native/API parity/profile-validation tests. PrintingTestDocumentsExport writes local PDF/PNG/ZPL previews without contacting printers. The parent agent owns coordinated verification and physical acceptance. This source checkpoint claims no new test results or printed output. + 2026-10-30 source0.1.2-rc.1: implemented client-authoritative Name/Description/Location synchronization, preserved computer/queue technical names, read-only native QQQ profile fields and friendly selector labels. Added additive schema backfill/widening, dispatch/history indexes, one-row payload-free claims and metadata-only keyset history pages. Added long-text, explicit-clear, migration, tenant and synthetic 10,000-row pagination/claim tests. Root verified22tests with zeroCheckstyle violations using mvnw.cmd verify. Protocol28definitions/21fixtures passed and CI pins a0de93b. Exact-source GitHub build41126545760 and security41126545795 passed for cfe8a5f. Prior verification below predates these changes. No physical prints were requested by this work. Current source is prepared for **0.1.1-rc.1**. Latest Java verification passed **19 tests with zero Checkstyle violations**. The full audit follow-up implements device-scoped test-print/retry/cancel/queued-job routes and native **Print again** / **Cancel queued jobs** actions from job records and table selections. Native review uses named records, rechecks administrator/tenant/row access and current state, requires explicit duplicate-risk confirmation for uncertain/spooled output, and uses a stable request identity for retry replay. Retry creates a new job and preserves the original outcome. Queued cancellation is serialized against claim and cannot promise cancellation after dispatch. diff --git a/docs/TODO.md b/docs/TODO.md index a907e92..97db0e9 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -33,3 +33,6 @@ - [ ] Record final package hashes, signing status and applicable integration evidence before replacement release. - [ ] Run a fresh explicitly authorized physical acceptance job for the final package; Zebra remains deferred. - [ ] Qualify reboot/sign-in/service recovery and deployed-host behavior; no source-only or synthetic result substitutes for those checks. + +- [x] Share branded diagnostic generators and explicit stock/resolution profiles across native QQQ and device test-print entrypoints. +- [ ] Run coordinated branded-document tests, inspect exported layouts and confirm physical output/scanning; no new template print is yet qualified. diff --git a/pom.xml b/pom.xml index 2100a25..a9cf1e3 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.kingsrookqbit-build-parent2.0.0 com.kingsrook.qbits qbit-printing - 0.1.2-rc.1 + 0.1.3-rc.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/PrintingProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java index 3b13489..5ce044a 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java @@ -22,7 +22,6 @@ package com.kingsrook.qbits.printing; import java.io.Serializable; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; @@ -49,6 +48,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; @@ -74,10 +76,11 @@ public class PrintingProcesses implements BackendStep public static void register(QInstance instance, QAppMetaData app) { PrintingJobProcesses.register(instance); + registerProfiles(instance); add(instance, "printingEnrollDevice", "Enroll a print device", List.of(field("facilityId", "Facility"), field("deviceName", "Device name"))); add(instance, "printingEnablePrinter", "Set printer approval", List.of(deviceField(), printerField(null), new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Approved for printing").withIsRequired(true))); - add(instance, "printingTestZpl", "Print a test label", List.of(deviceField(), printerField("ZPL"))); - add(instance, "printingTestPdf", "Print a test page", List.of(deviceField(), printerField("PDF"))); + add(instance, "printingTestZpl", "Print a test label", List.of(deviceField(), printerField("ZPL"), profileField(false))); + add(instance, "printingTestPdf", "Print a test page", List.of(deviceField(), printerField("PDF"), profileField(true))); selected(instance, "printingTestSelectedPdf", "Print a test page", "Each selected PDF printer will receive one test page after you continue."); selected(instance, "printingTestSelectedZpl", "Print a test label", "Each selected ZPL printer will receive one test label after you continue."); selected(instance, "printingEnableSelectedPrinters", "Approve printers", "The selected printers will be approved for printing after you continue."); @@ -86,6 +89,40 @@ public class PrintingProcesses implements BackendStep + /*************************************************************************** + ** Fixed native choices keep physical stock and resolution explicit. + ***************************************************************************/ + private static void registerProfiles(QInstance instance) + { + ArrayList> pdf = new ArrayList<>(); + ArrayList> zpl = new ArrayList<>(); + for(TestPrintProfile profile : TestPrintProfile.values()) + { + QPossibleValue option = new QPossibleValue<>(profile.name(), profile.getLabel()); + pdf.add(option); + if(profile.getDpi() > 0) + { + zpl.add(option); + } + } + instance.addPossibleValueSource(new QPossibleValueSource().withName("printing_testProfilePdf").withType(QPossibleValueSourceType.ENUM).withEnumValues(pdf)); + instance.addPossibleValueSource(new QPossibleValueSource().withName("printing_testProfileZpl").withType(QPossibleValueSourceType.ENUM).withEnumValues(zpl)); + } + + + + /*************************************************************************** + ** The same profile applies to every selected printer, so group matching stock. + ***************************************************************************/ + private static QFieldMetaData profileField(boolean pdf) + { + return (field("testProfile", "Test stock / printer resolution (match every selected printer)") + .withPossibleValueSourceName(pdf ? "printing_testProfilePdf" : "printing_testProfileZpl") + .withDefaultValue(pdf ? TestPrintProfile.LETTER.name() : TestPrintProfile.LABEL_4X6_203.name())); + } + + + /*************************************************************************** ** Shared administrator permission for printing apps and processes. ***************************************************************************/ @@ -156,6 +193,11 @@ public class PrintingProcesses implements BackendStep values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview); QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, SelectedTransform.class, SelectedLoad.class, values) .withName(name).withLabel(label).withTableName(PrintingTables.PRINTER).withMinInputRecords(1).withPermissionRules(permissionRules()); + if(name.startsWith("printingTest")) + { + process.withStep(0, new QFrontendStepMetaData().withName("testProfile").withLabel("Choose matching stock and resolution") + .withFormFields(List.of(profileField(name.endsWith("Pdf")))).withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))); + } instance.addProcess(process); } @@ -220,7 +262,7 @@ public class PrintingProcesses implements BackendStep case "printingTestZpl", "printingTestPdf" -> { QRecord printer = resolvePrinter(input.getValueString("printerId"), input.getValueString("deviceId")); - String job = submitTest(printer, "printingTestPdf".equals(input.getProcessName())); + String job = submitTest(printer, "printingTestPdf".equals(input.getProcessName()), input.getValueString("testProfile")); output.addValue("result", "Queued print job " + job + ". Check Print jobs for submission status."); } default -> throw new QException("Unknown printing action"); @@ -256,10 +298,21 @@ public class PrintingProcesses implements BackendStep ** Send one native test document through the normal durable queue. ***************************************************************************/ static String submitTest(QRecord printer, boolean pdf) throws QException + { + return (submitTest(printer, pdf, null)); + } + + + + /*************************************************************************** + ** All built-in document entrypoints share profile validation and generation. + ***************************************************************************/ + static String submitTest(QRecord printer, boolean pdf, String profileName) throws QException { try { - byte[] bytes = pdf ? testPdf() : "^XA^FO30,30^A0N,35,35^FDQQQ cloud printing test^FS^FO30,85^A0N,25,25^FDTest label - not for shipping^FS^XZ".getBytes(StandardCharsets.UTF_8); + TestPrintProfile profile = TestPrintProfile.resolve(pdf ? "PDF" : "ZPL", profileName); + byte[] bytes = pdf ? PrintingTestDocuments.pdf(profile) : PrintingTestDocuments.zpl(profile); JSONObject job = currentService().submit(new JSONObject().put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id")) .put("title", "QQQ print test").put("format", pdf ? "PDF" : "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", "test:" + UUID.randomUUID())); return (job.getString("jobId")); @@ -295,6 +348,14 @@ public class PrintingProcesses implements BackendStep if(input.getProcessName().startsWith("printingTest")) { String format = input.getProcessName().endsWith("Pdf") ? "PDF" : "ZPL"; + try + { + TestPrintProfile.resolve(format, input.getValueString("testProfile")); + } + catch(PrintingException exception) + { + throw new QException(exception.getMessage(), exception); + } if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available")) || !new org.json.JSONArray(printer.getValueString("formats")).toList().contains(format)) { @@ -344,8 +405,8 @@ public class PrintingProcesses implements BackendStep { switch(input.getProcessName()) { - case "printingTestSelectedPdf" -> submitTest(printer, true); - case "printingTestSelectedZpl" -> submitTest(printer, false); + case "printingTestSelectedPdf" -> submitTest(printer, true, input.getValueString("testProfile")); + case "printingTestSelectedZpl" -> submitTest(printer, false, input.getValueString("testProfile")); case "printingEnableSelectedPrinters", "printingDisableSelectedPrinters" -> currentService().enablePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id"), "printingEnableSelectedPrinters".equals(input.getProcessName())); default -> throw new QException("Unknown selected printing action"); } @@ -378,26 +439,7 @@ public class PrintingProcesses implements BackendStep ***************************************************************************/ static byte[] testPdf() throws QException { - try(org.apache.pdfbox.pdmodel.PDDocument document = new org.apache.pdfbox.pdmodel.PDDocument()) - { - org.apache.pdfbox.pdmodel.PDPage page = new org.apache.pdfbox.pdmodel.PDPage(); - document.addPage(page); - try(org.apache.pdfbox.pdmodel.PDPageContentStream content = new org.apache.pdfbox.pdmodel.PDPageContentStream(document, page)) - { - content.beginText(); - content.setFont(org.apache.pdfbox.pdmodel.font.PDType1Font.HELVETICA, 24); - content.newLineAtOffset(72, 700); - content.showText("QQQ cloud printing test"); - content.endText(); - } - java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); - document.save(bytes); - return (bytes.toByteArray()); - } - catch(java.io.IOException exception) - { - throw new QException("Could not generate test PDF", exception); - } + return (PrintingTestDocuments.pdf()); } } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index 2620229..c8c05fc 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -453,11 +453,8 @@ public class PrintingService public JSONObject testPrint(String deviceId, JSONObject request) throws Exception { String format = required(request.optString("format"), "format"); - if(!Set.of("PDF", "ZPL").contains(format)) - { - throw new PrintingException(400, "Only PDF and ZPL are supported"); - } - byte[] bytes = "PDF".equals(format) ? PrintingProcesses.testPdf() : "^XA^FO30,30^A0N,35,35^FDQQQ cloud printing test^FS^FO30,85^A0N,25,25^FDTest label - not for shipping^FS^XZ".getBytes(StandardCharsets.UTF_8); + TestPrintProfile profile = TestPrintProfile.resolve(format, request.has("testProfile") ? request.optString("testProfile") : null); + byte[] bytes = "PDF".equals(format) ? PrintingTestDocuments.pdf(profile) : PrintingTestDocuments.zpl(profile); return (submit(new JSONObject().put("deviceId", deviceId).put("printerId", required(request.optString("printerId"), "printerId")) .put("title", "QQQ print test").put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes)) .put("idempotencyKey", "device-test:" + deviceId + ":" + required(request.optString("requestId"), "requestId")))); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTestDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTestDocuments.java new file mode 100644 index 0000000..2c8f218 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTestDocuments.java @@ -0,0 +1,380 @@ +/* + * 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 javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontFormatException; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.Set; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; + + +/******************************************************************************* + ** Shared diagnostic documents. Font data travels with each job, never as a + ** persistent printer download or an assumption about installed printer fonts. + *******************************************************************************/ +public final class PrintingTestDocuments +{ + static final String FONT_RESOURCE = "/com/kingsrook/qbits/printing/fonts/JetBrainsMono-Bold.ttf"; + private static final String LOGO_RESOURCE = "/com/kingsrook/qbits/printing/images/qqq-logo.png"; + static final String BARCODE_VALUE = "QQQ-TEST-123456"; + + + + /*************************************************************************** + ** One vector Letter page with an embedded, subsetted warehouse test font. + ***************************************************************************/ + public static byte[] pdf() throws QException + { + return (pdf(TestPrintProfile.LETTER)); + } + + + + /*************************************************************************** + ** PDF media is explicit; label profiles retain their four-by-six-inch size. + ***************************************************************************/ + public static byte[] pdf(TestPrintProfile profile) throws QException + { + try(PDDocument document = new PDDocument(); InputStream fontStream = fontStream()) + { + COSArray identifiers = new COSArray(); + identifiers.add(new COSString("QQQ-TEST-" + profile.name())); + identifiers.add(new COSString("QQQ-TEST-" + profile.name())); + document.getDocument().getTrailer().setItem(COSName.ID, identifiers); + PDType0Font font = PDType0Font.load(document, fontStream, true); + PDRectangle media = profile.getDpi() > 0 ? new PDRectangle(288, 432) : (profile == TestPrintProfile.A4 ? PDRectangle.A4 : PDRectangle.LETTER); + PDPage page = new PDPage(media); + document.addPage(page); + try(PDPageContentStream content = new PDPageContentStream(document, page)) + { + if(profile.getDpi() > 0) + { + pdfLabel(content, font); + content.drawImage(LosslessFactory.createFromImage(document, logo()), 210, 348, 54, 54); + } + else + { + pdfText(content, font, 56, 48, 690, "QQQ"); + content.drawImage(LosslessFactory.createFromImage(document, logo()), media.getWidth() - 132, 660, 84, 84); + pdfText(content, font, 18, 48, 650, "WAREHOUSE PRINT CHECK"); + pdfText(content, font, 14, 48, 620, "TEST PRINT - NOT POSTAGE"); + pdfText(content, font, 11, 48, 590, (profile == TestPrintProfile.A4 ? "A4 210 x 297 mm" : "LETTER 8.5 x 11 in") + " / ACTUAL SIZE"); + pdfText(content, font, 12, 48, 535, "READ EACH CHARACTER"); + pdfText(content, font, 32, 48, 490, "0O 1Il 5S 8B 2Z 6G"); + pdfText(content, font, 18, 48, 440, "BIN A01-I1 / SKU O0-5S8B"); + pdfText(content, font, 18, 48, 410, "ORDER QQQ-123456 / QTY 02"); + pdfText(content, font, 12, 48, 350, "Measure this line: 25.4 mm / 1 inch"); + content.setLineWidth(1); + content.moveTo(48, 330); + content.lineTo(120, 330); + content.moveTo(48, 325); + content.lineTo(48, 335); + content.moveTo(120, 325); + content.lineTo(120, 335); + content.stroke(); + pdfGrayBars(content); + pdfText(content, font, 10, 48, 195, "Check edges, solid fill and each gray level."); + pdfText(content, font, 10, 48, 170, "Confirm paper output; SPOOLED means submitted only."); + content.addRect(36, 36, media.getWidth() - 72, media.getHeight() - 72); + content.stroke(); + } + } + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + document.save(bytes); + return (bytes.toByteArray()); + } + catch(IOException exception) + { + throw new QException("Could not generate branded test PDF", exception); + } + } + + + + /*************************************************************************** + ** Compatibility default for callers that have no configured label density. + ***************************************************************************/ + public static byte[] zpl() throws QException + { + return (zpl(203)); + } + + + + /*************************************************************************** + ** Reject page-only profiles even when invoked outside the HTTP entrypoint. + ***************************************************************************/ + public static byte[] zpl(TestPrintProfile profile) throws QException + { + return (zpl(profile.getDpi())); + } + + + + /*************************************************************************** + ** A four-by-six-inch label at the explicitly chosen printer resolution. + ** Text uses the bundled font rasterized to one bit; Code 128 stays native. + ***************************************************************************/ + public static byte[] zpl(int dpi) throws QException + { + BufferedImage image = labelImage(dpi); + int rowBytes = (image.getWidth() + 7) / 8; + byte[] bitmap = new byte[rowBytes * image.getHeight()]; + for(int y = 0; y < image.getHeight(); y++) + { + for(int x = 0; x < image.getWidth(); x++) + { + if((image.getRGB(x, y) & 0xffffff) == 0) + { + bitmap[y * rowBytes + x / 8] |= (byte) (0x80 >>> (x % 8)); + } + } + } + int module = Math.max(2, dpi / 100); + StringBuilder zpl = new StringBuilder("^XA^PW" + image.getWidth() + "^LL" + image.getHeight() + "^LH0,0"); + appendGraphicStrips(zpl, bitmap, rowBytes, image.getHeight()); + zpl.append("^FO").append(dots(24, dpi)).append(',').append(dots(276, dpi)).append("^BY").append(module) + .append("^BCN,").append(dots(42, dpi)).append(",N,N,N^FD").append(BARCODE_VALUE).append("^FS^PQ1^XZ"); + return (zpl.toString().getBytes(StandardCharsets.US_ASCII)); + } + + + + /*************************************************************************** + ** Zebra limits each graphic field to 99,999 bytes. Whole-row strips preserve + ** padding and image coordinates without downloading persistent graphics. + ***************************************************************************/ + private static void appendGraphicStrips(StringBuilder zpl, byte[] bitmap, int rowBytes, int height) + { + int rowsPerStrip = 99999 / rowBytes; + for(int firstRow = 0; firstRow < height; firstRow += rowsPerStrip) + { + int rows = Math.min(rowsPerStrip, height - firstRow); + int length = rows * rowBytes; + int offset = firstRow * rowBytes; + zpl.append("^FO0,").append(firstRow).append("^GFA,").append(length).append(',').append(length).append(',').append(rowBytes).append(',') + .append(HexFormat.of().withUpperCase().formatHex(bitmap, offset, offset + length)).append("^FS"); + } + } + + + + /*************************************************************************** + ** Deterministic image also supports inspection without contacting a printer. + ***************************************************************************/ + static BufferedImage labelImage(int dpi) throws QException + { + if(!Set.of(203, 300, 600).contains(dpi)) + { + throw new IllegalArgumentException("Label resolution must be 203, 300 or 600 dpi"); + } + try(InputStream stream = fontStream()) + { + Font font = Font.createFont(Font.TRUETYPE_FONT, stream); + BufferedImage image = new BufferedImage(dpi * 4, dpi * 6, BufferedImage.TYPE_BYTE_BINARY); + Graphics2D graphics = image.createGraphics(); + try + { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, image.getWidth(), image.getHeight()); + graphics.scale(dpi / 72.0, dpi / 72.0); + graphics.setColor(Color.BLACK); + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); + graphics.drawRect(12, 12, 264, 408); + graphics.drawImage(logo(), 210, 30, 54, 54, null); + labelText(graphics, font, 43, 24, 58, "QQQ"); + labelText(graphics, font, 10, 24, 79, "WAREHOUSE PRINT CHECK"); + labelText(graphics, font, 12, 24, 101, "TEST PRINT - NOT POSTAGE"); + labelText(graphics, font, 9, 24, 119, "4 x 6 in / " + dpi + " dpi / 1 copy"); + graphics.drawLine(24, 130, 264, 130); + labelText(graphics, font, 9, 24, 148, "READ EACH CHARACTER"); + labelText(graphics, font, 25, 24, 179, "0O 1Il 5S"); + labelText(graphics, font, 25, 24, 210, "8B 2Z 6G"); + labelText(graphics, font, 12, 24, 239, "BIN A01-I1 / SKU O0-5S8B"); + labelText(graphics, font, 12, 24, 258, "ORDER QQQ-123456 / QTY 02"); + labelText(graphics, font, 10, 24, 338, "SCAN: " + BARCODE_VALUE); + graphics.drawLine(24, 365, 96, 365); + graphics.drawLine(24, 361, 24, 369); + graphics.drawLine(96, 361, 96, 369); + labelText(graphics, font, 9, 106, 368, "25.4 mm / 1 inch"); + labelText(graphics, font, 8, 24, 398, "Confirm output, size and barcode scan."); + } + finally + { + graphics.dispose(); + } + return (image); + } + catch(IOException | FontFormatException exception) + { + throw new QException("Could not generate branded test label", exception); + } + } + + + + /*************************************************************************** + ** Fail explicitly if packaging omitted the licensed font resource. + ***************************************************************************/ + private static InputStream fontStream() throws IOException + { + InputStream stream = PrintingTestDocuments.class.getResourceAsStream(FONT_RESOURCE); + if(stream == null) + { + throw new IOException("Bundled test-document font is missing"); + } + return (stream); + } + + + + /*************************************************************************** + ** Composite transparent source artwork on white before monochrome printing. + ***************************************************************************/ + private static BufferedImage logo() throws IOException + { + try(InputStream stream = PrintingTestDocuments.class.getResourceAsStream(LOGO_RESOURCE)) + { + if(stream == null) + { + throw new IOException("Bundled QQQ logo is missing"); + } + BufferedImage source = ImageIO.read(stream); + BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_BYTE_BINARY); + Graphics2D graphics = image.createGraphics(); + try + { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, image.getWidth(), image.getHeight()); + graphics.drawImage(source, 0, 0, null); + } + finally + { + graphics.dispose(); + } + return (image); + } + } + + + + /*************************************************************************** + ** Convert layout points to printer dots without changing physical size. + ***************************************************************************/ + private static int dots(int points, int dpi) + { + return ((int) Math.round(points * dpi / 72.0)); + } + + + + /*************************************************************************** + ** Draw text using the same bundled face on both document formats. + ***************************************************************************/ + private static void labelText(Graphics2D graphics, Font font, int size, int x, int y, String text) + { + graphics.setFont(font.deriveFont((float) size)); + graphics.drawString(text, x, y); + } + + + + /*************************************************************************** + ** Vector PDF text remains searchable and carries its own font subset. + ***************************************************************************/ + private static void pdfText(PDPageContentStream content, PDType0Font font, int size, int x, int y, String text) throws IOException + { + content.beginText(); + content.setFont(font, size); + content.newLineAtOffset(x, y); + content.showText(text); + content.endText(); + } + + + + /*************************************************************************** + ** Five vector gray levels expose missing toner and driver conversion. + ***************************************************************************/ + private static void pdfGrayBars(PDPageContentStream content) throws IOException + { + for(int index = 0; index < 5; index++) + { + content.setNonStrokingColor(index / 4.0f); + content.addRect(48 + index * 72, 240, 72, 48); + content.fill(); + } + content.setNonStrokingColor(0f); + content.addRect(48, 240, 360, 48); + content.stroke(); + } + + + + /*************************************************************************** + ** Compact label PDF keeps vector glyphs and explicit physical media bounds. + ***************************************************************************/ + private static void pdfLabel(PDPageContentStream content, PDType0Font font) throws IOException + { + pdfText(content, font, 43, 24, 374, "QQQ"); + pdfText(content, font, 10, 24, 353, "WAREHOUSE PRINT CHECK"); + pdfText(content, font, 12, 24, 331, "TEST PRINT - NOT POSTAGE"); + pdfText(content, font, 9, 24, 313, "4 x 6 in / PDF / ACTUAL SIZE"); + pdfText(content, font, 9, 24, 284, "READ EACH CHARACTER"); + pdfText(content, font, 25, 24, 253, "0O 1Il 5S"); + pdfText(content, font, 25, 24, 222, "8B 2Z 6G"); + pdfText(content, font, 12, 24, 193, "BIN A01-I1 / SKU O0-5S8B"); + pdfText(content, font, 12, 24, 174, "ORDER QQQ-123456 / QTY 02"); + pdfText(content, font, 11, 24, 132, "PDF FONT / SCALE CHECK"); + pdfText(content, font, 9, 106, 64, "25.4 mm / 1 inch"); + pdfText(content, font, 8, 24, 34, "Confirm output and measure the line."); + content.setLineWidth(1); + content.moveTo(24, 67); + content.lineTo(96, 67); + content.moveTo(24, 63); + content.lineTo(24, 71); + content.moveTo(96, 63); + content.lineTo(96, 71); + content.addRect(12, 12, 264, 408); + content.stroke(); + } +} + diff --git a/src/main/java/com/kingsrook/qbits/printing/TestPrintProfile.java b/src/main/java/com/kingsrook/qbits/printing/TestPrintProfile.java new file mode 100644 index 0000000..ae5d79d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/TestPrintProfile.java @@ -0,0 +1,96 @@ +/* + * 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; + + +/******************************************************************************* + ** Explicit stock and resolution choices for built-in diagnostic documents. + *******************************************************************************/ +public enum TestPrintProfile +{ + LETTER("Letter - 8.5 x 11 in", 0), + A4("A4 - 210 x 297 mm", 0), + LABEL_4X6_203("4 x 6 in label - 203 dpi", 203), + LABEL_4X6_300("4 x 6 in label - 300 dpi", 300), + LABEL_4X6_600("4 x 6 in label - 600 dpi", 600); + + private final String label; + private final int dpi; + + + + /*************************************************************************** + ** Store a human label and printer density without guessing from a name. + ***************************************************************************/ + TestPrintProfile(String label, int dpi) + { + this.label = label; + this.dpi = dpi; + } + + + + /*************************************************************************** + ** Validate before enqueueing. Omitted profiles retain legacy defaults. + ***************************************************************************/ + public static TestPrintProfile resolve(String format, String value) throws PrintingException + { + if(!"PDF".equals(format) && !"ZPL".equals(format)) + { + throw new PrintingException(400, "Only PDF and ZPL are supported"); + } + TestPrintProfile profile; + try + { + profile = value == null ? ("PDF".equals(format) ? LETTER : LABEL_4X6_203) : valueOf(value); + } + catch(IllegalArgumentException exception) + { + throw new PrintingException(400, "Unknown test profile"); + } + if("ZPL".equals(format) && profile.dpi == 0) + { + throw new PrintingException(400, "ZPL test prints require a 4 x 6 label profile with the matching printer dpi"); + } + return (profile); + } + + + + /*************************************************************************** + ** Friendly choice label used by native QQQ forms. + ***************************************************************************/ + public String getLabel() + { + return (label); + } + + + + /*************************************************************************** + ** Zero marks page profiles; label values are supported device resolutions. + ***************************************************************************/ + public int getDpi() + { + return (dpi); + } +} + diff --git a/src/main/resources/com/kingsrook/qbits/printing/fonts/JetBrainsMono-Bold.ttf b/src/main/resources/com/kingsrook/qbits/printing/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8c93043de6454ad2d5575f0751150c6551d9c588 GIT binary patch literal 277828 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

^Q2@p_nJptvCyhdNtfUDv9XvP^J$Tq^Yf}I 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 s3n!(`lCJDArN|NmBd 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

Zh7cd=34FaGWy`SDvi)66*nbC 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 RXt+-ctVzYaTEQjd{{RH8A$|Y= literal 0 HcmV?d00001 diff --git a/src/main/resources/com/kingsrook/qbits/printing/fonts/OFL.txt b/src/main/resources/com/kingsrook/qbits/printing/fonts/OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/src/main/resources/com/kingsrook/qbits/printing/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/main/resources/com/kingsrook/qbits/printing/fonts/README.md b/src/main/resources/com/kingsrook/qbits/printing/fonts/README.md new file mode 100644 index 0000000..6e54476 --- /dev/null +++ b/src/main/resources/com/kingsrook/qbits/printing/fonts/README.md @@ -0,0 +1,25 @@ +# Test-document font + +`JetBrainsMono-Bold.ttf` is the unmodified static **JetBrains Mono Bold v2.304** font from JetBrains. It is bundled for the built-in PDF and rasterized ZPL test documents, so those paths use the same letterforms without relying on installed computer or printer fonts. + +Copyright 2020 The JetBrains Mono Project Authors. The font is licensed under the **SIL Open Font License 1.1**, reproduced in [OFL.txt](OFL.txt); it is not covered by this repository's Apache license. Preserve that license and attribution when redistributing the font. + +## Provenance + +- [Official font design and character comparisons](https://www.jetbrains.com/lp/mono/) +- [Official tagged font source](https://github.com/JetBrains/JetBrainsMono/blob/v2.304/fonts/ttf/JetBrainsMono-Bold.ttf) +- [Official tagged license](https://github.com/JetBrains/JetBrainsMono/blob/v2.304/OFL.txt) +- Acquired and inspected: 2026-10-30. No modifications or font conversions. +- Font size: 277,828 bytes. +- Font SHA-256: `5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb` +- License SHA-256: `30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad` + +## Rendering and readability + +Visual inspection of the actual bundled font confirmed a dotted zero versus plain capital O, an angled top and baseline on digit 1, horizontal bars on capital I, and a curved foot on lowercase l. The 5/S, 2/Z, 8/B and 6/G pairs also have visibly different outlines. Inspection included nominal 12-point text rasterized at 203 and 300 dpi; physical printer acceptance remains a separate check. This does not guarantee error-free reading at every size or on damaged labels. + +Use ordinary character drawing without programming ligatures for identifiers. Favor at least 12-point text for important warehouse identifiers, generous whitespace, and a larger explicit ambiguous-character proof row. Keep barcode generation separate from font rendering. + +The file contains TrueType `glyf` outlines, has no variable-font `fvar` table, and its OS/2 `fsType` is zero. It therefore has the static TrueType structure expected by the project's PDFBox 2.x embedding path. Load the resource with `PDType0Font.load(document, inputStream, true)` to embed a subset. See the [official PDFBox 2.x API](https://pdfbox.apache.org/docs/2.0.13/javadocs/org/apache/pdfbox/pdmodel/font/PDType0Font.html). The resource inspection itself is not a substitute for the generator's PDFBox regression test. + +For ZPL, load the same resource through `Font.createFont(Font.TRUETYPE_FONT, inputStream)`, derive the required pixel size from the selected printer dpi, render onto an opaque white background, and encode the monochrome result as document-local graphics. This preserves the distinctive glyphs without installing a font on the printer. Raster previews and physical output still need inspection at each supported resolution. diff --git a/src/main/resources/com/kingsrook/qbits/printing/images/qqq-logo.png b/src/main/resources/com/kingsrook/qbits/printing/images/qqq-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..da00c9174e0098d85883e59f75fce8cb68f584a5 GIT binary patch literal 43481 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 HdGr4Ok6+YI literal 0 HcmV?d00001 diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java index 5fdbb6e..a0947b7 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java @@ -47,6 +47,7 @@ import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -58,6 +59,52 @@ import static org.junit.jupiter.api.Assertions.assertTrue; *******************************************************************************/ class PrintingProcessesTest { + /*************************************************************************** + ** Native actions and device requests use identical bundled label documents. + ***************************************************************************/ + @Test + void builtInTestEntryPointsShareProfilesAndRejectChangedIdempotentRequests() throws Exception + { + Fixture fixture = fixture(); + fixture.service().inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser") + .put("name", "Packing test printer").put("formats", new JSONArray(List.of("PDF", "ZPL"))))); + QRecord printer = PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()); + String nativeJob = PrintingProcesses.submitTest(printer, false, "LABEL_4X6_300"); + JSONObject request = new JSONObject().put("printerId", "laser").put("format", "ZPL").put("testProfile", "LABEL_4X6_300").put("requestId", "label-profile-test"); + String deviceJob = fixture.service().testPrint(fixture.device(), request).getString("jobId"); + assertEquals(fixture.service().getJob(nativeJob).getString("contentSha256"), fixture.service().getJob(deviceJob).getString("contentSha256")); + assertEquals(deviceJob, fixture.service().testPrint(fixture.device(), request).getString("jobId")); + assertEquals(409, assertThrows(PrintingException.class, () -> fixture.service().testPrint(fixture.device(), request.put("testProfile", "LABEL_4X6_203"))).getStatus()); + assertEquals(nativeJob, fixture.service().claim(fixture.device()).getJSONObject("job").getString("jobId")); + assertArrayEquals(PrintingTestDocuments.zpl(300), fixture.service().content(fixture.device(), nativeJob)); + assertEquals(400, assertThrows(PrintingException.class, () -> fixture.service().testPrint(fixture.device(), request.put("testProfile", "LETTER"))).getStatus()); + assertThrows(PrintingException.class, () -> fixture.service().testPrint(fixture.device(), request.put("testProfile", JSONObject.NULL))); + assertThrows(PrintingException.class, () -> fixture.service().testPrint(fixture.device(), request.put("format", "RAW"))); + assertThrows(QException.class, () -> PrintingProcesses.submitTest(printer, false, "A4")); + assertEquals(1, fixture.service().queuedJobs(fixture.device()).length()); + } + + + + /*************************************************************************** + ** Stock/resolution choices are present before both native review flows. + ***************************************************************************/ + @Test + void nativeFormsOfferStockAndResolutionBeforeSelectedPrinterReview() throws Exception + { + Fixture fixture = fixture(); + QFrontendStepMetaData single = (QFrontendStepMetaData) fixture.instance().getProcess("printingTestZpl").getStep("details"); + assertEquals("printing_testProfileZpl", single.getFormField("testProfile").getPossibleValueSourceName()); + var selected = fixture.instance().getProcess("printingTestSelectedPdf"); + assertEquals("testProfile", selected.getStepList().getFirst().getName()); + QFrontendStepMetaData form = (QFrontendStepMetaData) selected.getStep("testProfile"); + assertEquals("printing_testProfilePdf", form.getFormField("testProfile").getPossibleValueSourceName()); + assertEquals(3, fixture.instance().getPossibleValueSource("printing_testProfileZpl").getEnumValues().size()); + assertEquals(5, fixture.instance().getPossibleValueSource("printing_testProfilePdf").getEnumValues().size()); + } + + + /*************************************************************************** ** Native record selection requires duplicate consent and preserves the source. ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingTestDocumentsExport.java b/src/test/java/com/kingsrook/qbits/printing/PrintingTestDocumentsExport.java new file mode 100644 index 0000000..e943e93 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingTestDocumentsExport.java @@ -0,0 +1,63 @@ +/* + * 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 javax.imageio.ImageIO; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; + + +/******************************************************************************* + ** Manual local preview export only; this utility never connects to a printer. + *******************************************************************************/ +public final class PrintingTestDocumentsExport +{ + /*************************************************************************** + ** Write diagnostic artifacts to the caller's explicit preview directory. + ***************************************************************************/ + public static void main(String[] args) throws Exception + { + if(args.length != 1) + { + throw new IllegalArgumentException("Pass one local preview output directory"); + } + Path directory = Path.of(args[0]).toAbsolutePath(); + Files.createDirectories(directory); + for(TestPrintProfile profile : TestPrintProfile.values()) + { + byte[] pdf = PrintingTestDocuments.pdf(profile); + Files.write(directory.resolve(profile.name() + ".pdf"), pdf); + try(PDDocument document = PDDocument.load(pdf)) + { + ImageIO.write(new PDFRenderer(document).renderImageWithDPI(0, 144), "png", directory.resolve(profile.name() + "-pdf.png").toFile()); + } + if(profile.getDpi() > 0) + { + Files.write(directory.resolve(profile.name() + ".zpl"), PrintingTestDocuments.zpl(profile)); + ImageIO.write(PrintingTestDocuments.labelImage(profile.getDpi()), "png", directory.resolve(profile.name() + "-raster.png").toFile()); + } + } + } +} + diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingTestDocumentsTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingTestDocumentsTest.java new file mode 100644 index 0000000..64cfd0a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingTestDocumentsTest.java @@ -0,0 +1,209 @@ +/* + * 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.awt.image.BufferedImage; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Verify portable font/media content and bounded monochrome printer payloads. + *******************************************************************************/ +class PrintingTestDocumentsTest +{ + /*************************************************************************** + ** Every PDF profile has searchable diagnostic text and an embedded font. + ***************************************************************************/ + @Test + void pdfProfilesEmbedFontAndUsePhysicalMedia() throws Exception + { + for(TestPrintProfile profile : TestPrintProfile.values()) + { + byte[] bytes = PrintingTestDocuments.pdf(profile); + assertArrayEquals(bytes, PrintingTestDocuments.pdf(profile), "Identical requests must preserve the content hash"); + try(PDDocument document = PDDocument.load(bytes)) + { + assertEquals(1, document.getNumberOfPages()); + PDRectangle size = document.getPage(0).getMediaBox(); + PDRectangle expected = profile.getDpi() > 0 ? new PDRectangle(288, 432) : (profile == TestPrintProfile.A4 ? PDRectangle.A4 : PDRectangle.LETTER); + assertEquals(expected.getWidth(), size.getWidth(), 0.01); + assertEquals(expected.getHeight(), size.getHeight(), 0.01); + String text = new PDFTextStripper().getText(document); + assertTrue(text.contains("QQQ")); + assertTrue(text.contains("TEST PRINT - NOT POSTAGE")); + assertTrue(text.contains("0O 1Il 5S")); + assertTrue(text.contains("25.4 mm")); + var resources = document.getPage(0).getResources(); + int fonts = 0; + for(var name : resources.getFontNames()) + { + fonts++; + assertTrue(resources.getFont(name).isEmbedded()); + assertTrue(resources.getFont(name).getName().contains("JetBrainsMono")); + } + assertEquals(1, fonts); + } + } + } + + + + /*************************************************************************** + ** All supported densities preserve inches and carry valid bounded GFA data. + ***************************************************************************/ + @Test + void labelDensitiesCarryExactBitmapAndNativeBarcodeWithoutPersistentDownloads() throws Exception + { + for(int dpi : new int[] {203, 300, 600}) + { + byte[] bytes = PrintingTestDocuments.zpl(dpi); + assertTrue(bytes.length < 3 * 1024 * 1024); + String zpl = new String(bytes, StandardCharsets.US_ASCII); + int width = dpi * 4; + int height = dpi * 6; + int rowBytes = (width + 7) / 8; + assertTrue(zpl.startsWith("^XA^PW" + width + "^LL" + height + "^LH0,0")); + byte[] bitmap = reconstructGraphicStrips(zpl, rowBytes, height); + BufferedImage expected = PrintingTestDocuments.labelImage(dpi); + for(int y = 0; y < height; y++) + { + for(int x = 0; x < rowBytes * 8; x++) + { + boolean black = (bitmap[y * rowBytes + x / 8] & (0x80 >>> (x % 8))) != 0; + boolean expectedBlack = x < width && (expected.getRGB(x, y) & 0xffffff) == 0; + assertEquals(expectedBlack, black, "Reconstructed raster and row padding must match"); + } + } + assertEquals(0, bitmap[0]); + assertEquals(0, bitmap[bitmap.length - 1]); + assertTrue(zpl.contains("^FD" + PrintingTestDocuments.BARCODE_VALUE + "^FS")); + Matcher barcode = Pattern.compile("\\^FO(\\d+),(\\d+)\\^BY(\\d+)\\^BCN,(\\d+),N,N,N").matcher(zpl); + assertTrue(barcode.find()); + int barcodeX = Integer.parseInt(barcode.group(1)); + int barcodeY = Integer.parseInt(barcode.group(2)); + int module = Integer.parseInt(barcode.group(3)); + int barcodeHeight = Integer.parseInt(barcode.group(4)); + int maximumModules = 11 * (PrintingTestDocuments.BARCODE_VALUE.length() + 2) + 13; + assertTrue(barcodeX >= 10 * module); + assertTrue(barcodeX + (maximumModules + 10) * module < width - dpi / 8); + assertTrue(barcodeY > 258 * dpi / 72); + assertTrue(barcodeY + barcodeHeight < 330 * dpi / 72); + assertTrue(zpl.endsWith("^PQ1^XZ")); + assertFalse(zpl.contains("~D")); + assertFalse(zpl.contains("^A0")); + } + assertArrayEquals(PrintingTestDocuments.zpl(203), PrintingTestDocuments.zpl()); + } + + + + /*************************************************************************** + ** Parse actual wire fields and reject any missing, overlapping or oversized + ** strip before comparing the complete bitmap with the rendered source. + ***************************************************************************/ + private static byte[] reconstructGraphicStrips(String zpl, int rowBytes, int height) + { + Matcher matcher = Pattern.compile("\\^FO0,(\\d+)\\^GFA,(\\d+),(\\d+),(\\d+),([0-9A-F]+)\\^FS").matcher(zpl); + byte[] bitmap = new byte[rowBytes * height]; + int nextRow = 0; + int strips = 0; + while(matcher.find()) + { + strips++; + int firstRow = Integer.parseInt(matcher.group(1)); + int length = Integer.parseInt(matcher.group(2)); + assertEquals(nextRow, firstRow); + assertTrue(length > 0 && length <= 99999); + assertEquals(length, Integer.parseInt(matcher.group(3))); + assertEquals(rowBytes, Integer.parseInt(matcher.group(4))); + assertEquals(0, length % rowBytes); + assertEquals(length * 2, matcher.group(5).length()); + byte[] strip = HexFormat.of().parseHex(matcher.group(5)); + assertTrue(firstRow * rowBytes + strip.length <= bitmap.length); + System.arraycopy(strip, 0, bitmap, firstRow * rowBytes, strip.length); + nextRow += length / rowBytes; + } + assertEquals(height, nextRow); + assertTrue(strips > 1); + assertEquals(strips, zpl.split("\\^GFA,", -1).length - 1); + return (bitmap); + } + + + + /*************************************************************************** + ** Visible text and border fit inside quiet margins at each device density. + ***************************************************************************/ + @Test + void rasterArtworkLeavesPhysicalMargins() throws Exception + { + for(int dpi : new int[] {203, 300, 600}) + { + BufferedImage image = PrintingTestDocuments.labelImage(dpi); + int black = 0; + int margin = dpi / 8; + for(int y = 0; y < image.getHeight(); y++) + { + for(int x = 0; x < image.getWidth(); x++) + { + if((image.getRGB(x, y) & 0xffffff) == 0) + { + black++; + assertTrue(x >= margin && x < image.getWidth() - margin && y >= margin && y < image.getHeight() - margin); + } + } + } + assertTrue(black > dpi * dpi / 2); + } + } + + + + /*************************************************************************** + ** Invalid combinations fail before a job can be created. + ***************************************************************************/ + @Test + void profilesRejectWrongFormatStockAndDensity() throws Exception + { + assertEquals(TestPrintProfile.LETTER, TestPrintProfile.resolve("PDF", null)); + assertEquals(TestPrintProfile.LABEL_4X6_203, TestPrintProfile.resolve("ZPL", null)); + assertEquals(TestPrintProfile.A4, TestPrintProfile.resolve("PDF", "A4")); + assertEquals(400, assertThrows(PrintingException.class, () -> TestPrintProfile.resolve("ZPL", "LETTER")).getStatus()); + assertThrows(PrintingException.class, () -> TestPrintProfile.resolve("ZPL", "A4")); + assertThrows(PrintingException.class, () -> TestPrintProfile.resolve("PDF", "UNKNOWN")); + assertThrows(PrintingException.class, () -> TestPrintProfile.resolve("RAW", null)); + assertThrows(IllegalArgumentException.class, () -> PrintingTestDocuments.zpl(200)); + } +} + -- 2.54.0 From ff4f328781a55f01f733b22cc90aa8b4b986bffb Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 17:54:54 -0500 Subject: [PATCH 03/35] docs: record branded diagnostic verification Co-authored-by: Codex (GPT-6) --- docs/SESSION-STATE.md | 8 ++++++-- docs/TODO.md | 15 +++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 2277a1a..a18eec4 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,10 +1,14 @@ # Session state — current checkpoint 2026-10-30 -## Branded diagnostic documents — source ready, verification pending +## Branded diagnostic documents — v0.1.3-rc.1 verified and published Built-in test paths now share PrintingTestDocuments and TestPrintProfile. Native QQQ forms and device test requests choose Letter, A4 or 4 × 6-inch label profiles at 203/300/600 dpi; omitted profiles retain PDF Letter/ZPL 203 defaults. ZPL rejects page profiles. Documents use the QQQ logo and wordmark, bundled OFL JetBrains Mono Bold, character-pair checks and a 25.4 mm reference. PDF embeds vector text; ZPL carries a monochrome bitmap in whole-row graphic strips of at most 99,999 bytes each and native Code 128. No persistent font download is required. Identical PDF generation uses stable document IDs for idempotency; changed request content still conflicts. -Added generator/media/font/margin tests, per-strip byte/row/hex bounds and full bitmap reconstruction checks at every density and native/API parity/profile-validation tests. PrintingTestDocumentsExport writes local PDF/PNG/ZPL previews without contacting printers. The parent agent owns coordinated verification and physical acceptance. This source checkpoint claims no new test results or printed output. +Source `a6266fe` passed the full `mvnw.cmd verify` gate: **28 tests and zero Checkstyle violations**. This includes generator/media/font/margin tests, per-strip byte/row/hex bounds and full bitmap reconstruction checks at every density, plus native/API parity and profile validation. Exact-source [build 41139885188](https://github.com/QRun-IO/qbit-printing/actions/runs/41139885188), [CodeQL 41139885415](https://github.com/QRun-IO/qbit-printing/actions/runs/41139885415) and [security 41139884980](https://github.com/QRun-IO/qbit-printing/actions/runs/41139884980) passed. The [v0.1.3-rc.1 release](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.3-rc.1) JAR, POM and SHA256SUMS assets are public and verified. + +PrintingTestDocumentsExport writes local PDF/PNG/ZPL previews without contacting printers. Installed Windows 0.1.7 passed all 16 UI checks. The loopback QQQ server was restarted with the new code using the same paired database and retained credentials. Exactly one Letter PDF to the laser, one LABEL_4X6_300 ZPL to the Zebra and one LABEL_4X6_300 PDF to the Zebra reached SPOOLED through the installed management pipe, device API and shared generator. Their bytes matched the reviewed previews. Physical output and scanner confirmation remain pending; spool acceptance does not establish paper output. Deployed remote QQQ integration and sustained throughput remain separate qualification work. + +## Historical source and integration checkpoints 2026-10-30 source0.1.2-rc.1: implemented client-authoritative Name/Description/Location synchronization, preserved computer/queue technical names, read-only native QQQ profile fields and friendly selector labels. Added additive schema backfill/widening, dispatch/history indexes, one-row payload-free claims and metadata-only keyset history pages. Added long-text, explicit-clear, migration, tenant and synthetic 10,000-row pagination/claim tests. Root verified22tests with zeroCheckstyle violations using mvnw.cmd verify. Protocol28definitions/21fixtures passed and CI pins a0de93b. Exact-source GitHub build41126545760 and security41126545795 passed for cfe8a5f. Prior verification below predates these changes. No physical prints were requested by this work. diff --git a/docs/TODO.md b/docs/TODO.md index 97db0e9..47c1df7 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,7 +6,7 @@ - [x] QQQ metadata and native enrollment/enable/test-print processes. - [x] Durable enqueue EffectTarget for QQQ workflows. - [x] Verify local laser output from connector and installed LocalSystem service; user confirmed both pages. -- [ ] Verify Zebra hardware when connected. +- [ ] Confirm physical output and barcode scanning on the connected Zebra; built-in PDF/ZPL spool acceptance is recorded below. - [x] Add separate synthetic native QQQ frontend preview host. - [x] Replace identifier text inputs with tenant-scoped, device-dependent selectors and add table/record actions. - [ ] Qualify deployed QQQ host integration. @@ -15,7 +15,8 @@ - [x] Verify remote CI/security and publish v0.1.0-rc.1 JAR/POM/checksum assets. - [x] Client-owned device/printer name, description and location synchronized into native QQQ metadata. - [ ] Logical destinations and object-storage document backend. -- [ ] Run the coordinated Java gate for profile migration, long text and 10,000-row bounded-history regressions; qualify sustained throughput separately. +- [x] Run the coordinated Java gate for profile migration, long text and 10,000-row bounded-history regressions; included in the passing 28-test v0.1.3-rc.1 gate. +- [ ] Qualify sustained throughput separately from synthetic bounded-history tests. - [x] Device-scoped test/retry/cancel/queued-job routes and native record/table cancellation/reprint review, explicit duplicate-risk consent and retry idempotency. - [ ] Server-side job expiry/document retention and operational alerts. - [ ] PostgreSQL integration and multi-node stress tests; migration/version management. @@ -23,7 +24,7 @@ - [x] Add native Printing home with scoped operational widgets and actionable links. - [ ] Complete dashboard-standard live attention, URL scope, stale/retry and responsive/accessibility acceptance. -## 0.1.1-rc.1 integration checkpoint +## Historical 0.1.1-rc.1 integration checkpoint - [x] Verify current Java source: 19 tests passed, zero Checkstyle violations. - [x] Preserve job outcome while retry creates a new job; reject cancellation after claim. @@ -34,5 +35,11 @@ - [ ] Run a fresh explicitly authorized physical acceptance job for the final package; Zebra remains deferred. - [ ] Qualify reboot/sign-in/service recovery and deployed-host behavior; no source-only or synthetic result substitutes for those checks. +## v0.1.3-rc.1 diagnostic documents + - [x] Share branded diagnostic generators and explicit stock/resolution profiles across native QQQ and device test-print entrypoints. -- [ ] Run coordinated branded-document tests, inspect exported layouts and confirm physical output/scanning; no new template print is yet qualified. +- [x] Verify source a6266fe: full Maven gate passed 28 tests with zero Checkstyle violations; exact-source build, CodeQL and security workflows passed. +- [x] Publish v0.1.3-rc.1 and verify its public JAR, POM and SHA256SUMS assets. +- [x] Installed Windows 0.1.7 passed all 16 UI checks; reviewed built-in preview bytes matched the three submitted documents. +- [x] Submit one Letter PDF to the laser, one 4 × 6-inch 300 dpi ZPL and one matching PDF to the Zebra through the installed pipe/device API/shared generator; all reached SPOOLED with the original paired database retained. +- [ ] Obtain user confirmation of all three physical outputs and barcode scanning; successful spooling is not physical acceptance. -- 2.54.0 From 33148b94d584f33f2458b93413c1d7fd6e212b77 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 18:07:13 -0500 Subject: [PATCH 04/35] docs: confirm laser and Zebra diagnostic output Co-authored-by: Codex (GPT-6) --- README.md | 2 ++ docs/SESSION-STATE.md | 4 +++- docs/TODO.md | 14 ++++++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f6e6ef6..1d4c340 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ For native QQQ screens, grant `printing_admin` and populate the session's `print Typical setup: run **Enroll a print device** from Printing home or Print devices, enroll the Windows connector, and select local queues to expose in its companion/tray. Use **Set printer approval** to choose a named device and one of its printers. Test-print forms offer only exposed, approved printers supporting the chosen format. Device and printer choices respect the current user's tenant access. +For label printers, configure the machine's **Windows Printing Defaults** for the actual stock size and print method before testing PDFs through the service. A test profile selects document size and ZPL resolution; it does not change the Windows driver or printer's media configuration. Our 300 dpi Zebra required 4 × 6-inch stock and Thermal Direct instead of its previous 4.09 × 2-inch Thermal Transfer defaults. After correcting those settings and resuming the printer, the user confirmed the built-in PDF label, alongside the already confirmed ZPL label and laser PDF. Barcode scanning and measured alignment remain separate checks; `SPOOLED` alone is not proof of physical output. + On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the related printer and device names; approval, availability, format and status fields have named choices. Metadata names follow QBit conventions: tables and data sources use `printing_*`, while processes/widgets use `printing...`. The initial namespace is fixed to `printing`. The QBit preserves the host's `QBrandingMetaData`; customer identity belongs in the host branding, not a custom QBit build. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index a18eec4..f9b2cc9 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -6,7 +6,9 @@ Built-in test paths now share PrintingTestDocuments and TestPrintProfile. Native Source `a6266fe` passed the full `mvnw.cmd verify` gate: **28 tests and zero Checkstyle violations**. This includes generator/media/font/margin tests, per-strip byte/row/hex bounds and full bitmap reconstruction checks at every density, plus native/API parity and profile validation. Exact-source [build 41139885188](https://github.com/QRun-IO/qbit-printing/actions/runs/41139885188), [CodeQL 41139885415](https://github.com/QRun-IO/qbit-printing/actions/runs/41139885415) and [security 41139884980](https://github.com/QRun-IO/qbit-printing/actions/runs/41139884980) passed. The [v0.1.3-rc.1 release](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.3-rc.1) JAR, POM and SHA256SUMS assets are public and verified. -PrintingTestDocumentsExport writes local PDF/PNG/ZPL previews without contacting printers. Installed Windows 0.1.7 passed all 16 UI checks. The loopback QQQ server was restarted with the new code using the same paired database and retained credentials. Exactly one Letter PDF to the laser, one LABEL_4X6_300 ZPL to the Zebra and one LABEL_4X6_300 PDF to the Zebra reached SPOOLED through the installed management pipe, device API and shared generator. Their bytes matched the reviewed previews. Physical output and scanner confirmation remain pending; spool acceptance does not establish paper output. Deployed remote QQQ integration and sustained throughput remain separate qualification work. +PrintingTestDocumentsExport writes local PDF/PNG/ZPL previews without contacting printers. Installed Windows 0.1.7 passed all 16 UI checks, and its published release assets are verified. The loopback QQQ server was restarted with the new code using the same paired database and retained credentials. Exactly one Letter PDF to the laser, one LABEL_4X6_300 ZPL to the Zebra and one LABEL_4X6_300 PDF to the Zebra initially reached SPOOLED through the installed management pipe, device API and shared generator. Their bytes matched the reviewed previews. The user confirmed the laser page and the Zebra ZPL label, but the initial Zebra PDF did not appear. + +The Zebra machine driver defaults were 4.09 × 2 inches and Thermal Transfer, while the loaded stock required 4 × 6 inches and Thermal Direct. Windows Printing Defaults were corrected; the paused printer was restored to Thermal Direct and resumed. One fresh submission of the same generated PDF through QQQ administration and the installed service advanced the hardware counter by one while preserving Thermal Direct, and the user confirmed correct physical output. All three built-in format paths are now physically confirmed after the configuration correction; no application code fix was required. Barcode scanning and measured alignment remain unverified. Deployed remote QQQ integration and sustained throughput remain separate qualification work. ## Historical source and integration checkpoints diff --git a/docs/TODO.md b/docs/TODO.md index 47c1df7..2547228 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,7 +6,7 @@ - [x] QQQ metadata and native enrollment/enable/test-print processes. - [x] Durable enqueue EffectTarget for QQQ workflows. - [x] Verify local laser output from connector and installed LocalSystem service; user confirmed both pages. -- [ ] Confirm physical output and barcode scanning on the connected Zebra; built-in PDF/ZPL spool acceptance is recorded below. +- [x] Confirm physical built-in ZPL and PDF output on the connected Zebra; scanning and measured alignment remain separate checks below. - [x] Add separate synthetic native QQQ frontend preview host. - [x] Replace identifier text inputs with tenant-scoped, device-dependent selectors and add table/record actions. - [ ] Qualify deployed QQQ host integration. @@ -29,10 +29,9 @@ - [x] Verify current Java source: 19 tests passed, zero Checkstyle violations. - [x] Preserve job outcome while retry creates a new job; reject cancellation after claim. - [x] Expose cloud-queued jobs to the companion for visibility/cancellation before local receipt. -- [ ] Record the pending latest Windows changed-tree gate, including independent heartbeat during slow printing. -- [ ] Complete final MSI rebuild and actual elevated upgrade/UI acceptance after the user's pending UAC approval. -- [ ] Record final package hashes, signing status and applicable integration evidence before replacement release. -- [ ] Run a fresh explicitly authorized physical acceptance job for the final package; Zebra remains deferred. + +The Windows gate, MSI rebuild, installed UI acceptance, release evidence and fresh hardware submissions pending at this historical checkpoint are superseded by the newer records below and the Windows acceptance notes. Zebra is connected and both label format paths are physically confirmed. + - [ ] Qualify reboot/sign-in/service recovery and deployed-host behavior; no source-only or synthetic result substitutes for those checks. ## v0.1.3-rc.1 diagnostic documents @@ -42,4 +41,7 @@ - [x] Publish v0.1.3-rc.1 and verify its public JAR, POM and SHA256SUMS assets. - [x] Installed Windows 0.1.7 passed all 16 UI checks; reviewed built-in preview bytes matched the three submitted documents. - [x] Submit one Letter PDF to the laser, one 4 × 6-inch 300 dpi ZPL and one matching PDF to the Zebra through the installed pipe/device API/shared generator; all reached SPOOLED with the original paired database retained. -- [ ] Obtain user confirmation of all three physical outputs and barcode scanning; successful spooling is not physical acceptance. +- [x] User confirmed the Letter laser page and the Zebra label containing the barcode, identifying the latter as the built-in ZPL output. +- [x] Correct the Zebra machine driver defaults from 4.09 × 2-inch Thermal Transfer to 4 × 6-inch Thermal Direct, restore/resume the printer, and confirm one fresh built-in PDF printed correctly. No application code fix was needed. +- [x] Verify published Windows 0.1.7 assets; all three built-in physical paths are confirmed: laser PDF, Zebra ZPL and Zebra PDF. +- [ ] Obtain explicit barcode scan and measured alignment confirmation; successful spooling is not physical acceptance. -- 2.54.0 From 1655d4e811eac0a86be8c1b75d10be961ec10a2b Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 18:59:07 -0500 Subject: [PATCH 05/35] fix(printing): harden queue and isolate document retention Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- .github/workflows/ci.yml | 20 +- .github/workflows/codeql.yml | 3 +- .github/workflows/pr-gates.yml | 20 + .github/workflows/security.yml | 1 + CHANGELOG.md | 10 + README.md | 18 +- SECURITY.md | 4 +- docs/AUDIT-2026-10-30.md | 26 + docs/OPERATIONS.md | 4 +- docs/SESSION-STATE.md | 8 + docs/STORAGE-AND-RETENTION.md | 39 ++ docs/TODO.md | 5 + pom.xml | 4 +- .../printing/PrintingDocumentStorage.java | 287 +++++++++ .../qbits/printing/PrintingFacilities.java | 106 ++++ .../qbits/printing/PrintingHome.java | 2 + .../qbits/printing/PrintingJobProcesses.java | 3 +- .../qbits/printing/PrintingProcesses.java | 98 ++- .../qbits/printing/PrintingQBitConfig.java | 88 +++ .../qbits/printing/PrintingQBitProducer.java | 17 +- .../printing/PrintingRetentionPolicy.java | 50 ++ .../printing/PrintingRetentionProcesses.java | 231 +++++++ .../qbits/printing/PrintingService.java | 595 +++++++++++++++++- .../printing/PrintingDocumentStorageTest.java | 391 ++++++++++++ .../printing/PrintingMaintenanceTest.java | 172 +++++ .../qbits/printing/PrintingPoolTest.java | 295 +++++++++ .../qbits/printing/PrintingProcessesTest.java | 197 +++++- .../PrintingRetentionProcessesTest.java | 188 ++++++ .../qbits/printing/PrintingServiceTest.java | 104 ++- 29 files changed, 2924 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/pr-gates.yml create mode 100644 docs/AUDIT-2026-10-30.md create mode 100644 docs/STORAGE-AND-RETENTION.md create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingFacilities.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingRetentionPolicy.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingRetentionProcesses.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingDocumentStorageTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingMaintenanceTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingPoolTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingRetentionProcessesTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4a2722..646fa97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,21 +69,37 @@ jobs: contents: read security-events: write + release-codeql: + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/workflows/codeql.yml + permissions: + contents: read + security-events: write + release: if: startsWith(github.ref, 'refs/tags/v') - needs: [verify, release-security] + needs: [verify, release-security, release-codeql] runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write + id-token: write + attestations: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: qbit-printing-package path: dist + - name: Verify packaged checksums + working-directory: dist + run: sha256sum --check --strict SHA256SUMS + - name: Attest verified release artifacts + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/* - name: Publish tested GitHub release artifacts env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 368ce90..07515fd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,6 +5,7 @@ on: branches: [main] schedule: - cron: '41 6 * * 1' + workflow_call: permissions: {} jobs: codeql: @@ -14,4 +15,4 @@ jobs: security-events: write with: languages: '["java-kotlin", "actions"]' - build-mode: none \ No newline at end of file + build-mode: none diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml new file mode 100644 index 0000000..a052210 --- /dev/null +++ b/.github/workflows/pr-gates.yml @@ -0,0 +1,20 @@ +# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and +# dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA +# (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current. +name: pr-gates + +on: + pull_request: + +permissions: {} + +concurrency: + group: pr-gates-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pr: + uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 + permissions: + contents: read # read the commits being merged + pull-requests: read # read the pull request title and dependency diff diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index d623128..ee0d615 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -83,6 +83,7 @@ jobs: --list-all-pkgs --format json --output /src/trivy.json --exit-code 0 /src # Fail closed if Maven analysis did not include the actual dependency graph. jq -e 'any(.Results[]?.Packages[]?; .Name == "com.kingsrook.qqq:qqq-backend-core") and + any(.Results[]?.Packages[]?; .Name == "com.kingsrook.qqq:qqq-backend-module-filesystem") and any(.Results[]?.Packages[]?; .Name == "com.google.guava:guava")' trivy.json > /dev/null docker run --rm -v "$PWD:/src" "$TRIVY_IMAGE" \ convert --format sarif --output /src/trivy.sarif /src/trivy.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f66d039..60f6d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.1.4-rc.1 — unreleased + +- Make native enrollment and test-print execution replay-safe and retain known duplicate-output risk after later failures. +- Strictly validate print options, inventory formats/status and event bounds before persistence; recheck current queue format support at claim time. +- Add dedicated S3 document storage through the host's QQQ storage API, tenant object namespaces and immutable destination/reference checks. +- Add admin retention settings with a 365-day default, reviewed cleanup and bounded scheduled maintenance; preserve job history and uncertain work. +- Use named facility choices during enrollment and revalidate them under the current user's tenant/record permissions. +- Require release security/CodeQL gates, verified artifact promotion and build provenance; report the real packaged QBit version. +- Full local verification passed 53 tests with zero failures/errors and Checkstyle violations. Hosted checks remain pending; this entry does not claim release or live storage migration. + ## 0.1.3-rc.1 — 2026-10-30 - Shared QQQ-branded warehouse test pages and labels across native record/table actions and device requests. diff --git a/README.md b/README.md index 1d4c340..92485a0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector initiates outbound HTTPS polling; the cloud needs no inbound route into the facility. -**0.1.0-rc.1 is an initial prerelease**, tested against QQQ **4.1.0-RC.20** and Java 21. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR. +**Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. Audit source targets **0.1.4-rc.1**; the published baseline remains **0.1.3-rc.1** until the audit and release gates complete. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR. 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. @@ -14,11 +14,11 @@ flowchart LR D --> E[Local laser / Zebra] ``` -See the [screen walkthrough](docs/UI-WALKTHROUGH.md), [operations and troubleshooting](docs/OPERATIONS.md), [contributing](CONTRIBUTING.md), [security reporting](SECURITY.md), [changelog](CHANGELOG.md), and [Apache-2.0 license](LICENSE). +See the [screen walkthrough](docs/UI-WALKTHROUGH.md), [storage and retention](docs/STORAGE-AND-RETENTION.md), [operations and troubleshooting](docs/OPERATIONS.md), [contributing](CONTRIBUTING.md), [security reporting](SECURITY.md), [changelog](CHANGELOG.md), and [Apache-2.0 license](LICENSE). ## Build and verify -Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. CI pins protocol commit `f977bd01b62ceb7539190fd2861809537fcf504c`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). +Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. CI pins protocol commit `f4f977ca4aa6b749e6a52760eed5e53c99a7704d`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). ```powershell .\mvnw.cmd verify @@ -58,7 +58,7 @@ Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator For a source checkout, `mvnw.cmd install` (or `bash ./mvnw install`) installs the verified library into your local Maven repository. For a GitHub release, download the JAR, matching POM and `SHA256SUMS`, verify the checksums, then install those artifacts locally or through your organization's artifact repository. Example using the release files in the current directory: ```powershell -.\mvnw.cmd install:install-file '-Dfile=qbit-printing-0.1.0-rc.1.jar' '-DpomFile=qbit-printing-0.1.0-rc.1.pom' +.\mvnw.cmd install:install-file '-Dfile=qbit-printing-0.1.3-rc.1.jar' '-DpomFile=qbit-printing-0.1.3-rc.1.pom' ``` After the artifact is available in your configured repository, add: @@ -67,7 +67,7 @@ After the artifact is available in your configured repository, add: com.kingsrook.qbits qbit-printing - 0.1.0-rc.1 + 0.1.3-rc.1 ``` @@ -77,19 +77,25 @@ Device and printer names, descriptions and locations are edited in the Windows c `initialize()` adds profile columns, backfills physical names, widens existing name/title columns and creates dispatch/history indexes without deleting records. The existing `name` column becomes the effective display label; `computer_name`/`queue_name` retain technical names and `display_name` holds the optional override. Back up the database and run schema initialization during a maintenance/deployment window; column widening and index creation can lock populated tables. -Job history uses `GET /printing/v1/admin/jobs?limit=200&before=` with opaque keyset cursors and a maximum 200 rows per page. Claims read one candidate, pending inspection reads 50, and all these queries project metadata without fetching stored documents. Native dashboard lists already cap attention rows at 10 per category and recent work at 25. Dispatch/history indexes support growing histories; the synthetic 10,000-row regression test checks bounded pages and claim correctness, **not** a certified 10,000-print/day throughput measurement. Driver speed, document size, database hardware and retention policy still determine capacity. Documents remain stored until the host supplies a retention policy; this change does not silently delete jobs or documents. +Job history uses `GET /printing/v1/admin/jobs?limit=200&before=` with opaque keyset cursors and a maximum 200 rows per page. Claims read one candidate, pending inspection reads 50, and all these queries project metadata without fetching stored documents. Native dashboard lists already cap attention rows at 10 per category and recent work at 25. Dispatch/history indexes support growing histories; the synthetic 10,000-row regression test checks bounded pages and claim correctness, **not** a certified 10,000-print/day throughput measurement. Driver speed, document size, database hardware and retention policy still determine capacity. Document cleanup follows the configured retention policy and host schedule; the audit does not run a live purge or delete job history. ```java PrintingService printing = new PrintingService(dataSource, tenantId); printing.initialize(); new PrintingQBitProducer().withConfig(new PrintingQBitConfig() .withBackendName("appRdbms") + .withStorageTableName("printing_documents") // existing dedicated S3 storage table + .withSchedulerName("appScheduler") // existing host scheduler .withService(printing)).produce(qInstance, "printing"); server.addRouteProvider(new PrintingRoutes(printing, context -> hostPrintingAdminGuard(context))); ``` `hostPrintingAdminGuard` is the host's own authenticated administrator policy, not a supplied library method. The service is bound to one tenant; use a separate host service instance for each tenant. Device token verification and job ownership are enforced on every device operation. Management service methods are intended for trusted application code; enforce user/facility authorization before calling them. +The storage/scheduler options above are new in the pending 0.1.4 source. Configure a dedicated private printing bucket for the customer/environment through existing QQQ S3 metadata; no bucket credentials belong in printing forms. Admin retention defaults to 365 days after successful completion. Scheduled cleanup handles bounded batches every five minutes and preserves active, failed and uncertain work plus all receipts/history. Without a configured storage binding, legacy database documents remain supported; without a host scheduler, automatic cleanup does not run. Review [storage configuration, bucket isolation and S3 versioning](docs/STORAGE-AND-RETENTION.md) before deployment. + +Enrollment uses a named facility selector. Set `withFacilityPossibleValueSourceName("your_existing_facility_source")` to reuse an already registered host source and its record security. Without it, the standalone choice is **Default facility**. The backend validates the selection under the current user's permissions before issuing an enrollment code; typing or forging an unknown/foreign-tenant ID cannot bypass that check. Existing stored facility IDs are preserved; configure their original source to resolve their names. + For native QQQ screens, grant `printing_admin` and populate the session's `printing_tenant` security key with the tenant ID. The Printing home shows device health, queue status and work needing attention, with Devices, Printers and Print jobs beneath it. It nests under an existing `admin` app when available. Processes are dashboard/table/record actions, not navigation entries. Secret/token and document columns are excluded from metadata. Table mutations are rejected; use the service/processes instead. Typical setup: run **Enroll a print device** from Printing home or Print devices, enroll the Windows connector, and select local queues to expose in its companion/tray. Use **Set printer approval** to choose a named device and one of its printers. Test-print forms offer only exposed, approved printers supporting the chosen format. Device and printer choices respect the current user's tenant access. diff --git a/SECURITY.md b/SECURITY.md index a2981d7..b16f725 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ Report vulnerabilities privately through [GitHub vulnerability reporting](https://github.com/QRun-IO/qbit-printing/security/advisories/new), or `contact@kingsrook.com` if that channel is unavailable. Include affected versions, a minimal synthetic reproduction and expected versus actual authorization behavior. Do not put enrollment codes, bearer credentials, customer documents or exploit details in public issues. -The current `0.1.0-rc.1` prerelease targets QQQ 4.1+. Fixes target the current prerelease; older QQQ versions are outside scope. +The current prerelease targets QQQ 4.1+. Fixes target the current prerelease; older QQQ versions are outside scope. The host must provide authenticated management authorization and the correct tenant/facility context. Native QQQ operations require `printing_admin` and a matching `printing_tenant` session security value. Device credentials only authorize their device's queue/content/status operations. HTTPS is required for remote deployment; the supplied HTTP demos deliberately bind to loopback. -The UI demonstration uses a fixed mock identity and synthetic data and must remain local. It registers no connector transport routes. RAW ZPL is printer-control input: submit trusted generated labels, not arbitrary untrusted printer commands. Stored documents can contain personal information and need an application-specific retention policy. +The UI demonstration uses a fixed mock identity and synthetic data and must remain local. It registers no connector transport routes. RAW ZPL is printer-control input: submit trusted generated labels, not arbitrary untrusted printer commands. Stored documents can contain personal information. See [storage and retention](docs/STORAGE-AND-RETENTION.md) for dedicated bucket boundaries, tenant isolation and the default 365-day policy. diff --git a/docs/AUDIT-2026-10-30.md b/docs/AUDIT-2026-10-30.md new file mode 100644 index 0000000..0a84e80 --- /dev/null +++ b/docs/AUDIT-2026-10-30.md @@ -0,0 +1,26 @@ +# Server printing audit + +Target: 0.1.4-rc.1. Local implementation verification passed **53 tests, zero failures/errors and zero Checkstyle violations**. Hosted verification and the independent baseline security scan remain pending. No live database, object storage, enrollment or printer was changed. + +## Corrected behavior + +- Native test-print and enrollment backend replay reuse prepared identities. A consumed enrollment cannot be reactivated by repeating a process. +- Print options, inventory formats/status and event fields are checked before persistence. Claim rechecks the queue's current format capability. +- A later failure retains known submission risk. Reprint requires explicit consent when output might already exist, and retry replay resolves its existing receipt even if the original content has expired. +- Enrollment offers authorized named facility choices, with a host-source option and a named standalone default. Backend validation rejects forged and foreign-tenant selections. +- Print documents can use the existing QQQ S3 storage API with a dedicated printing bucket, tenant namespace and exact job/reference/destination validation. Legacy database documents remain readable. +- Durable upload intents and exact orphan cleanup account for failed uploads/queue commits. Cleanup commits each confirmed document removal independently. A renewable lease prevents normal overlapping maintenance; all work reuses a single connection and passes real one-/two-connection pool concurrency regressions. +- Native administration exposes the default 365-day policy, read-only storage scope, backlog and last cleanup result. A host scheduler runs bounded batches every five minutes. Age starts at server-observed successful completion; active, failed, uncertain and unknown-completion jobs remain protected. +- Release publication requires build/security/CodeQL checks, verified artifact promotion and build provenance. Packaged QBit metadata uses the JAR's real Implementation-Version. + +## Evidence and migration + +The gate exercises real H2 persistence and bounded pools, QQQ StorageAction with an in-memory backend, native process review/resume, current-session record security and real QApplicationJavalinServer route registration/authentication over loopback. Failure tests cover partial upload/deletion recovery, per-document commit boundaries, conflicting/foreign references, destination retargeting, overlapping cleanup and original-content expiry during retry replay. The built JAR manifest reports 0.1.4-rc.1. + +Schema initialization adds consumed-enrollment receipts, storage references/content availability, completion timestamps, retention settings/lease and upload intents. Existing inline bytes are retained; completion dates are backfilled only from durable successful events. No automatic inline-to-S3 copy or live purge occurs. Schema changes should be qualified against a backed-up deployment database before rollout. + +## Remaining boundaries + +Live S3 endpoints, IAM/bucket policies, external bucket users, PostgreSQL, multi-node lease-expiry behavior, production scheduler execution, remote TLS deployment and sustained 10,000-print/day throughput are not qualified by these tests. SQL and object deletion are not one transaction; crashes between them require idempotent recovery. S3 versioning and unfinished multipart parts have separate lifecycle semantics. See [storage and retention](STORAGE-AND-RETENTION.md). + +The independent deep scan covers the frozen pre-audit baseline, not the subsequently added storage feature. Two implementation agents independently reviewed the new storage boundaries and added regressions, but that does not substitute for a completed security scan or prove absence of defects. No numerical quality score is assigned. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 6d028c6..00f6703 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -24,7 +24,7 @@ Normal progression is QUEUED → CLAIMED → RECEIVED → SUBMITTING → SPOOLED Use a stable business idempotency key for retries of the same request. A changed target/document/options with the same key is a conflict. A deliberately new print requires a new key. The initial implementation does not automatically reclaim RECEIVED/SUBMITTING jobs or resend ambiguous output; inspect the queue and physical result before deciding to print again. -Documents are stored as base64 in the database. Set a host-specific retention and access policy before deploying sensitive packing slips or shipping labels. The first release has no retention scheduler or object-storage backend. +The pending 0.1.4 implementation stores new documents through the host's configured dedicated QQQ S3 storage table; existing inline database content remains readable. Administration defaults to 365 days after server-observed successful completion, with bounded scheduled cleanup when a host scheduler is configured. Active, failed, uncertain and unknown-completion legacy jobs remain protected. See [storage and retention](STORAGE-AND-RETENTION.md) for bucket separation, recovery and versioning details. No live storage migration or cleanup is implied by a source upgrade. ## Common failures @@ -42,4 +42,4 @@ Documents are stored as base64 in the database. Set a host-specific retention an ## Release qualification -Initial checks cover H2/native/HTTP tests and user-confirmed laser output from the development connector and installed Windows service. Follow-up qualification includes Zebra hardware, PostgreSQL, production host authentication, upgrade/migration paths, multiple QQQ nodes, prolonged disconnects and a broader printer/driver set. Current prerelease artifacts must not be described as certifying those scenarios. +Prior released checks include user-confirmed laser PDF and Zebra ZPL/PDF output with correct machine driver settings. Audit source checks cover H2, native processes, host HTTP registration, synthetic storage and small real connection pools. Follow-up qualification includes live S3/IAM, PostgreSQL, production host authentication, upgrade/migration paths, multiple QQQ nodes, prolonged disconnects and a broader printer/driver set. Current prerelease artifacts must not be described as certifying those scenarios. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index f9b2cc9..d721877 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,13 @@ # Session state — current checkpoint 2026-10-30 +## Deep audit in progress — pending 0.1.4-rc.1 + +Final local server verification passed **53 tests, zero failures/errors and zero Checkstyle violations**, including named facility selectors and current-user tenant authorization. The JAR manifest reports 0.1.4-rc.1. See [audit evidence](AUDIT-2026-10-30.md). Root is preparing the draft PR; no merge, release or live migration. All three printing repositories now enforce required checks even for administrators, explicitly approved by the user. Master coordination is in `R:\agent-comms\qqq-cloud-printing.md`; Record Files owns originals, printing owns delivery copies. The shared immutable-version reader/async source-authorization contract remains pending. Legacy durable outbox byte payloads are outside printing's S3 retention scope. + +Source fixes address native process replay, strict print options/inventory validation, duplicate-risk preservation and event bounds. The user requires admin-configurable document retention, default **365 days**, scheduled cleanup and proper S3 bucket separation. Implementation uses existing QQQ StorageAction/S3 configuration with a dedicated printing bucket, tenant namespaces and immutable per-job storage bindings; exact cleanup scope and cross-tenant isolation require regression evidence before release. Active/uncertain jobs and history/receipts remain protected. Existing inline documents remain readable. No live storage configuration, purge or database migration has run. + +Root owns publication and build/release gates; server/native-metadata agents own source and tests. Source, hosted provenance and independent security results remain pending. The installed Windows baseline is still 0.1.7; unsigned prereleases are explicitly accepted by the user. Do not infer deployed-cloud or new hardware acceptance from these source changes. + ## Branded diagnostic documents — v0.1.3-rc.1 verified and published Built-in test paths now share PrintingTestDocuments and TestPrintProfile. Native QQQ forms and device test requests choose Letter, A4 or 4 × 6-inch label profiles at 203/300/600 dpi; omitted profiles retain PDF Letter/ZPL 203 defaults. ZPL rejects page profiles. Documents use the QQQ logo and wordmark, bundled OFL JetBrains Mono Bold, character-pair checks and a 25.4 mm reference. PDF embeds vector text; ZPL carries a monochrome bitmap in whole-row graphic strips of at most 99,999 bytes each and native Code 128. No persistent font download is required. Identical PDF generation uses stable document IDs for idempotency; changed request content still conflicts. diff --git a/docs/STORAGE-AND-RETENTION.md b/docs/STORAGE-AND-RETENTION.md new file mode 100644 index 0000000..68d6f9f --- /dev/null +++ b/docs/STORAGE-AND-RETENTION.md @@ -0,0 +1,39 @@ +# Print document storage and retention + +Status: audit implementation pending final verification and release. QQQ 4.1+ hosts provide the existing storage and scheduler metadata; this module does not create buckets or copy credentials into printing settings. + +## Deployment boundary + +Use a dedicated private printing bucket for each customer/environment, separate from general uploads, backups and unrelated QBits. Configure it through the host's existing QQQ S3 backend and a printing storage table. Bind that table with `PrintingQBitConfig.withStorageTableName(...)` and the existing scheduler with `withSchedulerName(...)`. Printing uses QQQ `StorageAction` for writing, reading and deleting objects. The Windows agent receives authenticated document bytes through QQQ and never receives S3 credentials or a bucket-wide URL. + +Keep access keys, role credentials and endpoint configuration in the host's established secret/configuration mechanism. Prefer a deployment role restricted to this printing bucket. Permit only the object read/write/delete operations the storage adapter needs, scoped to its printing prefix; grant bucket-level listing only if the configured adapter requires it, with an appropriate prefix condition. Do not grant bucket deletion, ACL/policy administration, or arbitrary access to other buckets. AWS distinguishes bucket and object resource permissions in its [bucket policy examples](https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies.html). + +The code validates the selected S3 metadata, detects conflicting bucket bindings within the QQQ instance and uses tenant-specific object namespaces. Each job retains its storage table, destination identity and exact document reference. Retrieval and cleanup validate those values and fail closed if metadata is retargeted or the reference belongs to another tenant/job. Cleanup deletes exact eligible objects; it never lists and wipes a prefix. Tenant prefixes are an application isolation control, not a substitute for separately scoped IAM roles or separate customer buckets. + +Application metadata cannot prove that a bucket is unused by another application or that deployed IAM/bucket policies are correct. Deployment qualification must check those external controls, public-access blocking, transport/encryption policy and the intended customer/environment boundary. No live bucket has been created or changed by the audit. + +## Retained originals and delivery copies + +Record Files owns original versions and attachments; document generation owns rendering. Printing owns a separate delivery payload and queue receipts. Never bind printing cleanup to a Record Files bucket or pass an original's storage key as a printing-owned reference. Printing expiration must not remove a source attachment or weaken its retention policy. + +The current independent integration seam accepts bytes and a stable business idempotency key. An authorized immutable-version reader and source/job correlation contract are being coordinated with Record Files; that API is not yet implemented or assumed here. Printing currently checks device/destination authorization at dispatch, not later revocation of the source attachment's permissions. A shared adapter must define that boundary before promising revocation propagation. + +The existing `PrintEnqueueEffectTarget` accepts base64 document bytes in the host's durable outbox. That outbox may retain another delivery copy outside this module's S3 cleanup policy. Hosts must manage its retention separately; do not claim that configuring printing S3 moves existing outbox payloads or all upstream copies into the printing bucket. The future retained-version adapter should carry authorized opaque source IDs/hash/length/media type and actor/correlation, not bucket URLs or duplicated document bytes. Direct byte submission stores the printing-owned payload through the configured storage backend without adding this host-outbox copy. + +## Administration and scheduled cleanup + +Printing administration exposes retention settings and a reviewed **Run cleanup** action. The default is **365 days after successful completion**. The storage destination is read-only in these forms: bucket changes require a reviewed deployment/storage migration, not typing a new backend ID into a cleanup form. The screen reports the effective storage scope, schedule, eligible backlog and last cleanup result. + +When the host supplies a scheduler, maintenance runs every five minutes in bounded batches of at most 200. That permits a nominal 57,600 eligible documents per day before storage latency or failures; it is not measured production throughput. Changing a retention setting does not itself purge documents. Disabling cleanup preserves content. Without a configured scheduler there is no automatic job; the administration screen must show that condition. + +Only eligible successful terminal jobs lose their document content. Active, failed or uncertain work remains protected. Job metadata, document hashes, receipts and idempotency records remain available for auditing and duplicate prevention. Legacy completion times are recovered only from durable successful events; unknown completion dates remain protected. Existing inline database documents remain readable; the initial storage integration does not silently migrate old bytes to S3. Reprinting a job whose document has been removed returns an explicit unavailable-document result. + +## Failure and versioning behavior + +External object storage and SQL cannot share one transaction. Cleanup must retain retryable state on failed deletion, and submission must account for an uploaded object whose database write fails. A process crash between object and database operations remains a recovery boundary; do not delete unknown objects merely because a prefix looks old. Review the recorded cleanup outcome/backlog and storage errors before intervening. + +Submission persists an exact upload intent before uploading and removes it atomically with the queued job. Failed compensation leaves that intent for maintenance; abandoned intents become eligible after one hour. Maintenance uses a renewable one-hour lease and exact row locks, reusing one SQL connection rather than acquiring a second connection while holding the first. Interrupted multipart uploads may leave unfinished parts rather than an object: configure the dedicated bucket's `AbortIncompleteMultipartUpload` lifecycle separately. Deleting an object does not remove unfinished multipart parts, and this lifecycle must not expire current objects by age. + +With S3 versioning, a normal delete can create a delete marker while retaining noncurrent bytes. Application cleanup therefore means the current document is no longer available through printing; it does not promise permanent erasure of all versions. Set an explicit noncurrent-version retention policy consistent with the customer's requirements and any Object Lock policy. See AWS's [expiration semantics](https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-expire-general-considerations.html) and [version lifecycle examples](https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-configuration-examples.html). + +Do not attach a blanket 365-day current-object expiration rule to the printing prefix: S3 cannot see job state and could delete an active or uncertain job that the application deliberately protects. The application controls eligibility; any noncurrent-version cleanup is a separate deployment policy. diff --git a/docs/TODO.md b/docs/TODO.md index 2547228..64b3cef 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,10 @@ # Follow-up +- [x] Finish audit regressions for native-process replay, strict options/inventory validation, retained duplicate risk and event field bounds; full gate passes 53 tests. +- [x] Implement admin-configurable document retention (one-year default), bounded scheduled cleanup and existing QQQ object-storage integration, retaining history/receipts and protecting active or uncertain output. +- [ ] Integrate Record Files' authorized immutable-version reader after its contract is implemented; define async source revocation/correlation and eliminate host-outbox byte copies for that path. +- [ ] Verify audit release gates and provenance; complete independent security scan and deployed-host qualification. + - [x] Versioned shared contract and HTTP happy-path test. - [x] Durable enrollment, device-scoped authentication, inventory and printer approval. - [x] PDF/ZPL queue, idempotency, content integrity and status events. diff --git a/pom.xml b/pom.xml index a9cf1e3..6d947f4 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.kingsrookqbit-build-parent2.0.0 com.kingsrook.qbits qbit-printing - 0.1.3-rc.1 + 0.1.4-rc.1 QQQ Printing Apache License, Version 2.0https://www.apache.org/licenses/LICENSE-2.0 @@ -22,6 +22,7 @@ com.kingsrook.qqqqqq-backend-core${qqq.version} com.kingsrook.qqqqqq-backend-module-rdbms${qqq.version} + com.kingsrook.qqqqqq-backend-module-filesystem${qqq.version} com.kingsrook.qqqqqq-middleware-javalin${qqq.version} com.h2databaseh22.3.232runtime org.junit.jupiterjunit-jupiter6.0.1test @@ -37,6 +38,7 @@ org.apache.maven.pluginsmaven-compiler-plugin3.14.1 org.apache.maven.pluginsmaven-surefire-plugin3.5.4 + org.apache.maven.pluginsmaven-jar-plugin3.4.2${project.version} org.codehaus.mojoexec-maven-plugin3.6.3com.kingsrook.qbits.printing.PrintingDemo diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java new file mode 100644 index 0000000..c2647d8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java @@ -0,0 +1,287 @@ +/* + * 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.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.SQLException; +import java.util.HexFormat; +import java.util.concurrent.Callable; +import com.kingsrook.qqq.backend.core.actions.tables.StorageAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails; + + +/******************************************************************************* + ** Reuse the host's QQQ storage table and backend credentials, including S3. + ** Only service-generated object references enter this adapter after tenant auth. + *******************************************************************************/ +public class PrintingDocumentStorage +{ + private final QInstance instance; + private final String prefix; + private final boolean allowMemoryTest; + + + + /*************************************************************************** + ** Bind storage to one host instance, including historical table bindings. + ***************************************************************************/ + public PrintingDocumentStorage(QInstance instance, String tenantId) + { + this(instance, tenantId, false); + } + + + + /*************************************************************************** + ** Package-scoped memory fixture seam; no runtime environment bypass exists. + ***************************************************************************/ + PrintingDocumentStorage(QInstance instance, String tenantId, boolean allowMemoryTest) + { + this.instance = java.util.Objects.requireNonNull(instance); + this.prefix = "printing/" + digest(tenantId) + "/"; + this.allowMemoryTest = allowMemoryTest; + } + + + + /*************************************************************************** + ** Store immutable document bytes through the existing configured backend. + ***************************************************************************/ + public void put(String table, String reference, String format, byte[] content) throws SQLException + { + execute(() -> + { + try(OutputStream stream = new StorageAction().createOutputStream(input(table, reference).withContentType("PDF".equals(format) ? "application/pdf" : "application/vnd.zebra-zpl"))) + { + stream.write(content); + } + return null; + }); + } + + + + /*************************************************************************** + ** Bound reads even if stored content was replaced outside this service. + ***************************************************************************/ + public byte[] get(String table, String reference) throws SQLException + { + return (execute(() -> + { + try(InputStream stream = new StorageAction().getInputStream(input(table, reference))) + { + byte[] bytes = stream.readNBytes(20 * 1024 * 1024 + 1); + if(bytes.length > 20 * 1024 * 1024) + { + throw new PrintingException(502, "Stored print document exceeds the allowed size"); + } + return bytes; + } + })); + } + + + + /*************************************************************************** + ** Backend deletion is idempotent; callers mark receipts only after success. + ***************************************************************************/ + public void delete(String table, String reference) throws SQLException + { + execute(() -> + { + new StorageAction().deleteObject(input(table, reference)); + return null; + }); + } + + + + /*************************************************************************** + ** Read-only host metadata lets administrators see the resolved destination. + ***************************************************************************/ + public String backendName(String table) + { + if(instance.getTable(table) == null) + { + throw new IllegalArgumentException("Configured printing storage table does not exist"); + } + return (instance.getTable(table).getBackendName()); + } + + + + /*************************************************************************** + ** Validate dedicated bucket use visible in this host's metadata. External + ** bucket policies and unrelated applications still require deployment review. + ***************************************************************************/ + public String binding(String table) + { + var metadata = instance.getTable(table); + var backend = instance.getBackend(backendName(table)); + if(allowMemoryTest && "memory".equals(backend.getBackendType())) + { + return (digest(table + ":" + backend.getName())); + } + if(!table.startsWith("printing_") || !(backend instanceof S3BackendMetaData s3) || !(metadata.getBackendDetails() instanceof S3TableBackendDetails details) + || s3.getBucketName() == null || s3.getBucketName().isBlank()) + { + throw new IllegalArgumentException("Printing requires a dedicated printing_* S3 storage table and configured bucket"); + } + String identity = bucketIdentity(s3); + for(var other : instance.getTables().values()) + { + if(!other.getName().startsWith("printing_") && instance.getBackend(other.getBackendName()) instanceof S3BackendMetaData otherBackend + && identity.equals(bucketIdentity(otherBackend))) + { + throw new IllegalArgumentException("Printing bucket is shared with a non-printing table; configure a dedicated bucket"); + } + } + return (digest(identity + "\n" + java.util.Objects.toString(s3.getRegion(), "") + "\n" + java.util.Objects.toString(s3.getPathStyleAccess(), "false") + + "\n" + java.util.Objects.toString(s3.getBasePath(), "") + "\n" + java.util.Objects.toString(details.getBasePath(), ""))); + } + + + + /*************************************************************************** + ** Display only the bucket name, never access keys or endpoint query data. + ***************************************************************************/ + public String bucketName(String table) + { + var backend = instance.getBackend(backendName(table)); + return (backend instanceof S3BackendMetaData s3 ? s3.getBucketName() : "In-memory test storage"); + } + + + + /*************************************************************************** + ** A tenant-specific owned namespace is shared by validation and generation. + ***************************************************************************/ + public String prefix() + { + return (prefix); + } + + + + /*************************************************************************** + ** Keep historical bindings immutable even when a host retargets metadata. + ***************************************************************************/ + public void verifyBinding(String table, String expected) + { + if(!binding(table).equals(expected)) + { + throw new PrintingException(409, "Stored document destination no longer matches the host configuration"); + } + } + + + + /*************************************************************************** + ** Include provider identity; identical bucket names at distinct endpoints + ** are not assumed to share storage. Fingerprints never contain credentials. + ***************************************************************************/ + private static String bucketIdentity(S3BackendMetaData backend) + { + String endpoint = java.util.Objects.toString(backend.getEndpoint(), "AWS"); + if(!"AWS".equals(endpoint)) + { + java.net.URI uri = java.net.URI.create(endpoint).normalize(); + if(uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null || uri.getHost() == null || !("https".equalsIgnoreCase(uri.getScheme()) + || ("http".equalsIgnoreCase(uri.getScheme()) && java.util.Set.of("localhost", "127.0.0.1", "[::1]").contains(uri.getHost().toLowerCase(java.util.Locale.ROOT))))) + { + throw new IllegalArgumentException("Printing storage endpoint requires HTTPS (except loopback development) and no credentials, query or fragment"); + } + endpoint = uri.getScheme().toLowerCase(java.util.Locale.ROOT) + "://" + uri.getHost().toLowerCase(java.util.Locale.ROOT) + + (uri.getPort() < 0 || ("https".equalsIgnoreCase(uri.getScheme()) && uri.getPort() == 443) || ("http".equalsIgnoreCase(uri.getScheme()) && uri.getPort() == 80) ? "" : ":" + uri.getPort()) + java.util.Objects.toString(uri.getPath(), "").replaceAll("/+$", ""); + } + return (endpoint + "\n" + backend.getBucketName()); + } + + + + /*************************************************************************** + ** Hash namespace/binding values instead of embedding host identifiers. + ***************************************************************************/ + private static String digest(String value) + { + try + { + return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)))); + } + catch(java.security.NoSuchAlgorithmException exception) + { + throw new IllegalStateException(exception); + } + } + + + + /*************************************************************************** + ** No caller-selected paths, bucket URLs or filesystem traversal are accepted. + ***************************************************************************/ + private StorageInput input(String table, String reference) + { + binding(table); + if(reference == null || !reference.startsWith(prefix) || !reference.matches("printing/[a-f0-9]{64}/[a-f0-9-]{36}\\.(pdf|zpl)")) + { + throw new IllegalArgumentException("Invalid owned printing storage reference"); + } + return (new StorageInput(table).withReference(reference)); + } + + + + /*************************************************************************** + ** Device HTTP threads have no QQQ session. Use a narrow instance-bound + ** system context only for storage, and always restore the caller context. + ***************************************************************************/ + private T execute(Callable operation) throws SQLException + { + var original = QContext.capture(); + try + { + QContext.init(instance, new QSystemUserSession(instance)); + return (operation.call()); + } + catch(PrintingException exception) + { + throw exception; + } + catch(Exception exception) + { + throw new SQLException("Printing document storage operation failed", exception); + } + finally + { + QContext.init(original); + } + } +} + diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingFacilities.java b/src/main/java/com/kingsrook/qbits/printing/PrintingFacilities.java new file mode 100644 index 0000000..cc21de1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingFacilities.java @@ -0,0 +1,106 @@ +/* + * 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.util.List; +import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; + + +/******************************************************************************* + ** Native facility choices share the host's current session and record security. + *******************************************************************************/ +final class PrintingFacilities +{ + static final String DEFAULT_SOURCE = "printing_facility"; + + + + /*************************************************************************** + ** Prevent instantiation of this metadata and validation helper. + ***************************************************************************/ + private PrintingFacilities() + { + } + + + + /*************************************************************************** + ** A configured source must exist; standalone hosts receive one named choice. + ***************************************************************************/ + static String register(QInstance instance, PrintingQBitConfig config) throws QException + { + String source = config.getFacilityPossibleValueSourceName(); + if(source == null || source.isBlank()) + { + instance.addPossibleValueSource(new QPossibleValueSource().withName(DEFAULT_SOURCE).withType(QPossibleValueSourceType.ENUM) + .withEnumValues(List.of(new QPossibleValue<>("default", "Default facility")))); + return (DEFAULT_SOURCE); + } + if(instance.getPossibleValueSource(source) == null) + { + throw new QException("The configured printing facility possible-value source does not exist"); + } + return (source); + } + + + + /*************************************************************************** + ** Reuse the same source for enrollment and read-only device display. + ***************************************************************************/ + static QFieldMetaData field(QInstance instance) + { + String source = instance.getTable(PrintingTables.DEVICE).getField("facility_id").getPossibleValueSourceName(); + return (new QFieldMetaData("facilityId", QFieldType.STRING).withLabel("Facility").withIsRequired(true).withMaxLength(200) + .withPossibleValueSourceName(source).withDefaultValue(DEFAULT_SOURCE.equals(source) ? "default" : null)); + } + + + + /*************************************************************************** + ** Re-query the selected ID under the caller's session, never a system session. + ***************************************************************************/ + static String validate(String selected) throws QException + { + if(selected == null || selected.isBlank() || selected.length() > 200) + { + throw new QException("Select an available facility"); + } + String source = QContext.getQInstance().getTable(PrintingTables.DEVICE).getField("facility_id").getPossibleValueSourceName(); + var options = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(source).withIdList(List.of(selected))); + if(options.getResults() == null || options.getResults().stream().noneMatch(option -> selected.equals(String.valueOf(option.getId())))) + { + throw new QException("The selected facility is unavailable or is not accessible to this user"); + } + return (selected); + } +} \ No newline at end of file diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java b/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java index 1dc7f3d..ca4ac0e 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java @@ -149,6 +149,8 @@ public class PrintingHome extends AbstractWidgetRenderer CompositeWidgetData data = column().withBlock(text("Keep packing slips and shipping labels moving by resolving disconnected devices, printer issues and print failures.")) .withBlock(text("Scope: current tenant. Devices/printers: now. Job exceptions/recent work: last 24 hours.")) .withBlock(text("Enroll a print device").withLink(new BlockLink("/app/printingEnrollDevice/?returnTo=%2Fapp%2Fprinting%2F"))) + .withBlock(text("Printing retention settings").withLink(new BlockLink("/app/printingRetentionSettings/?returnTo=%2Fapp%2Fprinting%2F"))) + .withBlock(text("Run retention cleanup").withLink(new BlockLink("/app/printingRunRetention/?returnTo=%2Fapp%2Fprinting%2F"))) .withBlock(text("Printing setup and help").withLink(new BlockLink("https://github.com/QRun-IO/qbit-printing/blob/main/docs/OPERATIONS.md"))); if(count(DEVICE, new QQueryFilter()) == 0) { diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java index 8a77e30..2ee8db0 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java @@ -132,7 +132,8 @@ public class PrintingJobProcesses throw new QException("Choose an accessible print job"); } String state = job.getValueString("status"); - if(CANCEL.equals(input.getProcessName()) ? !"QUEUED".equals(state) : !Set.of("FAILED", "OUTCOME_UNKNOWN", "SPOOLED", "SPOOLER_COMPLETED").contains(state)) + boolean alreadyCanceled = "FAILED".equals(state) && PrintingService.CANCELED_BEFORE_DISPATCH.equals(job.getValueString("message")); + if(CANCEL.equals(input.getProcessName()) ? (!"QUEUED".equals(state) && !alreadyCanceled) : !Set.of("FAILED", "OUTCOME_UNKNOWN", "SPOOLED", "SPOOLER_COMPLETED").contains(state)) { throw new QException("Job " + job.getValueString("title") + " is " + state + " and cannot be processed by this action"); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java index 5ce044a..00be85d 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java @@ -70,6 +70,10 @@ import org.json.JSONObject; *******************************************************************************/ public class PrintingProcesses implements BackendStep { + private static final String TEST_REQUEST_ID = "printingTestRequestId"; + + + /*************************************************************************** ** Register the setup and test actions in the Printing app. ***************************************************************************/ @@ -77,7 +81,7 @@ public class PrintingProcesses implements BackendStep { PrintingJobProcesses.register(instance); registerProfiles(instance); - add(instance, "printingEnrollDevice", "Enroll a print device", List.of(field("facilityId", "Facility"), field("deviceName", "Device name"))); + add(instance, "printingEnrollDevice", "Enroll a print device", List.of(PrintingFacilities.field(instance), field("deviceName", "Device name"))); add(instance, "printingEnablePrinter", "Set printer approval", List.of(deviceField(), printerField(null), new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Approved for printing").withIsRequired(true))); add(instance, "printingTestZpl", "Print a test label", List.of(deviceField(), printerField("ZPL"), profileField(false))); add(instance, "printingTestPdf", "Print a test page", List.of(deviceField(), printerField("PDF"), profileField(true))); @@ -174,6 +178,11 @@ public class PrintingProcesses implements BackendStep if("printingEnrollDevice".equals(name)) { process.withTableName(PrintingTables.DEVICE).withMinInputRecords(0); + process.withStep(0, new QBackendStepMetaData().withName("prepareEnrollment").withCode(new QCodeReference(PrepareEnrollment.class))); + } + if(name.startsWith("printingTest")) + { + process.withStep(0, new QBackendStepMetaData().withName("prepareTest").withCode(new QCodeReference(PrepareTest.class))); } instance.addProcess(process); } @@ -197,6 +206,7 @@ public class PrintingProcesses implements BackendStep { process.withStep(0, new QFrontendStepMetaData().withName("testProfile").withLabel("Choose matching stock and resolution") .withFormFields(List.of(profileField(name.endsWith("Pdf")))).withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))); + process.withStep(0, new QBackendStepMetaData().withName("prepareTest").withCode(new QCodeReference(PrepareTest.class))); } instance.addProcess(process); } @@ -250,7 +260,8 @@ public class PrintingProcesses implements BackendStep { case "printingEnrollDevice" -> { - JSONObject enrollment = service.createEnrollment(input.getValueString("facilityId"), input.getValueString("deviceName")); + JSONObject enrollment = service.createEnrollment(PrintingFacilities.validate(input.getValueString("facilityId")), input.getValueString("deviceName"), + input.getValueString("printingEnrollmentCode"), input.getValueString("printingEnrollmentExpires")); output.addValue("result", "Enrollment code: " + enrollment.getString("enrollmentCode") + " (expires " + enrollment.getString("expiresAt") + ")"); } case "printingEnablePrinter" -> @@ -262,7 +273,7 @@ public class PrintingProcesses implements BackendStep case "printingTestZpl", "printingTestPdf" -> { QRecord printer = resolvePrinter(input.getValueString("printerId"), input.getValueString("deviceId")); - String job = submitTest(printer, "printingTestPdf".equals(input.getProcessName()), input.getValueString("testProfile")); + String job = submitTest(printer, "printingTestPdf".equals(input.getProcessName()), input.getValueString("testProfile"), testRequestKey(input, printer)); output.addValue("result", "Queued print job " + job + ". Check Print jobs for submission status."); } default -> throw new QException("Unknown printing action"); @@ -308,13 +319,38 @@ public class PrintingProcesses implements BackendStep ** All built-in document entrypoints share profile validation and generation. ***************************************************************************/ static String submitTest(QRecord printer, boolean pdf, String profileName) throws QException + { + return (submitTest(printer, pdf, profileName, "test:" + UUID.randomUUID())); + } + + + + /*************************************************************************** + ** The native process persists its request identity before operator review. + ***************************************************************************/ + private static String testRequestKey(RunBackendStepInput input, QRecord printer) throws QException + { + String request = input.getValueString(TEST_REQUEST_ID); + if(request == null || request.isBlank()) + { + throw new QException("Restart the test-print action to obtain a safe request identity"); + } + return ("native-test:" + request + ":" + printer.getValueString("id")); + } + + + + /*************************************************************************** + ** Generate one immutable document and deduplicate replays of a process load. + ***************************************************************************/ + private static String submitTest(QRecord printer, boolean pdf, String profileName, String key) throws QException { try { TestPrintProfile profile = TestPrintProfile.resolve(pdf ? "PDF" : "ZPL", profileName); byte[] bytes = pdf ? PrintingTestDocuments.pdf(profile) : PrintingTestDocuments.zpl(profile); JSONObject job = currentService().submit(new JSONObject().put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id")) - .put("title", "QQQ print test").put("format", pdf ? "PDF" : "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", "test:" + UUID.randomUUID())); + .put("title", "QQQ print test").put("format", pdf ? "PDF" : "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key)); return (job.getString("jobId")); } catch(java.sql.SQLException | PrintingException exception) @@ -325,6 +361,56 @@ public class PrintingProcesses implements BackendStep + /*************************************************************************** + ** Prepare once before forms/review; a replay reuses the persisted identity. + ***************************************************************************/ + public static class PrepareTest implements BackendStep + { + /************************************************************************ + ** Direct invocation also requires administrator and tenant access. + ************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + currentService(); + String request = input.getValueString(TEST_REQUEST_ID); + output.addValue(TEST_REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request); + } + } + + + + /*************************************************************************** + ** Prepare one short-lived credential in the authorized process state. The + ** database stores its hash and consumed receipt, never its plaintext value. + ***************************************************************************/ + public static class PrepareEnrollment implements BackendStep + { + /************************************************************************ + ** Keep credential and expiry stable across form and backend replay. + ************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + currentService(); + String code = input.getValueString("printingEnrollmentCode"); + String expires = input.getValueString("printingEnrollmentExpires"); + if(code == null && expires == null) + { + byte[] random = new byte[32]; + new java.security.SecureRandom().nextBytes(random); + code = Base64.getUrlEncoder().withoutPadding().encodeToString(random); + expires = java.time.Instant.now().plusSeconds(900).toString(); + } + output.addValue("printingEnrollmentCode", code); + output.addValue("printingEnrollmentExpires", expires); + } + } + + + /*************************************************************************** ** Preview selected queues without printing or changing their approval. ***************************************************************************/ @@ -405,8 +491,8 @@ public class PrintingProcesses implements BackendStep { switch(input.getProcessName()) { - case "printingTestSelectedPdf" -> submitTest(printer, true, input.getValueString("testProfile")); - case "printingTestSelectedZpl" -> submitTest(printer, false, input.getValueString("testProfile")); + case "printingTestSelectedPdf" -> submitTest(printer, true, input.getValueString("testProfile"), testRequestKey(input, printer)); + case "printingTestSelectedZpl" -> submitTest(printer, false, input.getValueString("testProfile"), testRequestKey(input, printer)); case "printingEnableSelectedPrinters", "printingDisableSelectedPrinters" -> currentService().enablePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id"), "printingEnableSelectedPrinters".equals(input.getProcessName())); default -> throw new QException("Unknown selected printing action"); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java index 652847b..207565c 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java @@ -31,6 +31,94 @@ public class PrintingQBitConfig implements QBitConfig { private String backendName; private transient PrintingService service; + private PrintingRetentionPolicy retentionPolicy = new PrintingRetentionPolicy(true, 365, 200); + private String storageTableName; + private String schedulerName; + private String facilityPossibleValueSourceName; + + + + /*************************************************************************** + ** Reuse the host's facility selector and its existing record security. + ***************************************************************************/ + public PrintingQBitConfig withFacilityPossibleValueSourceName(String value) + { + facilityPossibleValueSourceName = value; + return (this); + } + + + + /*************************************************************************** + ** Missing configuration uses the named standalone default facility. + ***************************************************************************/ + public String getFacilityPossibleValueSourceName() + { + return (facilityPossibleValueSourceName); + } + + + + /*************************************************************************** + ** Deployment-owned dedicated S3 table; operators cannot retarget old files. + ***************************************************************************/ + public PrintingQBitConfig withStorageTableName(String value) + { + storageTableName = value; + return (this); + } + + + + /*************************************************************************** + ** Existing storage metadata owns bucket, endpoint and credentials. + ***************************************************************************/ + public String getStorageTableName() + { + return (storageTableName); + } + + + + /*************************************************************************** + ** Bind bounded five-minute maintenance to a scheduler already configured by the host. + ***************************************************************************/ + public PrintingQBitConfig withSchedulerName(String value) + { + schedulerName = value; + return (this); + } + + + + /*************************************************************************** + ** Missing host scheduler means no automatic background maintenance. + ***************************************************************************/ + public String getSchedulerName() + { + return (schedulerName); + } + + + + /*************************************************************************** + ** Host defaults are one year; setting policy does not itself run a purge. + ***************************************************************************/ + public PrintingQBitConfig withRetentionPolicy(PrintingRetentionPolicy value) + { + retentionPolicy = java.util.Objects.requireNonNull(value); + return (this); + } + + + + /*************************************************************************** + ** The host authorizes and schedules bounded service maintenance explicitly. + ***************************************************************************/ + public PrintingRetentionPolicy getRetentionPolicy() + { + return (retentionPolicy); + } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java index e42fc1e..3b9d35e 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java @@ -79,10 +79,13 @@ public class PrintingQBitProducer implements QBitProducer { throw new QException("The initial printing QBit supports the printing namespace only"); } - instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion("0.1.0-rc.1").withNamespace("printing").withConfig(config)); + config.getService().withRetentionPolicy(config.getRetentionPolicy()); + config.getService().configureStorage(instance, config.getStorageTableName()); + instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion(version()).withNamespace("printing").withConfig(config)); instance.addSecurityKeyType(new QSecurityKeyType().withName("printing_tenant")); QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at")); devices.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Enabled").withPossibleValueSourceName("printing_enabled")); + devices.getField("facility_id").withLabel("Facility").withPossibleValueSourceName(PrintingFacilities.register(instance, config)); QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at")); printers.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Approved for printing").withPossibleValueSourceName("printing_approved")); printers.withField(new QFieldMetaData("available", QFieldType.INTEGER).withLabel("Exposed by device").withPossibleValueSourceName("printing_available")); @@ -125,6 +128,7 @@ public class PrintingQBitProducer implements QBitProducer QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs); app.withPermissionRules(PrintingProcesses.permissionRules()); PrintingProcesses.register(instance, app); + PrintingRetentionProcesses.register(instance, config); PrintingHome.register(instance, app); instance.addApp(app); QAppMetaData parent = instance.getApp("admin"); @@ -136,6 +140,17 @@ public class PrintingQBitProducer implements QBitProducer + /*************************************************************************** + ** Read the build-produced JAR manifest; IDE/test class directories identify themselves honestly. + ***************************************************************************/ + private static String version() + { + String version = PrintingQBitProducer.class.getPackage().getImplementationVersion(); + return (version == null || version.isBlank() ? "development" : version); + } + + + /*************************************************************************** ** Produce read-only table metadata protected by tenant and administrator keys. ***************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionPolicy.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionPolicy.java new file mode 100644 index 0000000..0750e50 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionPolicy.java @@ -0,0 +1,50 @@ +/* + * 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; + + +/******************************************************************************* + ** Bounded maintenance policy; the host separately binds a native scheduler. + *******************************************************************************/ +public record PrintingRetentionPolicy(boolean enabled, int days, int batchSize) +{ + /*************************************************************************** + ** Bound age and work per maintenance invocation before touching storage. + ***************************************************************************/ + public PrintingRetentionPolicy + { + if(days < 1 || days > 36500 || batchSize < 1 || batchSize > 200) + { + throw new IllegalArgumentException("Retention requires 1..36500 days and a batch of 1..200 jobs"); + } + } + + + + /*************************************************************************** + ** Explicitly disable removal while retaining the standard age and batch bounds. + ***************************************************************************/ + public static PrintingRetentionPolicy disabled() + { + return (new PrintingRetentionPolicy(false, 365, 200)); + } +} + diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionProcesses.java new file mode 100644 index 0000000..4f0cc08 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionProcesses.java @@ -0,0 +1,231 @@ +/* + * 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.util.List; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import org.json.JSONObject; + + +/******************************************************************************* + ** Tenant-authorized retention settings and bounded document cleanup using QQQ. + *******************************************************************************/ +public class PrintingRetentionProcesses implements BackendStep +{ + public static final String SETTINGS = "printingRetentionSettings"; + public static final String RUN_NOW = "printingRunRetention"; + public static final String SCHEDULED = "printingScheduledRetention"; + + + + /*************************************************************************** + ** Register native settings and confirmation forms; schedule only on a host scheduler. + ***************************************************************************/ + public static void register(QInstance instance, PrintingQBitConfig config) + { + QProcessMetaData settings = manual(SETTINGS, "Printing retention settings") + .withStep(review("Current retention and storage")) + .withStep(new QFrontendStepMetaData().withName("settings").withLabel("Edit retention") + .withFormFields(List.of(new QFieldMetaData("days", QFieldType.INTEGER).withLabel("Keep successful job documents for days (1–36500)").withIsRequired(true), + new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Enable document retention cleanup").withIsRequired(true))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))) + .withStep(backend("execute")).withStep(result()); + instance.addProcess(settings); + instance.addProcess(manual(RUN_NOW, "Run printing retention cleanup") + .withStep(review("Review cleanup before continuing")).withStep(backend("execute")).withStep(result())); + if(config.getSchedulerName() != null && !config.getSchedulerName().isBlank()) + { + instance.addProcess(new QProcessMetaData().withName(SCHEDULED).withLabel("Scheduled printing retention cleanup").withIsHidden(true) + .withPermissionRules(PrintingProcesses.permissionRules()).withStep(backend("execute")) + .withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()) + .withCronExpression("0 */5 * * * ?").withCronTimeZoneId("UTC") + .withDescription("Remove one bounded batch of eligible successful print documents using the tenant retention policy."))); + } + } + + + + /*************************************************************************** + ** Prepare each form from live tenant settings rather than stale metadata defaults. + ***************************************************************************/ + private static QProcessMetaData manual(String name, String label) + { + return (new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules()).withStep(backend("prepare"))); + } + + + + /*************************************************************************** + ** All step entrypoints repeat permission checks, including direct backend invocation. + ***************************************************************************/ + private static QBackendStepMetaData backend(String name) + { + return (new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRetentionProcesses.class))); + } + + + + /*************************************************************************** + ** Read-only deployment details intentionally expose no editable backend IDs or credentials. + ***************************************************************************/ + private static QFrontendStepMetaData review(String label) + { + return (new QFrontendStepMetaData().withName("review").withLabel(label) + .withViewFields(List.of(text("policy", "Retention policy"), text("storage", "Document storage"), text("schedule", "Automatic schedule"), text("progress", "Cleanup progress"), text("safety", "What cleanup does"))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))); + } + + + + /*************************************************************************** + ** Keep native result rendering consistent with other printing processes. + ***************************************************************************/ + private static QFrontendStepMetaData result() + { + return (new QFrontendStepMetaData().withName("result").withLabel("Result").withViewFields(List.of(text("result", "Result"))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))); + } + + + + /*************************************************************************** + ** Plain text view fields cannot act as arbitrary storage selectors. + ***************************************************************************/ + private static QFieldMetaData text(String name, String label) + { + return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label)); + } + + + + /*************************************************************************** + ** Scheduled system execution stays bound to this QQQ instance's configured tenant. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + boolean scheduled = SCHEDULED.equals(input.getProcessName()); + PrintingService service; + if(scheduled) + { + if(!(QContext.getQSession() instanceof QSystemUserSession)) + { + throw new QPermissionDeniedException("The scheduled retention entrypoint requires the system session; use Run retention cleanup instead"); + } + service = configuration().getService(); + } + else + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + service = PrintingProcesses.currentService(); + } + try + { + if("prepare".equals(input.getStepName()) && (SETTINGS.equals(input.getProcessName()) || RUN_NOW.equals(input.getProcessName()))) + { + prepare(service, output); + } + else if("execute".equals(input.getStepName()) && SETTINGS.equals(input.getProcessName())) + { + Integer days = input.getValueInteger("days"); + Boolean enabled = input.getValueBoolean("enabled"); + if(days == null || enabled == null) + { + throw new QException("Specify retention days and whether cleanup is enabled"); + } + service.saveRetentionSettings(days, enabled); + output.addValue("result", "Printing retention settings saved for this tenant. Changing the policy does not run cleanup immediately."); + } + else if("execute".equals(input.getStepName()) && (RUN_NOW.equals(input.getProcessName()) || scheduled)) + { + int removed = service.runRetention(); + output.addValue("result", "Removed " + removed + " eligible documents or abandoned uploads. Job receipts and uncertain or active work were preserved. Cleanup processes one bounded batch per run."); + } + else + { + throw new QException("Unknown printing retention action"); + } + } + catch(java.sql.SQLException | PrintingException | IllegalArgumentException exception) + { + throw new QException("Printing retention action failed: " + exception.getMessage(), exception); + } + } + + + + /*************************************************************************** + ** Show effective policy and resolved storage scope without exposing secrets. + ***************************************************************************/ + private static void prepare(PrintingService service, RunBackendStepOutput output) throws java.sql.SQLException, QException + { + JSONObject settings = service.getRetentionSettings(); + output.addValue("days", settings.getInt("days")); + output.addValue("enabled", settings.getBoolean("enabled")); + output.addValue("policy", (settings.getBoolean("enabled") ? "Enabled" : "Disabled") + ": retain successful job documents for " + settings.getInt("days") + " days; at most " + settings.getInt("batchSize") + " documents per run."); + String table = settings.optString("storageTableName", ""); + String label = QContext.getQInstance().getTable(table) == null ? table : QContext.getQInstance().getTable(table).getLabel(); + output.addValue("storage", table.isBlank() ? "Dedicated printing storage is not configured. Ask the application administrator to bind an existing QQQ storage table and dedicated printing bucket." + : label + "; bucket: " + settings.optString("bucketName", "Configured by the host") + "; tenant prefix: " + settings.optString("storagePrefix", "Scoped by the service")); + String scheduler = configuration().getSchedulerName(); + output.addValue("schedule", scheduler == null || scheduler.isBlank() ? "Not scheduled. Configure a host QQQ scheduler to enable automatic cleanup; Run cleanup remains available." : "Every five minutes (UTC) using the host QQQ scheduler; overlapping runs are prevented."); + String lastRun = settings.optString("lastRunAt", ""); + output.addValue("progress", "Eligible documents waiting: " + settings.optLong("eligibleBacklog", 0) + + "; last run: " + (lastRun.isBlank() ? "Not run yet" : lastRun) + "; removed: " + settings.optInt("lastRunCount", 0) + + (settings.optString("lastRunError", "").isBlank() ? "" : "; last run problem: " + settings.optString("lastRunError"))); + output.addValue("safety", "Cleanup removes only old successful-terminal document content. Active, failed and uncertain jobs, receipt metadata and deduplication records remain. Removed documents cannot be reprinted from history. Continue to edit the policy or run the selected cleanup action."); + } + + + + /*************************************************************************** + ** Avoid static service caches and prevent a scheduler from crossing instance boundaries. + ***************************************************************************/ + private static PrintingQBitConfig configuration() throws QException + { + for(QBitMetaData metadata : QContext.getQInstance().getQBits().values()) + { + if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() != null) + { + return (config); + } + } + throw new QException("Printing QBit is not configured"); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index c8c05fc..1fbcdf2 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -46,10 +46,57 @@ import org.json.JSONObject; *******************************************************************************/ public class PrintingService { + static final String CANCELED_BEFORE_DISPATCH = "Canceled by operator before dispatch"; private static final int MAX_CONTENT_BYTES = 20 * 1024 * 1024; private static final String JOB_COLUMNS = "id, tenant_id, device_id, printer_id, title, format, content_hash, content_length, options_json, idempotency_key, created_at, status, message, spooler_job_id, printer_row_id"; private final DataSource dataSource; private final String tenantId; + private PrintingDocumentStorage documentStorage; + private String storageTableName; + private PrintingRetentionPolicy defaultRetentionPolicy = new PrintingRetentionPolicy(true, 365, 200); + + + + /*************************************************************************** + ** Bind a host-owned storage table before exposing this service to requests. + ***************************************************************************/ + public void configureStorage(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance, String table) throws com.kingsrook.qqq.backend.core.exceptions.QException + { + try + { + configureStorage(new PrintingDocumentStorage(instance, tenantId), table); + } + catch(IllegalArgumentException exception) + { + throw new com.kingsrook.qqq.backend.core.exceptions.QException(exception.getMessage(), exception); + } + } + + + + /*************************************************************************** + ** Package-scoped adapter seam supports storage failure tests without S3. + ***************************************************************************/ + void configureStorage(PrintingDocumentStorage storage, String table) + { + if(table != null) + { + storage.binding(table); + } + documentStorage = storage; + storageTableName = table; + } + + + + /*************************************************************************** + ** Defaults are host-owned; a persisted administrator setting takes priority. + ***************************************************************************/ + public PrintingService withRetentionPolicy(PrintingRetentionPolicy policy) + { + defaultRetentionPolicy = java.util.Objects.requireNonNull(policy); + return (this); + } @@ -71,12 +118,25 @@ public class PrintingService { String[] statements = { "CREATE TABLE IF NOT EXISTS printing_enrollment (code_hash VARCHAR(64) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, facility_id VARCHAR(200) NOT NULL, device_name VARCHAR(200) NOT NULL, expires_at VARCHAR(40) NOT NULL)", + "ALTER TABLE printing_enrollment ADD COLUMN IF NOT EXISTS consumed INTEGER DEFAULT 0 NOT NULL", "CREATE TABLE IF NOT EXISTS printing_device (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, facility_id VARCHAR(200) NOT NULL, name VARCHAR(200) NOT NULL, agent_version VARCHAR(100) NOT NULL, token_hash VARCHAR(64) UNIQUE NOT NULL, last_seen_at VARCHAR(40) NOT NULL, enabled INTEGER NOT NULL)", "CREATE TABLE IF NOT EXISTS printing_printer (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, name VARCHAR(300) NOT NULL, driver_name VARCHAR(300), port_name VARCHAR(300), status VARCHAR(40) NOT NULL, formats VARCHAR(100) NOT NULL, enabled INTEGER NOT NULL, last_seen_at VARCHAR(40) NOT NULL, UNIQUE(device_id, printer_id))", "ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS available INTEGER DEFAULT 0 NOT NULL", "CREATE TABLE IF NOT EXISTS printing_job (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, title VARCHAR(300) NOT NULL, format VARCHAR(10) NOT NULL, content_hash VARCHAR(64) NOT NULL, content_base64 TEXT NOT NULL, content_length INTEGER NOT NULL, options_json VARCHAR(2000) NOT NULL, idempotency_key VARCHAR(300) NOT NULL, created_at VARCHAR(40) NOT NULL, status VARCHAR(40) NOT NULL, message VARCHAR(2000), spooler_job_id VARCHAR(100), UNIQUE(tenant_id, idempotency_key))", "CREATE TABLE IF NOT EXISTS printing_event (id VARCHAR(100) PRIMARY KEY, job_id VARCHAR(36) NOT NULL REFERENCES printing_job(id), status VARCHAR(40) NOT NULL, event_json VARCHAR(4000) NOT NULL, created_at VARCHAR(40) NOT NULL)", "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_row_id VARCHAR(36)", + "ALTER TABLE printing_job ALTER COLUMN content_base64 DROP NOT NULL", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_available INTEGER DEFAULT 1 NOT NULL", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS completed_at VARCHAR(40)", + "UPDATE printing_job SET completed_at=(SELECT MAX(e.created_at) FROM printing_event e WHERE e.job_id=printing_job.id AND e.status IN ('SPOOLED','SPOOLER_COMPLETED')) WHERE completed_at IS NULL AND status IN ('SPOOLED','SPOOLER_COMPLETED')", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_storage_table VARCHAR(200)", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_reference VARCHAR(500)", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_binding VARCHAR(64)", + "CREATE TABLE IF NOT EXISTS printing_settings (tenant_id VARCHAR(200) PRIMARY KEY, retention_days INTEGER NOT NULL, retention_enabled INTEGER NOT NULL, last_run_at VARCHAR(40), last_run_count INTEGER, last_run_error VARCHAR(500))", + "ALTER TABLE printing_settings ADD COLUMN IF NOT EXISTS lease_token VARCHAR(36)", + "ALTER TABLE printing_settings ADD COLUMN IF NOT EXISTS lease_expires_at VARCHAR(40)", + "CREATE TABLE IF NOT EXISTS printing_document_orphan (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, storage_table VARCHAR(200) NOT NULL, reference VARCHAR(500) NOT NULL, binding VARCHAR(64) NOT NULL, created_at VARCHAR(40) NOT NULL)", + "CREATE INDEX IF NOT EXISTS printing_orphan_tenant_idx ON printing_document_orphan(tenant_id, created_at, id)", "UPDATE printing_job SET printer_row_id=(SELECT p.id FROM printing_printer p WHERE p.device_id=printing_job.device_id AND p.printer_id=printing_job.printer_id AND p.tenant_id=printing_job.tenant_id) WHERE printer_row_id IS NULL", "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS computer_name VARCHAR(1024)", "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS display_name VARCHAR(512)", @@ -90,6 +150,7 @@ public class PrintingService "UPDATE printing_printer SET queue_name=name WHERE queue_name IS NULL", "CREATE INDEX IF NOT EXISTS printing_job_dispatch_idx ON printing_job(tenant_id, device_id, status, created_at, id)", "CREATE INDEX IF NOT EXISTS printing_job_history_idx ON printing_job(tenant_id, created_at, id)", + "CREATE INDEX IF NOT EXISTS printing_job_retention_completed_idx ON printing_job(tenant_id, content_available, status, completed_at, id)", "CREATE INDEX IF NOT EXISTS printing_event_job_idx ON printing_event(job_id)", "CREATE INDEX IF NOT EXISTS printing_device_tenant_idx ON printing_device(tenant_id, last_seen_at)", "CREATE INDEX IF NOT EXISTS printing_printer_tenant_idx ON printing_printer(tenant_id, status)" @@ -110,6 +171,10 @@ public class PrintingService widenColumn(connection, "printing_printer", "driver_name", 1024); widenColumn(connection, "printing_printer", "port_name", 1024); widenColumn(connection, "printing_job", "title", 1024); + widenColumn(connection, "printing_job", "message", 2048); + widenColumn(connection, "printing_job", "spooler_job_id", 128); + widenColumn(connection, "printing_event", "id", 128); + widenColumn(connection, "printing_event", "event_json", 16000); } } @@ -192,11 +257,62 @@ public class PrintingService ***************************************************************************/ public JSONObject createEnrollment(String facilityId, String deviceName) throws SQLException { - String code = secret(); - String expires = Instant.now().plusSeconds(900).toString(); + return (createEnrollment(facilityId, deviceName, secret(), Instant.now().plusSeconds(900).toString())); + } + + + + /*************************************************************************** + ** Native processes persist a prepared random code before the insert step. + ** Replay reuses only an identical, unexpired, unconsumed hashed receipt. + ***************************************************************************/ + JSONObject createEnrollment(String facilityId, String deviceName, String code, String expires) throws SQLException + { + required(facilityId, "facilityId"); + required(deviceName, "deviceName"); + try + { + if(code == null || !code.matches("[A-Za-z0-9_-]{43}") || Base64.getUrlDecoder().decode(code).length != 32 + || expires == null || !Instant.parse(expires).isAfter(Instant.now()) || Instant.parse(expires).isAfter(Instant.now().plusSeconds(901))) + { + throw new IllegalArgumentException("Invalid prepared enrollment"); + } + } + catch(IllegalArgumentException | java.time.format.DateTimeParseException exception) + { + throw new PrintingException(400, "Enrollment request is invalid or expired; restart enrollment"); + } try(Connection connection = dataSource.getConnection()) { - update(connection, "INSERT INTO printing_enrollment VALUES (?, ?, ?, ?, ?)", hash(code), tenantId, required(facilityId, "facilityId"), required(deviceName, "deviceName"), expires); + List previous = query(connection, "SELECT * FROM printing_enrollment WHERE code_hash=?", hash(code)); + if(previous.isEmpty()) + { + try + { + update(connection, "INSERT INTO printing_enrollment (code_hash, tenant_id, facility_id, device_name, expires_at) VALUES (?, ?, ?, ?, ?)", hash(code), tenantId, facilityId, deviceName, expires); + } + catch(SQLException exception) + { + if(!"23505".equals(exception.getSQLState())) + { + throw exception; + } + previous = query(connection, "SELECT * FROM printing_enrollment WHERE code_hash=?", hash(code)); + if(previous.isEmpty()) + { + throw exception; + } + } + } + if(!previous.isEmpty()) + { + JSONObject old = previous.getFirst(); + if(old.getInt("consumed") != 0 || !tenantId.equals(old.getString("tenant_id")) || !facilityId.equals(old.getString("facility_id")) + || !deviceName.equals(old.getString("device_name")) || !expires.equals(old.getString("expires_at"))) + { + throw new PrintingException(409, "Enrollment request was already consumed or changed; restart enrollment"); + } + } } return (new JSONObject().put("enrollmentCode", code).put("expiresAt", expires)); } @@ -215,7 +331,7 @@ public class PrintingService try { List codes = query(connection, "SELECT * FROM printing_enrollment WHERE code_hash=? AND tenant_id=? FOR UPDATE", codeHash, tenantId); - if(codes.isEmpty() || Instant.parse(codes.getFirst().getString("expires_at")).isBefore(Instant.now())) + if(codes.isEmpty() || codes.getFirst().getInt("consumed") != 0 || !Instant.parse(codes.getFirst().getString("expires_at")).isAfter(Instant.now())) { throw new PrintingException(401, "Enrollment code is invalid or expired"); } @@ -224,7 +340,7 @@ public class PrintingService String token = secret(); String computerName = required(request.optString("deviceName"), "deviceName"); update(connection, "INSERT INTO printing_device (id, tenant_id, facility_id, name, agent_version, token_hash, last_seen_at, enabled, computer_name) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)", id, tenantId, code.getString("facility_id"), computerName, required(request.optString("agentVersion"), "agentVersion"), hash(token), Instant.now().toString(), computerName); - update(connection, "DELETE FROM printing_enrollment WHERE code_hash=?", codeHash); + update(connection, "UPDATE printing_enrollment SET consumed=1 WHERE code_hash=?", codeHash); connection.commit(); return (new JSONObject().put("protocolVersion", 1).put("deviceId", id).put("deviceToken", token).put("pollIntervalSeconds", 5)); } @@ -314,6 +430,12 @@ public class PrintingService String name = required(printer.optString("name"), "name"); String formats = printer.getJSONArray("formats").toString(); String status = printer.optString("status", "UNKNOWN"); + List supported = printer.getJSONArray("formats").toList(); + if(supported.isEmpty() || supported.size() > 2 || new java.util.HashSet<>(supported).size() != supported.size() + || !Set.of("PDF", "ZPL").containsAll(supported) || !Set.of("READY", "OFFLINE", "PAUSED", "ERROR", "UNKNOWN").contains(status)) + { + throw new PrintingException(400, "Invalid printer status or supported formats"); + } String now = Instant.now().toString(); String driver = optionalText(printer.optString("driverName"), "driverName", 1024); String port = optionalText(printer.optString("portName"), "portName", 1024); @@ -390,28 +512,28 @@ public class PrintingService { throw new PrintingException(400, "PDF header is missing"); } - JSONObject options = request.optJSONObject("options"); - if(options == null) - { - options = new JSONObject(); - } - options = new JSONObject(options.toString()); - options.put("copies", options.optInt("copies", 1)); - options.put("scale", options.optString("scale", "ACTUAL_SIZE")); - if(options.has("paperWidthMm") || options.has("paperHeightMm")) - { - throw new PrintingException(400, "Custom paper dimensions are not supported yet; configure the Windows queue"); - } - if(options.getInt("copies") < 1 || options.getInt("copies") > 100 || !Set.of("ACTUAL_SIZE", "FIT").contains(options.getString("scale"))) - { - throw new PrintingException(400, "Invalid print options"); - } + JSONObject options = printOptions(request); String digest = hash(content); + String id = UUID.randomUUID().toString(); + String reference = storageTableName == null ? null : documentStorage.prefix() + id + "." + format.toLowerCase(java.util.Locale.ROOT); + String binding = storageTableName == null ? null : documentStorage.binding(storageTableName); + String uploadedId = null; try(Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); try { + if(storageTableName != null) + { + requireDevice(connection, deviceId); + reserveUpload(connection, id, storageTableName, reference, binding); + connection.commit(); + uploadedId = id; + if(query(connection, "SELECT id FROM printing_document_orphan WHERE id=? FOR UPDATE", id).isEmpty()) + { + throw new PrintingException(409, "Upload reservation expired; retry submission"); + } + } // Serialize submissions per device; the unique key also protects cross-device races. requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); @@ -423,6 +545,7 @@ public class PrintingService { throw new PrintingException(409, "Idempotency key was already used for a different request"); } + update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId); connection.commit(); return (job(old)); } @@ -431,8 +554,12 @@ public class PrintingService { throw new PrintingException(400, "Printer is not enabled for this format"); } - String id = UUID.randomUUID().toString(); - update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?)", id, tenantId, deviceId, printerId, title, format, digest, Base64.getEncoder().encodeToString(content), content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id")); + if(storageTableName != null) + { + documentStorage.put(storageTableName, reference, format, content); + } + update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"), storageTableName, reference, binding); + update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId); JSONObject result = job(query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE id=?", id).getFirst()); connection.commit(); return (result); @@ -440,6 +567,17 @@ public class PrintingService catch(Exception exception) { connection.rollback(); + if(uploadedId != null) + { + try + { + cleanupOrphan(connection, uploadedId); + } + catch(Exception cleanupFailure) + { + exception.addSuppressed(cleanupFailure); + } + } throw exception; } } @@ -447,6 +585,52 @@ public class PrintingService + /*************************************************************************** + ** Preserve request intent: reject malformed types instead of coercing them. + ***************************************************************************/ + private static JSONObject printOptions(JSONObject request) + { + if(request.has("options") && !(request.opt("options") instanceof JSONObject)) + { + throw new PrintingException(400, "Print options must be an object"); + } + JSONObject options = request.has("options") ? new JSONObject(request.getJSONObject("options").toString()) : new JSONObject(); + Object copies = options.opt("copies"); + int count = 1; + if(options.has("copies")) + { + if(!(copies instanceof Number number)) + { + throw new PrintingException(400, "Copies must be an integer between 1 and 100"); + } + try + { + count = new java.math.BigDecimal(number.toString()).intValueExact(); + } + catch(ArithmeticException | NumberFormatException exception) + { + throw new PrintingException(400, "Copies must be an integer between 1 and 100"); + } + } + Object scale = options.has("scale") ? options.opt("scale") : "ACTUAL_SIZE"; + if(count < 1 || count > 100 || !(scale instanceof String) || !Set.of("ACTUAL_SIZE", "FIT").contains(scale)) + { + throw new PrintingException(400, "Invalid print options"); + } + if(options.has("paperWidthMm") || options.has("paperHeightMm")) + { + throw new PrintingException(400, "Custom paper dimensions are not supported yet; configure the Windows queue"); + } + options.put("copies", count).put("scale", scale); + if(options.toString().length() > 2000) + { + throw new PrintingException(400, "Print options are too large"); + } + return (options); + } + + + /*************************************************************************** ** Claim one job; replay the same assignment until the device advances it. ***************************************************************************/ @@ -487,24 +671,37 @@ public class PrintingService public JSONObject retry(String deviceId, String jobId, JSONObject request) throws SQLException { JSONObject row; + boolean mayHavePrinted; + String retryKey = "device-retry:" + deviceId + ":" + jobId + ":" + required(request.optString("requestId"), "requestId"); try(Connection connection = dataSource.getConnection()) { requireDevice(connection, deviceId); row = deviceJob(connection, deviceId, jobId, true); + List previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND device_id=? AND idempotency_key=?", tenantId, deviceId, retryKey); + if(!previous.isEmpty()) + { + return (job(previous.getFirst())); + } + if(row.getInt("content_available") == 0) + { + throw new PrintingException(410, "Print document expired under the host retention policy; submit a new document explicitly"); + } + mayHavePrinted = !row.optString("spooler_job_id").isBlank() + || !query(connection, "SELECT id FROM printing_event WHERE job_id=? AND status IN ('SUBMITTING','SPOOLED','SPOOLER_COMPLETED','OUTCOME_UNKNOWN') LIMIT 1", jobId).isEmpty(); } String state = row.getString("status"); if(!Set.of("FAILED", "OUTCOME_UNKNOWN", "SPOOLED", "SPOOLER_COMPLETED").contains(state)) { throw new PrintingException(409, "Only a finished or uncertain job can be reprinted"); } - if(!"FAILED".equals(state) && !request.optBoolean("confirmDuplicateRisk", false)) + if((!"FAILED".equals(state) || mayHavePrinted) && !Boolean.TRUE.equals(request.opt("confirmDuplicateRisk"))) { throw new PrintingException(409, "Inspect the printer and confirm the risk of duplicate output before reprinting"); } return (submit(new JSONObject().put("deviceId", deviceId).put("printerId", row.getString("printer_id")) - .put("title", row.getString("title")).put("format", row.getString("format")).put("contentBase64", row.getString("content_base64")) + .put("title", row.getString("title")).put("format", row.getString("format")).put("contentBase64", Base64.getEncoder().encodeToString(documentBytes(row))) .put("options", new JSONObject(row.getString("options_json"))) - .put("idempotencyKey", "device-retry:" + deviceId + ":" + jobId + ":" + required(request.optString("requestId"), "requestId")))); + .put("idempotencyKey", retryKey))); } @@ -522,11 +719,16 @@ public class PrintingService requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); JSONObject row = deviceJob(connection, deviceId, jobId); + if("FAILED".equals(row.getString("status")) && CANCELED_BEFORE_DISPATCH.equals(row.optString("message"))) + { + connection.commit(); + return (new JSONObject().put("accepted", true)); + } if(!"QUEUED".equals(row.getString("status"))) { throw new PrintingException(409, "Job already reached the device; inspect or cancel its Windows spooler job"); } - update(connection, "UPDATE printing_job SET status='FAILED', message='Canceled by operator before dispatch' WHERE id=?", jobId); + update(connection, "UPDATE printing_job SET status='FAILED', message=? WHERE id=?", CANCELED_BEFORE_DISPATCH, jobId); connection.commit(); return (new JSONObject().put("accepted", true)); } @@ -552,7 +754,7 @@ public class PrintingService { requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); - List candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') ORDER BY j.created_at, j.id LIMIT 1", tenantId, deviceId); + List candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') AND ((j.format='PDF' AND p.formats LIKE '%\"PDF\"%') OR (j.format='ZPL' AND p.formats LIKE '%\"ZPL\"%')) ORDER BY j.created_at, j.id LIMIT 1", tenantId, deviceId); if(candidates.isEmpty()) { connection.commit(); @@ -590,7 +792,11 @@ public class PrintingService { throw new PrintingException(409, "Claim the job before downloading"); } - return (Base64.getDecoder().decode(row.getString("content_base64"))); + if(row.getInt("content_available") == 0) + { + throw new PrintingException(410, "Print document expired under the host retention policy"); + } + return (documentBytes(row)); } } @@ -603,6 +809,12 @@ public class PrintingService { String eventId = required(event.optString("eventId"), "eventId"); String status = required(event.optString("status"), "status"); + String message = event.has("message") ? eventText(event, "message", 2048) : ""; + String spooler = event.has("spoolerJobId") ? eventText(event, "spoolerJobId", 128) : null; + if(event.toString().length() > 16000) + { + throw new PrintingException(400, "Event is too large"); + } List states = List.of("CLAIMED", "RECEIVED", "SUBMITTING", "SPOOLED", "SPOOLER_COMPLETED"); if(!Set.of("RECEIVED", "SUBMITTING", "SPOOLED", "SPOOLER_COMPLETED", "FAILED", "OUTCOME_UNKNOWN").contains(status)) { @@ -632,8 +844,13 @@ public class PrintingService { throw new PrintingException(409, "Invalid state transition"); } - update(connection, "INSERT INTO printing_event VALUES (?, ?, ?, ?, ?)", eventId, jobId, status, event.toString(), Instant.now().toString()); - update(connection, "UPDATE printing_job SET status=?, message=?, spooler_job_id=? WHERE id=?", status, event.optString("message"), event.optString("spoolerJobId", row.optString("spooler_job_id")), jobId); + String eventTime = Instant.now().toString(); + update(connection, "INSERT INTO printing_event VALUES (?, ?, ?, ?, ?)", eventId, jobId, status, event.toString(), eventTime); + if(Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(status)) + { + update(connection, "UPDATE printing_job SET completed_at=? WHERE id=?", eventTime, jobId); + } + update(connection, "UPDATE printing_job SET status=?, message=?, spooler_job_id=? WHERE id=?", status, message, spooler == null ? row.optString("spooler_job_id") : spooler, jobId); connection.commit(); } catch(Exception exception) @@ -781,6 +998,70 @@ public class PrintingService + /*************************************************************************** + ** Host-authorized maintenance only, never called by device routes. Purge a + ** bounded batch of old successful payloads while preserving all receipts, + ** hashes and idempotency keys. Failed and uncertain jobs are never selected. + ***************************************************************************/ + public int purgeCompletedContent(PrintingRetentionPolicy policy) throws SQLException + { + try(Connection connection = dataSource.getConnection()) + { + return (purgeCompletedContent(connection, policy, new int[1], null)); + } + } + + + + /*************************************************************************** + ** Commit each confirmed removal so a later failure cannot undo its receipt. + ***************************************************************************/ + private int purgeCompletedContent(Connection connection, PrintingRetentionPolicy policy, int[] progress, String leaseToken) throws SQLException + { + java.util.Objects.requireNonNull(policy); + if(!policy.enabled()) + { + return (0); + } + String cutoff = Instant.now().minusSeconds(policy.days() * 86400L).toString(); + { + connection.setAutoCommit(false); + try + { + List rows = query(connection, "SELECT id FROM printing_job WHERE tenant_id=? AND content_available=1 AND status IN ('SPOOLED','SPOOLER_COMPLETED') AND completed_at locked = query(connection, "SELECT id, format, content_storage_table, content_reference, content_binding FROM printing_job WHERE tenant_id=? AND id=? AND content_available=1 AND status IN ('SPOOLED','SPOOLER_COMPLETED') AND completed_at orphans = query(connection, "SELECT id FROM printing_document_orphan WHERE tenant_id=? AND created_at rows = query(connection, "SELECT * FROM printing_document_orphan WHERE tenant_id=? AND id=? FOR UPDATE", tenantId, id); + if(rows.isEmpty()) + { + connection.commit(); + return (0); + } + JSONObject orphan = rows.getFirst(); + if(query(connection, "SELECT id FROM printing_job WHERE id=? AND tenant_id=?", id, tenantId).isEmpty()) + { + if(documentStorage == null) + { + throw new PrintingException(503, "The host printing storage adapter is not configured"); + } + documentStorage.verifyBinding(orphan.getString("storage_table"), orphan.getString("binding")); + String reference = orphan.getString("reference"); + String expected = documentStorage.prefix() + id; + if(!reference.equals(expected + ".pdf") && !reference.equals(expected + ".zpl")) + { + throw new PrintingException(409, "Orphan reference does not match its owned upload identity"); + } + documentStorage.delete(orphan.getString("storage_table"), orphan.getString("reference")); + } + update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId); + connection.commit(); + return (1); + } + catch(Exception exception) + { + connection.rollback(); + throw exception; + } + } + } + + + /*************************************************************************** ** Restrict content and event access to the assigned device. ***************************************************************************/ @@ -809,7 +1323,7 @@ public class PrintingService ***************************************************************************/ private JSONObject deviceJob(Connection connection, String deviceId, String jobId, boolean includeContent) throws SQLException { - List rows = query(connection, "SELECT " + JOB_COLUMNS + (includeContent ? ", content_base64" : "") + " FROM printing_job WHERE id=? AND tenant_id=? AND device_id=?", jobId, tenantId, deviceId); + List rows = query(connection, "SELECT " + JOB_COLUMNS + (includeContent ? ", content_base64, content_available, content_storage_table, content_reference, content_binding" : "") + " FROM printing_job WHERE id=? AND tenant_id=? AND device_id=?", jobId, tenantId, deviceId); if(rows.isEmpty()) { throw new PrintingException(404, "Job not found"); @@ -892,7 +1406,8 @@ public class PrintingService { case "name", "deviceName", "title" -> 1024; case "tenantId", "facilityId", "printerId" -> 200; - case "agentVersion", "eventId" -> 100; + case "agentVersion" -> 100; + case "eventId" -> 128; default -> 300; }; if(value == null || value.isBlank() || value.length() > maximum) @@ -904,6 +1419,20 @@ public class PrintingService + /*************************************************************************** + ** Bound event fields before persistence without coercing invalid JSON types. + ***************************************************************************/ + private static String eventText(JSONObject event, String name, int maximum) + { + if(!(event.opt(name) instanceof String value)) + { + throw new PrintingException(400, "Invalid " + name); + } + return (optionalText(value, name, maximum)); + } + + + /*************************************************************************** ** Client-owned metadata synchronizes without changing technical identity. ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingDocumentStorageTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingDocumentStorageTest.java new file mode 100644 index 0000000..adfbfa1 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingDocumentStorageTest.java @@ -0,0 +1,391 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Exercise the native storage abstraction with isolated in-memory objects. + ** S3 metadata validation never contacts an endpoint or uses credentials. + *******************************************************************************/ +class PrintingDocumentStorageTest +{ + private static final byte[] DOCUMENT = "^XA^FO20,20^FDTest^FS^XZ".getBytes(StandardCharsets.US_ASCII); + + + + /*************************************************************************** + ** New payloads are external; historical bindings survive target changes. + ***************************************************************************/ + @Test + void storesNativeObjectsAndReadsImmutableHistoricalBindings() throws Exception + { + Fixture fixture = fixture(); + String id = fixture.service.submit(request(fixture.device, "first")).getString("jobId"); + JSONObject stored = row(fixture.source, "SELECT * FROM printing_job WHERE id='" + id + "'"); + fixture.service.claim(fixture.device); + assertTrue(stored.isNull("CONTENT_BASE64")); + assertEquals("printing_documents", stored.getString("CONTENT_STORAGE_TABLE")); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, id)); + assertTrue(QContext.getQSession() == null); + fixture.service.configureStorage(fixture.storage, "printing_archive"); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, id)); + ageSuccess(fixture, id); + JSONObject retryRequest = new JSONObject().put("requestId", "retry").put("confirmDuplicateRisk", true); + String retry = fixture.service.retry(fixture.device, id, retryRequest).getString("jobId"); + fixture.service.claim(fixture.device); + assertNotEquals(id, retry); + assertEquals("printing_archive", row(fixture.source, "SELECT * FROM printing_job WHERE id='" + retry + "'").getString("CONTENT_STORAGE_TABLE")); + assertEquals(1, fixture.service.runRetention()); + assertEquals(retry, fixture.service.retry(fixture.device, id, retryRequest).getString("jobId")); + assertEquals(410, assertThrows(PrintingException.class, () -> fixture.service.retry(fixture.device, id, new JSONObject(retryRequest.toString()).put("requestId", "new"))).getStatus()); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, retry)); + assertEquals(1, fixture.service.getRetentionSettings().getInt("lastRunCount")); + assertEquals(0, fixture.service.getRetentionSettings().getLong("eligibleBacklog")); + } + + + + /*************************************************************************** + ** Foreign tenant, sibling job and retargeted metadata fail before deletion. + ***************************************************************************/ + @Test + void rejectsForeignAndSiblingReferencesAndChangedBindings() throws Exception + { + Fixture fixture = fixture(); + String first = fixture.service.submit(request(fixture.device, "first")).getString("jobId"); + String second = fixture.service.submit(request(fixture.device, "second")).getString("jobId"); + execute(fixture.source, "UPDATE printing_job SET status='CLAIMED'"); + String own = fixture.storage.prefix() + first + ".zpl"; + String sibling = fixture.storage.prefix() + second + ".zpl"; + for(String invalid : List.of(sibling, new PrintingDocumentStorage(fixture.instance, "foreign", true).prefix() + first + ".zpl", "../foreign.zpl")) + { + execute(fixture.source, "UPDATE printing_job SET content_reference=? WHERE id=?", invalid, first); + assertEquals(409, assertThrows(PrintingException.class, () -> fixture.service.content(fixture.device, first)).getStatus()); + ageSuccess(fixture, first); + assertThrows(PrintingException.class, fixture.service::runRetention); + assertArrayEquals(DOCUMENT, fixture.storage.get("printing_documents", own)); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, second)); + } + execute(fixture.source, "UPDATE printing_job SET content_reference=?,content_binding=? WHERE id=?", own, "incorrect", first); + assertEquals(409, assertThrows(PrintingException.class, () -> fixture.service.content(fixture.device, first)).getStatus()); + } + + + + /*************************************************************************** + ** Upload/delete failures leave durable exact references for later recovery. + ***************************************************************************/ + @Test + void uploadFailureAndOrphanRetryNeverDeleteAnotherJob() throws Exception + { + Fixture fixture = fixture(); + fixture.storage.failUpload = true; + assertThrows(SQLException.class, () -> fixture.service.submit(request(fixture.device, "compensated"))); + assertEquals(0, row(fixture.source, "SELECT COUNT(*) AS total FROM printing_document_orphan").getInt("TOTAL")); + assertThrows(SQLException.class, () -> fixture.storage.get("printing_documents", fixture.storage.lastReference)); + fixture.storage.failDelete = true; + assertThrows(SQLException.class, () -> fixture.service.submit(request(fixture.device, "broken"))); + assertEquals(0, fixture.service.jobs().length()); + JSONObject orphan = row(fixture.source, "SELECT * FROM printing_document_orphan"); + String reference = orphan.getString("REFERENCE"); + assertArrayEquals(DOCUMENT, fixture.storage.get("printing_documents", reference)); + fixture.storage.failUpload = false; + fixture.storage.failDelete = false; + String other = fixture.service.submit(request(fixture.device, "other")).getString("jobId"); + fixture.service.claim(fixture.device); + execute(fixture.source, "UPDATE printing_document_orphan SET created_at=?,reference=?", Instant.now().minusSeconds(7200).toString(), fixture.storage.prefix() + other + ".zpl"); + assertThrows(PrintingException.class, fixture.service::runRetention); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, other)); + execute(fixture.source, "UPDATE printing_document_orphan SET reference=?", reference); + assertEquals(1, fixture.service.runRetention()); + assertThrows(SQLException.class, () -> fixture.storage.get("printing_documents", reference)); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, other)); + } + + + + /*************************************************************************** + ** A later failed removal cannot roll back a prior confirmed receipt. + ***************************************************************************/ + @Test + void commitsSuccessfulDeletionBeforeLaterFailure() throws Exception + { + Fixture fixture = fixture(); + String first = fixture.service.submit(request(fixture.device, "first")).getString("jobId"); + String second = fixture.service.submit(request(fixture.device, "second")).getString("jobId"); + ageSuccess(fixture, first); + ageSuccess(fixture, second); + fixture.storage.failDeleteNumber = 2; + assertThrows(SQLException.class, fixture.service::runRetention); + assertEquals(1, row(fixture.source, "SELECT COUNT(*) AS total FROM printing_job WHERE content_available=0").getInt("TOTAL")); + assertEquals(1, fixture.service.getRetentionSettings().getInt("lastRunCount")); + assertFalse(fixture.service.getRetentionSettings().getString("lastRunError").isBlank()); + fixture.storage.failDeleteNumber = -1; + assertEquals(1, fixture.service.runRetention()); + assertEquals(0, fixture.service.runRetention()); + } + + + + /*************************************************************************** + ** Canonical bucket checks ignore region/path-style/default-port aliases. + ***************************************************************************/ + @Test + void validatesDedicatedS3BindingWithoutNetwork() throws Exception + { + QInstance instance = new QInstance(); + S3BackendMetaData printing = new S3BackendMetaData().withName("printing").withBucketName("print-documents").withEndpoint("https://storage.example"); + instance.addBackend(printing); + instance.addTable(new QTableMetaData().withName("printing_documents").withBackendName("printing").withBackendDetails(new S3TableBackendDetails())); + PrintingDocumentStorage storage = new PrintingDocumentStorage(instance, "tenant-a"); + String original = storage.binding("printing_documents"); + printing.setBucketName("another-bucket"); + assertThrows(PrintingException.class, () -> storage.verifyBinding("printing_documents", original)); + printing.setBucketName("print-documents"); + S3BackendMetaData other = new S3BackendMetaData().withName("uploads").withBucketName("print-documents").withEndpoint("https://storage.example:443/"); + other.setPathStyleAccess(true); + other.setRegion("different-region"); + instance.addBackend(other); + instance.addTable(new QTableMetaData().withName("general_uploads").withBackendName("uploads").withBackendDetails(new S3TableBackendDetails())); + assertThrows(IllegalArgumentException.class, () -> storage.binding("printing_documents")); + other.setEndpoint("https://separate.example"); + assertEquals(original, storage.binding("printing_documents")); + for(String endpoint : List.of("http://remote.example", "ftp://remote.example", "https://user:secret@storage.example", "https://storage.example?secret=value", "//storage.example")) + { + printing.setEndpoint(endpoint); + assertThrows(IllegalArgumentException.class, () -> storage.binding("printing_documents")); + } + printing.setEndpoint("http://127.0.0.1:9000"); + assertFalse(storage.binding("printing_documents").isBlank()); + } + + + + /*************************************************************************** + ** Existing active documents get a full retention interval after completion. + ***************************************************************************/ + @Test + void completionAgeAndLegacyMigrationAreConservative() throws Exception + { + Fixture fixture = fixture(); + String job = fixture.service.submit(request(fixture.device, "old-active")).getString("jobId"); + execute(fixture.source, "UPDATE printing_job SET created_at=? WHERE id=?", Instant.now().minusSeconds(800 * 86400L).toString(), job); + fixture.service.claim(fixture.device); + fixture.service.event(fixture.device, job, new JSONObject().put("eventId", UUID.randomUUID().toString()).put("status", "SPOOLED")); + assertEquals(0, fixture.service.runRetention()); + execute(fixture.source, "UPDATE printing_job SET completed_at=NULL WHERE id=?", job); + fixture.service.initialize(); + assertEquals(0, fixture.service.runRetention()); + assertFalse(row(fixture.source, "SELECT * FROM printing_job WHERE id='" + job + "'").isNull("COMPLETED_AT")); + execute(fixture.source, "DELETE FROM printing_event WHERE job_id=?", job); + execute(fixture.source, "UPDATE printing_job SET completed_at=NULL WHERE id=?", job); + fixture.service.initialize(); + assertEquals(0, fixture.service.runRetention()); + } + + + + /*************************************************************************** + ** Shared synthetic fixture; no S3 bucket or printer is used. + ***************************************************************************/ + private static Fixture fixture() throws Exception + { + QContext.clear(); + QInstance instance = new QInstance(); + instance.withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData().withName("mock").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.MOCK)); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule.class)); + for(String table : List.of("printing_documents", "printing_archive")) + { + instance.addTable(new QTableMetaData().withName(table).withBackendName("memory").withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB))); + } + DriverDataSource source = new DriverDataSource("jdbc:h2:mem:storage-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + FaultStorage storage = new FaultStorage(instance); + PrintingService service = new PrintingService(source, "tenant-a"); + service.initialize(); + service.configureStorage(storage, "printing_documents"); + String device = service.enroll(service.createEnrollment("warehouse", "station").put("deviceName", "station").put("agentVersion", "test")).getString("deviceId"); + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic").put("formats", new JSONArray(List.of("ZPL"))))); + service.enablePrinter(device, "queue", true); + return (new Fixture(source, service, storage, instance, device)); + } + + + + /*************************************************************************** + ** Synthetic bytes never leave the test process. + ***************************************************************************/ + private static JSONObject request(String device, String key) + { + return (new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Synthetic").put("format", "ZPL") + .put("contentBase64", Base64.getEncoder().encodeToString(DOCUMENT)).put("idempotencyKey", key)); + } + + + + /*************************************************************************** + ** Explicitly age a synthetic successful receipt. + ***************************************************************************/ + private static void ageSuccess(Fixture fixture, String id) throws Exception + { + execute(fixture.source, "UPDATE printing_job SET status='SPOOLED',completed_at=? WHERE id=?", Instant.now().minusSeconds(400 * 86400L).toString(), id); + } + + + + /*************************************************************************** + ** Isolated database mutations for fault and migration fixtures. + ***************************************************************************/ + private static void execute(DriverDataSource source, String sql, Object... values) throws Exception + { + try(var connection = source.getConnection(); var statement = connection.prepareStatement(sql)) + { + for(int index = 0; index < values.length; index++) + { + statement.setObject(index + 1, values[index]); + } + statement.executeUpdate(); + } + } + + + + /*************************************************************************** + ** Read synthetic metadata without loading production state. + ***************************************************************************/ + private static JSONObject row(DriverDataSource source, String sql) throws Exception + { + try(var connection = source.getConnection(); var statement = connection.createStatement(); var rows = statement.executeQuery(sql)) + { + assertTrue(rows.next()); + JSONObject result = new JSONObject(); + for(int index = 1; index <= rows.getMetaData().getColumnCount(); index++) + { + result.put(rows.getMetaData().getColumnLabel(index), rows.getObject(index) == null ? JSONObject.NULL : rows.getObject(index)); + } + return (result); + } + } + + + + /*************************************************************************** + ** Do not leak the memory backend context between tests. + ***************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + + + /*************************************************************************** + ** Compact isolated state owned by one test. + ***************************************************************************/ + private record Fixture(DriverDataSource source, PrintingService service, FaultStorage storage, QInstance instance, String device) + { + } + + + + /*************************************************************************** + ** Fail after upload or at a selected deletion to exercise crash receipts. + ***************************************************************************/ + private static class FaultStorage extends PrintingDocumentStorage + { + private boolean failUpload; + private boolean failDelete; + private int failDeleteNumber = -1; + private int deletes; + private String lastReference; + + + + /************************************************************************ + ** Use real QQQ memory storage underneath each injected failure. + ************************************************************************/ + private FaultStorage(QInstance instance) + { + super(instance, "tenant-a", true); + } + + + + /************************************************************************ + ** Ambiguous upload failure may already have stored all bytes. + ************************************************************************/ + @Override + public void put(String table, String reference, String format, byte[] content) throws SQLException + { + lastReference = reference; + super.put(table, reference, format, content); + if(failUpload) + { + throw new SQLException("Synthetic upload acknowledgement failure"); + } + } + + + + /************************************************************************ + ** Failed deletes must retain a retryable database reference. + ************************************************************************/ + @Override + public void delete(String table, String reference) throws SQLException + { + deletes++; + if(failDelete || deletes == failDeleteNumber) + { + throw new SQLException("Synthetic deletion failure"); + } + super.delete(table, reference); + } + } +} + + diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingMaintenanceTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingMaintenanceTest.java new file mode 100644 index 0000000..7c23d43 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingMaintenanceTest.java @@ -0,0 +1,172 @@ +/* + * 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.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Isolated maintenance and concurrency regressions never use live print state. + *******************************************************************************/ +class PrintingMaintenanceTest +{ + /*************************************************************************** + ** Retention is bounded, tenant-scoped and preserves replay protection. + ***************************************************************************/ + @Test + void retentionPurgesOnlyOldSuccessAndKeepsIdempotencyReceipts() throws Exception + { + DriverDataSource source = source(); + PrintingService service = new PrintingService(source, "tenant-a"); + service.initialize(); + String device = enroll(service); + Map submitted = new HashMap<>(); + for(String status : List.of("QUEUED", "CLAIMED", "RECEIVED", "SUBMITTING", "SPOOLED", "SPOOLER_COMPLETED", "FAILED", "OUTCOME_UNKNOWN")) + { + JSONObject request = request(device, status); + String id = service.submit(request).getString("jobId"); + submitted.put(id, request); + try(var connection = source.getConnection(); var statement = connection.prepareStatement("UPDATE printing_job SET status=?,completed_at=? WHERE id=?")) + { + statement.setString(1, status); + statement.setString(2, Instant.now().minusSeconds(40 * 86400L).toString()); + statement.setString(3, id); + statement.executeUpdate(); + } + } + assertEquals(0, service.purgeCompletedContent(new PrintingQBitConfig().getRetentionPolicy())); + PrintingRetentionPolicy enabled = new PrintingRetentionPolicy(true, 30, 1); + PrintingService other = new PrintingService(source, "tenant-b"); + assertEquals(0, other.purgeCompletedContent(enabled)); + assertEquals(1, service.purgeCompletedContent(enabled)); + String purged; + try(var connection = source.getConnection(); var statement = connection.prepareStatement("SELECT id FROM printing_job WHERE content_available=0"); var rows = statement.executeQuery()) + { + assertTrue(rows.next()); + purged = rows.getString(1); + } + assertEquals(purged, service.submit(submitted.get(purged)).getString("jobId")); + assertEquals(410, assertThrows(PrintingException.class, () -> service.content(device, purged)).getStatus()); + assertEquals(410, assertThrows(PrintingException.class, () -> service.retry(device, purged, new JSONObject().put("requestId", "retry").put("confirmDuplicateRisk", true))).getStatus()); + assertEquals(1, service.purgeCompletedContent(enabled)); + assertEquals(0, service.purgeCompletedContent(enabled)); + assertEquals(8, service.jobs().length()); + assertThrows(IllegalArgumentException.class, () -> new PrintingRetentionPolicy(true, 0, 100)); + assertThrows(IllegalArgumentException.class, () -> new PrintingRetentionPolicy(true, 30, 201)); + } + + + + /*************************************************************************** + ** Concurrent enrollment consumes one code; matching submissions deduplicate. + ***************************************************************************/ + @Test + void concurrentEnrollmentAndSubmissionPreserveSingleOwnership() throws Exception + { + PrintingService service = new PrintingService(source(), "tenant-a"); + service.initialize(); + JSONObject code = service.createEnrollment("warehouse", "station"); + Callable enroll = () -> + { + try + { + return service.enroll(new JSONObject(code.toString()).put("deviceName", "station").put("agentVersion", "test")).getString("deviceId"); + } + catch(PrintingException exception) + { + assertEquals(401, exception.getStatus()); + return "rejected"; + } + }; + try(var pool = Executors.newFixedThreadPool(2)) + { + var outcomes = pool.invokeAll(List.of(enroll, enroll)); + assertNotEquals(outcomes.get(0).get(), outcomes.get(1).get()); + assertEquals(1, service.devices().length()); + String device = service.devices().getJSONObject(0).getString("deviceId"); + configure(service, device); + Callable submit = () -> service.submit(request(device, "same-key")).getString("jobId"); + var jobs = pool.invokeAll(List.of(submit, submit)); + assertEquals(jobs.get(0).get(), jobs.get(1).get()); + assertEquals(1, service.jobs().length()); + } + } + + + + /*************************************************************************** + ** In-memory data only; no production credentials or printer connections. + ***************************************************************************/ + private static DriverDataSource source() + { + return (new DriverDataSource("jdbc:h2:mem:audit-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "")); + } + + + + /*************************************************************************** + ** Register an isolated synthetic queue. + ***************************************************************************/ + private static String enroll(PrintingService service) throws Exception + { + String device = service.enroll(service.createEnrollment("warehouse", "station").put("deviceName", "station").put("agentVersion", "test")).getString("deviceId"); + configure(service, device); + return (device); + } + + + + /*************************************************************************** + ** Approval is explicit even in the test fixture. + ***************************************************************************/ + private static void configure(PrintingService service, String device) throws Exception + { + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic").put("formats", new JSONArray(List.of("ZPL"))))); + service.enablePrinter(device, "queue", true); + } + + + + /*************************************************************************** + ** A minimal synthetic document that is never dispatched to Windows. + ***************************************************************************/ + private static JSONObject request(String device, String key) + { + return (new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Synthetic").put("format", "ZPL") + .put("contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(java.nio.charset.StandardCharsets.US_ASCII))).put("idempotencyKey", key)); + } +} + diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingPoolTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingPoolTest.java new file mode 100644 index 0000000..a9f0176 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingPoolTest.java @@ -0,0 +1,295 @@ +/* + * 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.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import org.h2.jdbcx.JdbcConnectionPool; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** A bounded real JDBC pool exposes nested-acquisition deadlocks hidden by unpooled fixtures. + *******************************************************************************/ +class PrintingPoolTest +{ + /*************************************************************************** + ** A second scheduler invocation skips promptly while the first is deleting an object. + ***************************************************************************/ + @Test + void cleanupLeasePreventsOverlappingStorageDeletionAndReleasesAfterSuccess() throws Exception + { + JdbcConnectionPool pool = JdbcConnectionPool.create("jdbc:h2:mem:printing-lease-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + pool.setMaxConnections(2); + pool.setLoginTimeout(2); + BlockingDocuments documents = new BlockingDocuments(); + try + { + PrintingService service = new PrintingService(pool, "tenant-a"); + service.initialize(); + service.configureStorage(documents, "printing_documents"); + String job = service.submit(request(enroll(service, "station"), "old")).getString("jobId"); + try(var connection = pool.getConnection(); var statement = connection.prepareStatement("UPDATE printing_job SET status='SPOOLED',completed_at=? WHERE id=?")) + { + statement.setString(1, Instant.now().minusSeconds(400L * 86400).toString()); + statement.setString(2, job); + statement.executeUpdate(); + } + try(var executor = Executors.newFixedThreadPool(2)) + { + var active = executor.submit(service::runRetention); + try + { + assertTrue(documents.entered.await(5, TimeUnit.SECONDS)); + assertEquals(0, executor.submit(service::runRetention).get(3, TimeUnit.SECONDS)); + assertEquals(1, documents.deletions.get()); + } + finally + { + documents.release.countDown(); + } + assertEquals(1, active.get(5, TimeUnit.SECONDS)); + } + try(var connection = pool.getConnection(); var statement = connection.prepareStatement("SELECT lease_token,last_run_count FROM printing_settings WHERE tenant_id='tenant-a'"); var rows = statement.executeQuery()) + { + assertTrue(rows.next()); + assertNull(rows.getString("lease_token")); + assertEquals(1, rows.getInt("last_run_count")); + } + assertEquals(0, service.runRetention()); + } + finally + { + documents.release.countDown(); + pool.dispose(); + } + } + + + + /*************************************************************************** + ** A single connection must suffice even for the durable external-upload intent. + ***************************************************************************/ + @Test + void singleConnectionSupportsConcurrentSubmissionsAndMaintenance() throws Exception + { + verifyBoundedPool(1); + } + + + + /*************************************************************************** + ** Two concurrent devices must not each hold one connection while waiting for another. + ***************************************************************************/ + @Test + void twoConnectionsSupportConcurrentSubmissionsAndMaintenance() throws Exception + { + verifyBoundedPool(2); + } + + + + /*************************************************************************** + ** Exercise external-storage submission and cleanup concurrently, without network or printing. + ***************************************************************************/ + private static void verifyBoundedPool(int capacity) throws Exception + { + JdbcConnectionPool pool = JdbcConnectionPool.create("jdbc:h2:mem:printing-pool-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + pool.setMaxConnections(capacity); + pool.setLoginTimeout(2); + try + { + PrintingService service = new PrintingService(pool, "tenant-a"); + service.initialize(); + MemoryDocuments documents = new MemoryDocuments(); + service.configureStorage(documents, "printing_documents"); + String first = enroll(service, "first"); + String second = enroll(service, "second"); + String old = service.submit(request(first, "old")).getString("jobId"); + try(var connection = pool.getConnection(); var statement = connection.prepareStatement("UPDATE printing_job SET status='SPOOLED',completed_at=? WHERE id=?")) + { + statement.setString(1, Instant.now().minusSeconds(400L * 86400).toString()); + statement.setString(2, old); + assertEquals(1, statement.executeUpdate()); + } + CountDownLatch start = new CountDownLatch(1); + try(var executor = Executors.newFixedThreadPool(3)) + { + var a = executor.submit(() -> + { + start.await(); + return service.submit(request(first, "new-a")).getString("jobId"); + }); + var b = executor.submit(() -> + { + start.await(); + return service.submit(request(second, "new-b")).getString("jobId"); + }); + var cleanup = executor.submit(() -> + { + start.await(); + return service.runRetention(); + }); + start.countDown(); + assertNotEquals(a.get(10, TimeUnit.SECONDS), b.get(10, TimeUnit.SECONDS)); + assertEquals(1, cleanup.get(10, TimeUnit.SECONDS)); + } + assertEquals(3, service.jobs().length()); + assertEquals(2, documents.objects.size()); + assertEquals(0, service.runRetention()); + assertEquals(0, pool.getActiveConnections()); + } + finally + { + pool.dispose(); + } + } + + + + /*************************************************************************** + ** Synthetic devices have independent database locks and explicitly approved queues. + ***************************************************************************/ + private static String enroll(PrintingService service, String name) throws Exception + { + String device = service.enroll(service.createEnrollment("warehouse", name).put("deviceName", name).put("agentVersion", "test")).getString("deviceId"); + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic").put("formats", new JSONArray(List.of("ZPL"))))); + service.enablePrinter(device, "queue", true); + return (device); + } + + + + /*************************************************************************** + ** Valid synthetic bytes exercise storage writes but never reach a physical printer. + ***************************************************************************/ + private static JSONObject request(String device, String key) + { + return (new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Pool regression").put("format", "ZPL") + .put("contentBase64", Base64.getEncoder().encodeToString(new byte[] { 1 })).put("idempotencyKey", key)); + } + + + + /*************************************************************************** + ** Only document I/O is fake; SQL pooling, transactions, intents and leases are real. + ***************************************************************************/ + private static class MemoryDocuments extends PrintingDocumentStorage + { + private final Map objects = new ConcurrentHashMap<>(); + + + + /*********************************************************************** + ** Bind the same tenant prefix used by production reference verification. + ***********************************************************************/ + MemoryDocuments() + { + super(new QInstance(), "tenant-a", true); + } + + + + /*********************************************************************** + ** This test does not exercise S3 metadata validation; separate adapter tests do. + ***********************************************************************/ + @Override + public String binding(String table) + { + return ("fixture-binding"); + } + + + + /*********************************************************************** + ** Retain exact bytes by the generated object reference. + ***********************************************************************/ + @Override + public void put(String table, String reference, String format, byte[] content) + { + assertTrue(reference.startsWith(prefix())); + objects.put(reference, content.clone()); + } + + + + /*********************************************************************** + ** Deletion is idempotent, matching the external-storage contract. + ***********************************************************************/ + @Override + public void delete(String table, String reference) + { + objects.remove(reference); + } + } + + + + /*************************************************************************** + ** Hold one real cleanup transaction at the external-storage boundary. + ***************************************************************************/ + private static class BlockingDocuments extends MemoryDocuments + { + private final CountDownLatch entered = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final java.util.concurrent.atomic.AtomicInteger deletions = new java.util.concurrent.atomic.AtomicInteger(); + + + + /*********************************************************************** + ** A second deletion would reveal an ineffective cross-worker lease. + ***********************************************************************/ + @Override + public void delete(String table, String reference) + { + deletions.incrementAndGet(); + entered.countDown(); + try + { + if(!release.await(10, TimeUnit.SECONDS)) + { + throw new IllegalStateException("Fixture deletion was not released"); + } + } + catch(InterruptedException exception) + { + Thread.currentThread().interrupt(); + throw new IllegalStateException(exception); + } + super.delete(table, reference); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java index a0947b7..cc49aee 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java @@ -59,6 +59,174 @@ import static org.junit.jupiter.api.Assertions.assertTrue; *******************************************************************************/ class PrintingProcessesTest { + /*************************************************************************** + ** Standalone enrollment has a named default instead of accepting arbitrary raw IDs. + ***************************************************************************/ + @Test + void standaloneFacilitySelectorRejectsForgedValues() throws Exception + { + Fixture fixture = fixture(); + QFieldMetaData field = ((QFrontendStepMetaData) fixture.instance().getProcess("printingEnrollDevice").getStep("details")).getFormField("facilityId"); + assertEquals("printing_facility", field.getPossibleValueSourceName()); + assertEquals("default", field.getDefaultValue()); + assertEquals(field.getPossibleValueSourceName(), fixture.instance().getTable(PrintingTables.DEVICE).getField("facility_id").getPossibleValueSourceName()); + var choices = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(field.getPossibleValueSourceName())); + assertEquals(1, choices.getResults().size()); + assertEquals("Default facility", choices.getResults().getFirst().getLabel()); + assertEquals("default", PrintingFacilities.validate("default")); + RunBackendStepInput forged = new RunBackendStepInput().withProcessName("printingEnrollDevice") + .addValue("facilityId", "invented-id").addValue("deviceName", "station"); + assertThrows(QException.class, () -> new PrintingProcesses().run(forged, new RunBackendStepOutput())); + assertThrows(QException.class, () -> PrintingFacilities.validate(null)); + assertThrows(QException.class, () -> PrintingFacilities.validate("")); + assertThrows(QException.class, () -> PrintingFacilities.register(fixture.instance(), new PrintingQBitConfig().withFacilityPossibleValueSourceName("missing_source"))); + } + + + + /*************************************************************************** + ** A forged selection cannot bypass the host source's real tenant record lock. + ***************************************************************************/ + @Test + void configuredFacilitySelectorHonorsRecordSecurityAtEnrollment() throws Exception + { + Fixture fixture = fixture("host_facilities"); + assertEquals("host_facilities", PrintingFacilities.field(fixture.instance()).getPossibleValueSourceName()); + assertEquals("host_facilities", fixture.instance().getTable(PrintingTables.DEVICE).getField("facility_id").getPossibleValueSourceName()); + assertEquals(fixture.device(), PrintingFacilities.validate(fixture.device())); + RunBackendStepInput input = new RunBackendStepInput().withProcessName("printingEnrollDevice") + .addValue("facilityId", fixture.foreignDevice()).addValue("deviceName", "station"); + RunBackendStepOutput prepared = new RunBackendStepOutput(); + new PrintingProcesses.PrepareEnrollment().run(input, prepared); + input.addValue("printingEnrollmentCode", prepared.getValueString("printingEnrollmentCode")) + .addValue("printingEnrollmentExpires", prepared.getValueString("printingEnrollmentExpires")); + assertThrows(QException.class, () -> new PrintingProcesses().run(input, new RunBackendStepOutput())); + input.addValue("facilityId", fixture.device()); + RunBackendStepOutput accepted = new RunBackendStepOutput(); + new PrintingProcesses().run(input, accepted); + assertTrue(accepted.getValueString("result").startsWith("Enrollment code:")); + } + + + + /*************************************************************************** + ** The production QQQ host extension point mounts routes with host auth. + ***************************************************************************/ + @Test + void productionApplicationHostRegistersPrintingRoutesAndPreservesAdminGate() throws Exception + { + Fixture fixture = fixture(); + var application = new com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication() + { + @Override + public QInstance defineQInstance() + { + return (fixture.instance()); + } + }; + var javalin = new java.util.concurrent.atomic.AtomicReference(); + var server = new com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer(application) + .withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false) + .withJavalinConfigCustomizer(config -> config.jetty.host = "127.0.0.1") + .withJavalinConfigurationCustomizer(javalin::set) + .withAdditionalRouteProviders(List.of(new PrintingRoutes(fixture.service(), context -> "allowed".equals(context.header("X-Test-Admin"))))); + try(java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient()) + { + server.start(); + java.net.URI url = java.net.URI.create("http://127.0.0.1:" + javalin.get().port() + "/printing/v1/admin/devices"); + assertEquals(403, client.send(java.net.http.HttpRequest.newBuilder(url).GET().build(), java.net.http.HttpResponse.BodyHandlers.discarding()).statusCode()); + var response = client.send(java.net.http.HttpRequest.newBuilder(url).header("X-Test-Admin", "allowed").GET().build(), java.net.http.HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertEquals(2, new JSONObject(response.body()).getJSONArray("devices").length()); + assertFalse(response.body().contains("token_hash")); + } + finally + { + server.stop(); + } + } + + + + /*************************************************************************** + ** Real QQQ pause/resume phases review selections before any job is queued. + ***************************************************************************/ + @Test + void selectedTestPausesForProfileAndReviewThenSubmitsOnce() throws Exception + { + Fixture fixture = fixture(); + String process = "printingTestSelectedPdf"; + var profile = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withValue("recordIds", fixture.printerId())); + assertFalse(profile.getException().isPresent(), () -> profile.getException().toString()); + assertEquals("testProfile", profile.getProcessState().getNextStepName().orElseThrow()); + assertEquals(0, fixture.service().jobs().length()); + var review = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(profile.getProcessUUID()) + .withStartAfterStep("testProfile").withValue("testProfile", "LETTER")); + assertFalse(review.getException().isPresent(), () -> review.getException().toString()); + assertTrue(review.getProcessState().getNextStepName().isPresent()); + assertEquals(0, fixture.service().jobs().length()); + var submitted = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(review.getProcessUUID()) + .withStartAfterStep(review.getProcessState().getNextStepName().orElseThrow()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)); + assertFalse(submitted.getException().isPresent(), () -> submitted.getException().toString()); + assertEquals(1, fixture.service().jobs().length()); + } + + + + /*************************************************************************** + ** Enrollment replay cannot issue extra codes or reactivate consumed ones. + ***************************************************************************/ + @Test + void nativeEnrollmentReplayRetainsCodeAndNeverReactivatesConsumedReceipt() throws Exception + { + Fixture fixture = fixture(); + RunBackendStepInput input = new RunBackendStepInput().withProcessName("printingEnrollDevice") + .addValue("facilityId", "default").addValue("deviceName", "station"); + RunBackendStepOutput prepared = new RunBackendStepOutput(); + new PrintingProcesses.PrepareEnrollment().run(input, prepared); + input.addValue("printingEnrollmentCode", prepared.getValueString("printingEnrollmentCode")) + .addValue("printingEnrollmentExpires", prepared.getValueString("printingEnrollmentExpires")); + RunBackendStepOutput first = new RunBackendStepOutput(); + RunBackendStepOutput second = new RunBackendStepOutput(); + new PrintingProcesses().run(input, first); + new PrintingProcesses().run(input, second); + assertEquals(first.getValueString("result"), second.getValueString("result")); + JSONObject enrollment = new JSONObject().put("enrollmentCode", prepared.getValueString("printingEnrollmentCode")).put("deviceName", "station").put("agentVersion", "test"); + fixture.service().enroll(enrollment); + assertThrows(QException.class, () -> new PrintingProcesses().run(input, new RunBackendStepOutput())); + assertThrows(PrintingException.class, () -> fixture.service().enroll(enrollment)); + input.addValue("printingEnrollmentExpires", java.time.Instant.now().minusSeconds(1).toString()); + assertThrows(QException.class, () -> new PrintingProcesses().run(input, new RunBackendStepOutput())); + } + + + + /*************************************************************************** + ** A lost load response must not duplicate a physical test on process replay. + ***************************************************************************/ + @Test + void testActionsDeduplicateSingleAndSelectedLoadReplay() throws Exception + { + Fixture fixture = fixture(); + RunBackendStepInput single = new RunBackendStepInput().withProcessName("printingTestPdf") + .addValue("deviceId", fixture.device()).addValue("printerId", fixture.printerId()).addValue("printingTestRequestId", "single-replay"); + for(int attempt = 0; attempt < 2; attempt++) + { + new PrintingProcesses().run(single, new RunBackendStepOutput()); + } + assertEquals(1, fixture.service().jobs().length()); + RunBackendStepInput selected = new RunBackendStepInput().withProcessName("printingTestSelectedPdf") + .withRecords(List.of(new QRecord().withValue("id", fixture.printerId()), new QRecord().withValue("id", fixture.otherPrinterId()))) + .addValue("printingTestRequestId", "selected-replay"); + for(int attempt = 0; attempt < 2; attempt++) + { + new PrintingProcesses.SelectedLoad().runOnePage(selected, new RunBackendStepOutput()); + } + assertEquals(3, fixture.service().jobs().length()); + } + + + /*************************************************************************** ** Native actions and device requests use identical bundled label documents. ***************************************************************************/ @@ -96,7 +264,8 @@ class PrintingProcessesTest QFrontendStepMetaData single = (QFrontendStepMetaData) fixture.instance().getProcess("printingTestZpl").getStep("details"); assertEquals("printing_testProfileZpl", single.getFormField("testProfile").getPossibleValueSourceName()); var selected = fixture.instance().getProcess("printingTestSelectedPdf"); - assertEquals("testProfile", selected.getStepList().getFirst().getName()); + assertEquals("prepareTest", selected.getStepList().getFirst().getName()); + assertEquals("testProfile", selected.getStepList().get(1).getName()); QFrontendStepMetaData form = (QFrontendStepMetaData) selected.getStep("testProfile"); assertEquals("printing_testProfilePdf", form.getFormField("testProfile").getPossibleValueSourceName()); assertEquals(3, fixture.instance().getPossibleValueSource("printing_testProfileZpl").getEnumValues().size()); @@ -159,6 +328,9 @@ class PrintingProcessesTest assertFalse(result.getException().isPresent(), () -> result.getException().toString()); assertTrue(fixture.service().claim(fixture.device()).isNull("job")); assertTrue(fixture.service().claim(fixture.otherDevice()).isNull("job")); + var replay = new RunProcessAction().execute(new RunProcessInput().withProcessName(PrintingJobProcesses.CANCEL) + .withValue("recordIds", first + "," + second).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)); + assertFalse(replay.getException().isPresent(), () -> replay.getException().toString()); String claimed = PrintingProcesses.submitTest(PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()), true); fixture.service().claim(fixture.device()); RunBackendStepInput selection = new RunBackendStepInput().withProcessName(PrintingJobProcesses.CANCEL) @@ -282,7 +454,7 @@ class PrintingProcessesTest void enrollmentRejectsMissingAndWrongTenantDespiteAdminPermission() throws Exception { fixture(); - RunBackendStepInput input = new RunBackendStepInput().withProcessName("printingEnrollDevice").addValue("facilityId", "warehouse").addValue("deviceName", "station"); + RunBackendStepInput input = new RunBackendStepInput().withProcessName("printingEnrollDevice").addValue("facilityId", "default").addValue("deviceName", "station"); QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("tenant-b"))); assertThrows(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class, () -> new PrintingProcesses().run(input, new RunBackendStepOutput())); QContext.getQSession().setSecurityKeyValues(Map.of()); @@ -295,6 +467,16 @@ class PrintingProcessesTest ** One database, two tenants and multiple devices exercise real query scoping. ***************************************************************************/ private static Fixture fixture() throws Exception + { + return (fixture(null)); + } + + + + /*************************************************************************** + ** An optional host facility source reuses real tenant-scoped device rows as fixtures. + ***************************************************************************/ + private static Fixture fixture(String facilitySource) throws Exception { String database = "printing_process_" + UUID.randomUUID(); String jdbc = "jdbc:h2:mem:" + database + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE"; @@ -312,7 +494,12 @@ class PrintingProcessesTest QInstance instance = new QInstance(); instance.addBackend(new RDBMSBackendMetaData().withName(backendName).withVendor("h2").withHostName("mem").withDatabaseName(database).withJdbcUrl(jdbc).withUsername("sa").withPassword("")); instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.MOCK)); - new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backendName).withService(service)).produce(instance, "printing"); + if(facilitySource != null) + { + instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.DEVICE).withName(facilitySource)); + } + new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backendName).withService(service) + .withFacilityPossibleValueSourceName(facilitySource)).produce(instance, "printing"); QContext.init(instance, new QSession().withUser(new QUser().withIdReference("operator")).withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-a")); QueryInput query = new QueryInput(); query.setTableName(PrintingTables.PRINTER); @@ -329,7 +516,7 @@ class PrintingProcessesTest foreignId = result.getString(1); } } - return (new Fixture(instance, service, device, otherDevice, printerId, otherPrinterId, foreignId)); + return (new Fixture(instance, service, device, otherDevice, printerId, otherPrinterId, foreignId, foreign)); } @@ -358,7 +545,7 @@ class PrintingProcessesTest /*************************************************************************** ** Per-test handles, without exposed device tokens. ***************************************************************************/ - private record Fixture(QInstance instance, PrintingService service, String device, String otherDevice, String printerId, String otherPrinterId, String foreignPrinterId) + private record Fixture(QInstance instance, PrintingService service, String device, String otherDevice, String printerId, String otherPrinterId, String foreignPrinterId, String foreignDevice) { } } diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRetentionProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRetentionProcessesTest.java new file mode 100644 index 0000000..8ff5e9f --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRetentionProcessesTest.java @@ -0,0 +1,188 @@ +/* + * 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.util.List; +import java.util.Map; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Native retention forms enforce administrator and tenant scope independently. + *******************************************************************************/ +class PrintingRetentionProcessesTest +{ + /*************************************************************************** + ** Opening native cleanup is read-only until the operator continues past review. + ***************************************************************************/ + @Test + void nativeCleanupPausesForReviewBeforeRunningMaintenance() throws Exception + { + PrintingService service = fixture(); + var preview = new RunProcessAction().execute(new RunProcessInput().withProcessName(PrintingRetentionProcesses.RUN_NOW)); + assertFalse(preview.getException().isPresent(), () -> preview.getException().toString()); + assertEquals("review", preview.getProcessState().getNextStepName().orElseThrow()); + assertTrue(service.getRetentionSettings().optString("lastRunAt").isBlank()); + var completed = new RunProcessAction().execute(new RunProcessInput().withProcessName(PrintingRetentionProcesses.RUN_NOW) + .withProcessUUID(preview.getProcessUUID()).withStartAfterStep("review").withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)); + assertFalse(completed.getException().isPresent(), () -> completed.getException().toString()); + assertFalse(service.getRetentionSettings().optString("lastRunAt").isBlank()); + } + + + + /*************************************************************************** + ** Registering a QBit never starts maintenance without a host scheduler binding. + ***************************************************************************/ + @Test + void scheduleRequiresExplicitHostBindingAndUsesBoundedFiveMinuteCadence() + { + QInstance manual = new QInstance(); + PrintingRetentionProcesses.register(manual, new PrintingQBitConfig()); + assertNotNull(manual.getProcess(PrintingRetentionProcesses.SETTINGS)); + assertNotNull(manual.getProcess(PrintingRetentionProcesses.RUN_NOW)); + assertNull(manual.getProcess(PrintingRetentionProcesses.SCHEDULED)); + QInstance scheduled = new QInstance(); + PrintingRetentionProcesses.register(scheduled, new PrintingQBitConfig().withSchedulerName("host-scheduler")); + var schedule = scheduled.getProcess(PrintingRetentionProcesses.SCHEDULED).getSchedule(); + assertEquals("host-scheduler", schedule.getSchedulerName()); + assertEquals("0 */5 * * * ?", schedule.getCronExpression()); + assertEquals("UTC", schedule.getCronTimeZoneId()); + assertTrue(scheduled.getProcess(PrintingRetentionProcesses.SCHEDULED).getIsHidden()); + } + + + + /*************************************************************************** + ** Backend direct invocation cannot bypass either permission or tenant checks. + ***************************************************************************/ + @Test + void settingsEnforceAdminTenantAndValidationAndReadPersistedPolicy() throws Exception + { + PrintingService service = fixture(); + PrintingRetentionProcesses step = new PrintingRetentionProcesses(); + RunBackendStepInput save = input(PrintingRetentionProcesses.SETTINGS, "execute").addValue("days", 730).addValue("enabled", false); + QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("foreign"))); + assertThrows(QPermissionDeniedException.class, () -> step.run(save, new RunBackendStepOutput())); + QContext.init(QContext.getQInstance(), new QSession().withSecurityKeyValue("printing_tenant", "tenant-a")); + assertThrows(QPermissionDeniedException.class, () -> step.run(save, new RunBackendStepOutput())); + QContext.init(QContext.getQInstance(), new QSession().withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-a")); + step.run(save, new RunBackendStepOutput()); + assertEquals(730, service.getRetentionSettings().getInt("days")); + assertFalse(service.getRetentionSettings().getBoolean("enabled")); + RunBackendStepOutput prepared = new RunBackendStepOutput(); + step.run(input(PrintingRetentionProcesses.SETTINGS, "prepare"), prepared); + assertEquals(730, prepared.getValueInteger("days")); + assertEquals(false, prepared.getValueBoolean("enabled")); + assertTrue(prepared.getValueString("storage").contains("not configured")); + assertTrue(prepared.getValueString("schedule").contains("Not scheduled")); + assertThrows(QException.class, () -> step.run(input(PrintingRetentionProcesses.SETTINGS, "execute").addValue("days", 0).addValue("enabled", true), new RunBackendStepOutput())); + assertEquals(730, service.getRetentionSettings().getInt("days")); + } + + + + /*************************************************************************** + ** Only a true system session can call the hidden scheduled entrypoint. + ***************************************************************************/ + @Test + void scheduledEntryRejectsHumanAdminAndUsesInstanceBoundSystemService() throws Exception + { + fixture(); + PrintingRetentionProcesses step = new PrintingRetentionProcesses(); + RunBackendStepInput scheduled = input(PrintingRetentionProcesses.SCHEDULED, "execute"); + assertThrows(QPermissionDeniedException.class, () -> step.run(scheduled, new RunBackendStepOutput())); + QContext.init(QContext.getQInstance(), new QSystemUserSession(QContext.getQInstance())); + RunBackendStepOutput result = new RunBackendStepOutput(); + step.run(scheduled, result); + assertTrue(result.getValueString("result").contains("0 eligible")); + } + + + + /*************************************************************************** + ** Each test owns an isolated database and tenant-specific instance binding. + ***************************************************************************/ + private static PrintingService fixture() throws Exception + { + DriverDataSource source = new DriverDataSource("jdbc:h2:mem:retention-ui-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + PrintingService service = new PrintingService(source, "tenant-a"); + service.initialize(); + QInstance instance = new QInstance(); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule.class)); + instance.addTable(new QTableMetaData().withName("fixture").withBackendName("memory").withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.STRING))); + PrintingQBitConfig config = new PrintingQBitConfig().withService(service); + instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion("test").withNamespace("printing").withConfig(config)); + PrintingRetentionProcesses.register(instance, config); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.MOCK)); + QContext.init(instance, new QSession().withUser(new QUser().withIdReference("operator")).withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-a")); + return (service); + } + + + + /*************************************************************************** + ** Explicit step names prevent a forged form from selecting another operation. + ***************************************************************************/ + private static RunBackendStepInput input(String process, String step) + { + RunBackendStepInput input = new RunBackendStepInput().withProcessName(process); + input.setStepName(step); + return (input); + } + + + + /*************************************************************************** + ** Thread-local context must never carry tenant authorization between tests. + ***************************************************************************/ + @AfterEach + void cleanup() + { + QContext.clear(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingServiceTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingServiceTest.java index c3034a3..baca170 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingServiceTest.java @@ -44,6 +44,97 @@ import static org.junit.jupiter.api.Assertions.assertTrue; *******************************************************************************/ class PrintingServiceTest { + /*************************************************************************** + ** Protocol maximum event fields persist; malformed/oversized values fail400. + ***************************************************************************/ + @Test + void eventBoundsMatchProtocolAndInventoryRejectsUnsupportedEnums() throws Exception + { + PrintingService service = new PrintingService(source(), "tenant-a"); + service.initialize(); + String device = enroll(service); + configure(service, device); + String job = service.submit(request(device)).getString("jobId"); + service.claim(device); + for(JSONObject bad : List.of(new JSONObject().put("message", "m".repeat(2049)), new JSONObject().put("spoolerJobId", "s".repeat(129)), new JSONObject().put("message", JSONObject.NULL))) + { + assertEquals(400, assertThrows(PrintingException.class, () -> service.event(device, job, bad.put("eventId", "invalid").put("status", "RECEIVED"))).getStatus()); + } + service.event(device, job, new JSONObject().put("eventId", "e".repeat(128)).put("status", "SPOOLED").put("message", "m".repeat(2048)).put("spoolerJobId", "s".repeat(128))); + assertEquals(2048, service.getJob(job).getString("message").length()); + assertEquals(128, service.getJob(job).getString("spoolerJobId").length()); + for(JSONArray formats : List.of(new JSONArray(), new JSONArray(List.of("RAW")), new JSONArray(List.of("PDF", "PDF")))) + { + assertEquals(400, assertThrows(PrintingException.class, () -> service.inventory(device, new JSONArray().put(inventory().getJSONObject(0).put("formats", formats)))).getStatus()); + } + assertTrue(service.printers(device).getJSONObject(0).getBoolean("available")); + } + + + + /*************************************************************************** + ** Changing a driver's capabilities must block previously queued formats. + ***************************************************************************/ + @Test + void claimRechecksCurrentPrinterFormat() throws Exception + { + PrintingService service = new PrintingService(source(), "tenant-a"); + service.initialize(); + String device = enroll(service); + configure(service, device); + String job = service.submit(request(device)).getString("jobId"); + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "zebra").put("name", "New driver") + .put("formats", new JSONArray(List.of("PDF"))))); + assertTrue(service.claim(device).isNull("job")); + assertEquals("QUEUED", service.getJob(job).getString("status")); + service.inventory(device, inventory()); + assertEquals(job, service.claim(device).getJSONObject("job").getString("jobId")); + } + + + + /*************************************************************************** + ** Invalid JSON option types must not silently change the requested output. + ***************************************************************************/ + @Test + void malformedPrintOptionsAreRejectedBeforeQueueing() throws Exception + { + PrintingService service = new PrintingService(source(), "tenant-a"); + service.initialize(); + String device = enroll(service); + configure(service, device); + for(Object options : List.of("invalid", new JSONArray(), JSONObject.NULL, + new JSONObject().put("copies", 1.5), new JSONObject().put("copies", "2"), new JSONObject().put("copies", JSONObject.NULL))) + { + assertEquals(400, assertThrows(PrintingException.class, () -> service.submit(request(device).put("options", options))).getStatus()); + } + assertEquals(0, service.jobs().length()); + } + + + + /*************************************************************************** + ** A failed status cannot erase evidence that a job may already have printed. + ***************************************************************************/ + @Test + void failureAfterSubmissionStillRequiresDuplicateConsent() throws Exception + { + PrintingService service = new PrintingService(source(), "tenant-a"); + service.initialize(); + String device = enroll(service); + configure(service, device); + String id = service.submit(request(device)).getString("jobId"); + service.claim(device); + service.event(device, id, new JSONObject().put("eventId", "spooled-before-failure").put("status", "SPOOLED").put("spoolerJobId", "42")); + service.event(device, id, new JSONObject().put("eventId", "later-failure").put("status", "FAILED")); + assertEquals(409, assertThrows(PrintingException.class, () -> service.retry(device, id, new JSONObject().put("requestId", "retry"))).getStatus()); + assertEquals(1, service.jobs().length()); + service.retry(device, id, new JSONObject().put("requestId", "retry").put("confirmDuplicateRisk", true)); + assertEquals(2, service.jobs().length()); + } + + + /*************************************************************************** ** An independently reconstructed runtime reads durable jobs and credentials. ***************************************************************************/ @@ -172,7 +263,12 @@ class PrintingServiceTest assertThrows(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class, () -> new PrintingReadPermission().checkPermissionsThrowing(new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(), instance.getTable(PrintingTables.DEVICE))); RunBackendStepOutput enrollment = new RunBackendStepOutput(); - new PrintingProcesses().run(new RunBackendStepInput().withProcessName("printingEnrollDevice").addValue("facilityId", "warehouse").addValue("deviceName", "station"), enrollment); + RunBackendStepInput enrollmentInput = new RunBackendStepInput().withProcessName("printingEnrollDevice").addValue("facilityId", "default").addValue("deviceName", "station"); + RunBackendStepOutput enrollmentPrepared = new RunBackendStepOutput(); + new PrintingProcesses.PrepareEnrollment().run(enrollmentInput, enrollmentPrepared); + enrollmentInput.addValue("printingEnrollmentCode", enrollmentPrepared.getValueString("printingEnrollmentCode")) + .addValue("printingEnrollmentExpires", enrollmentPrepared.getValueString("printingEnrollmentExpires")); + new PrintingProcesses().run(enrollmentInput, enrollment); assertTrue(enrollment.getValueString("result").startsWith("Enrollment code:")); String device = enroll(service); service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Laser").put("formats", new JSONArray(List.of("PDF"))))); @@ -181,7 +277,11 @@ class PrintingServiceTest query.setTableName(PrintingTables.PRINTER); String printerRow = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query).getRecords().getFirst().getValueString("id"); RunBackendStepOutput result = new RunBackendStepOutput(); - new PrintingProcesses().run(new RunBackendStepInput().withProcessName("printingTestPdf").addValue("deviceId", device).addValue("printerId", printerRow), result); + RunBackendStepInput printInput = new RunBackendStepInput().withProcessName("printingTestPdf").addValue("deviceId", device).addValue("printerId", printerRow); + RunBackendStepOutput prepared = new RunBackendStepOutput(); + new PrintingProcesses.PrepareTest().run(printInput, prepared); + printInput.addValue("printingTestRequestId", prepared.getValueString("printingTestRequestId")); + new PrintingProcesses().run(printInput, result); assertTrue(result.getValueString("result").startsWith("Queued print job")); JSONObject job = service.claim(device).getJSONObject("job"); assertEquals("PDF", job.getString("format")); -- 2.54.0 From 0028bfdfdcf03e3f4156bdee2d599149ae1018d9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 19:11:35 -0500 Subject: [PATCH 06/35] ci: reject missing security scan evidence Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- .github/scripts/check-codeql-sarif.py | 53 +++++++++++ .github/scripts/test-scan-gates.py | 89 ++++++++++++++++++ .github/workflows/codeql-analysis.yml | 125 ++++++++++++++++++++++++++ .github/workflows/codeql.yml | 2 +- 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/check-codeql-sarif.py create mode 100644 .github/scripts/test-scan-gates.py create mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/scripts/check-codeql-sarif.py b/.github/scripts/check-codeql-sarif.py new file mode 100644 index 0000000..cf44763 --- /dev/null +++ b/.github/scripts/check-codeql-sarif.py @@ -0,0 +1,53 @@ +"""Reject missing/malformed CodeQL evidence before enforcing the severity gate.""" +import json +import math +import os +from pathlib import Path + + +def check(directory, language, threshold): + if not math.isfinite(threshold) or not 0 <= threshold <= 10: + raise ValueError("Invalid security severity threshold") + canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language) + files = sorted(Path(directory).glob("*.sarif")) + if not canonical or not any(path.name == canonical + ".sarif" for path in files): + raise ValueError("Missing expected CodeQL SARIF report for " + language) + blocking = [] + for path in files: + report = json.loads(path.read_text(encoding="utf-8")) + runs = report.get("runs") + if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs: + raise ValueError("Invalid or empty SARIF runs: " + path.name) + for run in runs: + driver = run.get("tool", {}).get("driver", {}) + results = run.get("results") + if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list): + raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name) + rules = {} + for component in [driver, *run.get("tool", {}).get("extensions", [])]: + for rule in component.get("rules", []): + rules[rule["id"]] = rule + if not rules: + raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name) + for invocation in run.get("invocations", []): + if invocation.get("executionSuccessful") is False: + raise ValueError("CodeQL invocation failed: " + path.name) + for result in results: + rule_id = result.get("ruleId") or result.get("rule", {}).get("id") + if rule_id not in rules: + raise ValueError("Result references missing rule metadata: " + path.name) + if result.get("suppressions"): + continue + severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0) + if not math.isfinite(severity) or not 0 <= severity <= 10: + raise ValueError("Invalid rule severity: " + rule_id) + if severity >= threshold: + blocking.append((severity, rule_id)) + for severity, rule_id in sorted(blocking, reverse=True): + print(f"::error title=CodeQL security finding::{rule_id} ({severity})") + print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)") + return bool(blocking) + + +if __name__ == "__main__": + raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0) diff --git a/.github/scripts/test-scan-gates.py b/.github/scripts/test-scan-gates.py new file mode 100644 index 0000000..d9356b2 --- /dev/null +++ b/.github/scripts/test-scan-gates.py @@ -0,0 +1,89 @@ +"""Exercise the exact CI guards using synthetic evidence only.""" +import contextlib +import copy +import io +import importlib.util +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +sys.dont_write_bytecode = True + + +def load(name): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +codeql = load("check-codeql-sarif") + + +class SarifTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + self.report = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]} + + def save(self, report): + (self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8") + + def test_clean_report_with_zero_findings_passes(self): + self.save(self.report) + self.assertFalse(codeql.check(self.directory, "java-kotlin", 7)) + + def test_missing_report_or_wrong_language_fails(self): + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + self.save(self.report) + with self.assertRaises(ValueError): + codeql.check(self.directory, "csharp", 7) + + def test_empty_or_malformed_report_fails(self): + for text in ["", "{", "null", "[]"]: + with self.subTest(text=text): + (self.directory / "java.sarif").write_text(text) + with self.assertRaises((ValueError, AttributeError)): + codeql.check(self.directory, "java-kotlin", 7) + + def test_missing_runs_tool_rules_or_results_fails(self): + variants = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}] + for field in ["tool", "results"]: + bad = copy.deepcopy(self.report) + del bad["runs"][0][field] + variants.append(bad) + bad = copy.deepcopy(self.report) + bad["runs"][0]["tool"]["driver"]["rules"] = [] + variants.append(bad) + for bad in variants: + with self.subTest(report=bad): + self.save(bad) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + + def test_high_finding_blocks_and_unknown_rule_fails(self): + self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}] + self.save(self.report) + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(codeql.check(self.directory, "java-kotlin", 7)) + self.report["runs"][0]["results"] = [{"ruleId": "missing"}] + self.save(self.report) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + + def test_failed_invocation_and_invalid_threshold_fail(self): + self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}] + self.save(self.report) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", 7) + with self.assertRaises(ValueError): + codeql.check(self.directory, "java-kotlin", float("nan")) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..e22af6e --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,125 @@ +# Local copy of KofTwentyTwo/standards at fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2). +# Pins, inputs and analysis checks preserved; report validation now fails closed. +# Static analysis (K22-CI-22): CodeQL with the security-extended suite, failing on +# alerts of high severity or above. Called as: +# +# jobs: +# codeql: +# uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@ # vX.Y.Z +# permissions: +# contents: read +# security-events: write +# with: +# languages: '["csharp", "actions"]' +# build-mode: manual +# build-command: dotnet build MySolution.slnx -p:Platform=x64 +# runs-on: windows-latest +# +# producing `codeql / analyze ()` for each language. The CodeQL action +# uploads results but never fails a build on them, so the last step reads the SARIF +# and fails on any result whose rule has security-severity >= the threshold. +# Suppress a false positive with a query filter in a CodeQL config file passed as +# `config-file`, citing the exception record in a comment. +name: codeql + +on: + workflow_call: + inputs: + languages: + description: JSON array of CodeQL languages, e.g. '["csharp","actions"]'. + type: string + required: true + build-mode: + description: CodeQL build mode for compiled languages (none, autobuild, manual). Interpreted languages always use none. + type: string + default: autobuild + build-command: + description: Command run for build-mode manual. + type: string + default: "" + runs-on: + description: Runner label; Windows-only builds (WinUI, WPF) need windows-latest. + type: string + default: ubuntu-24.04 + setup-dotnet: + description: Install the .NET SDK from global.json before building. + type: boolean + default: false + config-file: + description: Optional path to a CodeQL configuration file in the calling repository. + type: string + default: "" + fail-on-security-severity: + description: Minimum CVSS-style security-severity that fails the job (7.0 = high). + type: string + default: "7.0" + +permissions: {} + +jobs: + analyze: + name: analyze (${{ matrix.language }}) + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 60 + permissions: + contents: read + security-events: write # upload SARIF results to code scanning + strategy: + fail-fast: false + matrix: + language: ${{ fromJSON(inputs.languages) }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Test scan guard rejection paths + shell: python + run: | + import runpy + runpy.run_path(".github/scripts/test-scan-gates.py", run_name="__main__") + + - name: Set up .NET SDK + if: inputs.setup-dotnet + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + + - name: Initialize CodeQL + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ contains(fromJSON('["actions","javascript-typescript","python","ruby"]'), matrix.language) && 'none' || inputs.build-mode }} + queries: security-extended + config-file: ${{ inputs.config-file }} + + - name: Build (manual mode) + if: inputs.build-mode == 'manual' && !contains(fromJSON('["actions","javascript-typescript","python","ruby"]'), matrix.language) + shell: bash + env: + BUILD_COMMAND: ${{ inputs.build-command }} + run: | + if [[ -z "$BUILD_COMMAND" ]]; then + echo "::error::build-mode is manual but build-command is empty" + exit 1 + fi + eval "$BUILD_COMMAND" + + - name: Analyze + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + category: /language:${{ matrix.language }} + output: codeql-sarif + # Fork pull requests have a read-only token; analyze still runs and gates. + upload: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'never' || 'always' }} + + - name: Gate on security severity + shell: python + env: + SARIF_DIR: codeql-sarif + THRESHOLD: ${{ inputs.fail-on-security-severity }} + EXPECTED_LANGUAGE: ${{ matrix.language }} + run: | + import runpy + runpy.run_path(".github/scripts/check-codeql-sarif.py", run_name="__main__") diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 07515fd..bc38661 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,7 +9,7 @@ on: permissions: {} jobs: codeql: - uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 + uses: ./.github/workflows/codeql-analysis.yml permissions: contents: read security-events: write -- 2.54.0 From aeac1a68425d81bd562f2f7bcb3e8d840e41aae5 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 19:37:30 -0500 Subject: [PATCH 07/35] fix: bound print resource admission and inventory history Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 3 +- docs/AUDIT-2026-10-30.md | 8 +- docs/OPERATIONS.md | 20 + docs/SESSION-STATE.md | 12 +- docs/TODO.md | 3 + .../qbits/printing/PrintingAdmission.java | 289 +++++++++ .../printing/PrintingAdmissionLimits.java | 54 ++ .../qbits/printing/PrintingQBitConfig.java | 22 + .../qbits/printing/PrintingQBitProducer.java | 2 +- .../qbits/printing/PrintingRoutes.java | 3 +- .../qbits/printing/PrintingService.java | 216 +++++-- .../qbits/printing/PrintingAdmissionTest.java | 601 ++++++++++++++++++ 12 files changed, 1184 insertions(+), 49 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingAdmission.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingAdmissionLimits.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingAdmissionTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f6d39..14f9e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ - Add admin retention settings with a 365-day default, reviewed cleanup and bounded scheduled maintenance; preserve job history and uncertain work. - Use named facility choices during enrollment and revalidate them under the current user's tenant/record permissions. - Require release security/CodeQL gates, verified artifact promotion and build provenance; report the real packaged QBit version. -- Full local verification passed 53 tests with zero failures/errors and Checkstyle violations. Hosted checks remain pending; this entry does not claim release or live storage migration. +- Enforce configurable atomic queue/storage/rate budgets and cumulative printer limits, with bounded management pagination and stable replay at capacity. +- Full local verification passed 62 tests with zero failures/errors/skips and Checkstyle violations. Final hosted checks remain pending; this entry does not claim release or live storage migration. ## 0.1.3-rc.1 — 2026-10-30 diff --git a/docs/AUDIT-2026-10-30.md b/docs/AUDIT-2026-10-30.md index 0a84e80..8fae253 100644 --- a/docs/AUDIT-2026-10-30.md +++ b/docs/AUDIT-2026-10-30.md @@ -1,6 +1,6 @@ # Server printing audit -Target: 0.1.4-rc.1. Local implementation verification passed **53 tests, zero failures/errors and zero Checkstyle violations**. Hosted verification and the independent baseline security scan remain pending. No live database, object storage, enrollment or printer was changed. +Target: 0.1.4-rc.1. Final local implementation verification passed **62 tests, zero failures/errors/skips and zero Checkstyle violations**. The independent baseline scan and source remediation review are complete. Earlier audit and CI guard commits passed hosted checks; final remediation commits require fresh verification. No live database, object storage, enrollment or printer was changed. ## Corrected behavior @@ -12,6 +12,8 @@ Target: 0.1.4-rc.1. Local implementation verification passed **53 tests, zero fa - Durable upload intents and exact orphan cleanup account for failed uploads/queue commits. Cleanup commits each confirmed document removal independently. A renewable lease prevents normal overlapping maintenance; all work reuses a single connection and passes real one-/two-connection pool concurrency regressions. - Native administration exposes the default 365-day policy, read-only storage scope, backlog and last cleanup result. A host scheduler runs bounded batches every five minutes. Age starts at server-observed successful completion; active, failed, uncertain and unknown-completion jobs remain protected. - Release publication requires build/security/CodeQL checks, verified artifact promotion and build provenance. Packaged QBit metadata uses the JAR's real Implementation-Version. +- Shared submission admission enforces configurable atomic device/tenant limits for outstanding work, retained bytes and hourly allocations. Durable uploads remain charged until confirmed cleanup; matching receipts replay even at capacity. Legacy unknown uploads conservatively reserve 20 MiB. +- Cumulative printer limits prevent rotating identities from growing history indefinitely. Management pages remain bounded, with optional opaque cursors; approval, metadata and job references survive rejected snapshots. A narrow index avoids PostgreSQL limits on wide Unicode names. ## Evidence and migration @@ -19,8 +21,10 @@ The gate exercises real H2 persistence and bounded pools, QQQ StorageAction with Schema initialization adds consumed-enrollment receipts, storage references/content availability, completion timestamps, retention settings/lease and upload intents. Existing inline bytes are retained; completion dates are backfilled only from durable successful events. No automatic inline-to-S3 copy or live purge occurs. Schema changes should be qualified against a backed-up deployment database before rollout. +Admission accounting also backfills retained bytes and recent allocations. Stop older writers before this migration; mixed-version writers bypass accounting and are unsupported. Regression cases cover concurrent tenant admissions, retained terminal payloads, replay at capacity, failed cleanup, unknown legacy uploads, rotating inventory and long-name pagination. See [operations](OPERATIONS.md) for configurable limits and recovery. + ## Remaining boundaries Live S3 endpoints, IAM/bucket policies, external bucket users, PostgreSQL, multi-node lease-expiry behavior, production scheduler execution, remote TLS deployment and sustained 10,000-print/day throughput are not qualified by these tests. SQL and object deletion are not one transaction; crashes between them require idempotent recovery. S3 versioning and unfinished multipart parts have separate lifecycle semantics. See [storage and retention](STORAGE-AND-RETENTION.md). -The independent deep scan covers the frozen pre-audit baseline, not the subsequently added storage feature. Two implementation agents independently reviewed the new storage boundaries and added regressions, but that does not substitute for a completed security scan or prove absence of defects. No numerical quality score is assigned. +The completed independent deep scan covers the frozen pre-audit baseline, not the subsequently added storage feature. Its two server findings were remediated and received a fresh independent bypass/regression review; that review's wide-index compatibility finding was corrected before the final gate. Separate review and regressions cover the new storage boundaries. These results do not prove absence of defects or qualify the live deployment boundaries above. No numerical quality score is assigned. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 00f6703..4792a4e 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -26,6 +26,26 @@ Use a stable business idempotency key for retries of the same request. A changed The pending 0.1.4 implementation stores new documents through the host's configured dedicated QQQ S3 storage table; existing inline database content remains readable. Administration defaults to 365 days after server-observed successful completion, with bounded scheduled cleanup when a host scheduler is configured. Active, failed, uncertain and unknown-completion legacy jobs remain protected. See [storage and retention](STORAGE-AND-RETENTION.md) for bucket separation, recovery and versioning details. No live storage migration or cleanup is implied by a source upgrade. +## Admission capacity and inventory history + +The host configures resource ceilings through `PrintingQBitConfig.withAdmissionLimits(PrintingAdmissionLimits)`. The producer passes these limits to every submission path, including native processes, direct Java submissions, connector test prints and explicit retries. Retention remains a separate administrator setting, defaulting to 365 days after successful completion. + +| Limit | Per device | Per tenant | +| --- | ---: | ---: | +| Outstanding jobs and upload reservations | 10,000 | 100,000 | +| Outstanding decoded document bytes | 10 GiB | 100 GiB | +| Retained decoded document bytes, including terminal jobs and upload reservations | 2 TiB | 20 TiB | +| New admissions in a rolling hour | 2,000 | 20,000 | +| Cumulative printer identities | 1,000 | 20,000 | + +`PrintingAdmissionLimits.defaults()` returns these defaults. To override them, construct the record with device/tenant pairs in this order: outstanding jobs, outstanding bytes, retained bytes, hourly admissions, cumulative printers. Values must be positive. Use the same configuration on every QQQ node serving a tenant. Size retained capacity for actual document sizes and the chosen retention period; 10,000 jobs/day is a sizing target, not a measured hardware throughput result. These are logical application byte limits, not an S3 billing limit: object versions, multipart fragments and provider overhead require their own deployment monitoring and lifecycle policy. + +Admission is serialized per tenant in SQL before storage writes. Pending and failed upload reservations stay charged until exact-object cleanup succeeds; failed admissions that reached a committed reservation also consume the hourly allowance. Legacy reservations with unknown length conservatively reserve the maximum 20 MiB each, against the tenant when their device is unknown. Existing payloads are backfilled without removal. Apply this additive accounting migration with older application writers stopped; mixed-version writers or direct SQL changes bypass accounting and are unsupported. + +HTTP 429 means capacity, the rolling-hour allowance, cumulative inventory history, or an existing upload reservation prevents a new allocation. The response explains the category. Reuse the same idempotency/request identity when retrying: a completed matching replay returns its existing job even at capacity or after the original retry source expires. Wait for outstanding work or the hourly window, investigate failed cleanup/storage access, or have the host administrator adjust a justified capacity limit. Retention only removes eligible successful documents; it does not erase failed, uncertain or active work to make space. + +Inventory snapshots remain limited to 200 entries. Rotating IDs cannot bypass cumulative history limits, and rejected snapshots leave approval, availability and job references intact. Existing IDs remain updateable when a legacy database already exceeds a limit. Management `GET /printing/v1/admin/devices/{deviceId}/printers` accepts `limit` (1–200, default 200) and opaque `before` from the preceding response's `nextCursor`. The response is `{ "printers": [...], "nextCursor": null | "..." }`, ordered by stored name and row identity. The existing Java `printers()` method returns the first bounded array; use `printersPage()` for additional history. Native QQQ tables retain their own backend paging. + ## Common failures | Symptom | Check | diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index d721877..4123da7 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,12 +1,14 @@ -# Session state — current checkpoint 2026-10-30 +# Session state — 2026-10-30 -## Deep audit in progress — pending 0.1.4-rc.1 +## Audit candidate 0.1.4-rc.1 -Final local server verification passed **53 tests, zero failures/errors and zero Checkstyle violations**, including named facility selectors and current-user tenant authorization. The JAR manifest reports 0.1.4-rc.1. See [audit evidence](AUDIT-2026-10-30.md). Root is preparing the draft PR; no merge, release or live migration. All three printing repositories now enforce required checks even for administrators, explicitly approved by the user. Master coordination is in `R:\agent-comms\qqq-cloud-printing.md`; Record Files owns originals, printing owns delivery copies. The shared immutable-version reader/async source-authorization contract remains pending. Legacy durable outbox byte payloads are outside printing's S3 retention scope. +Final isolated verification passed **62 tests, zero failures/errors/skips and zero Checkstyle violations**. The independent baseline scan's two server findings were remediated: configurable atomic tenant/device admission covers outstanding work, retained bytes and hourly allocations; cumulative printer limits and cursor pagination bound inventory. A fresh reviewer found a PostgreSQL wide-index regression, corrected before the final gate. Durable upload accounting, legacy unknown reservations, stable replay and single-connection cleanup have regression coverage. -Source fixes address native process replay, strict print options/inventory validation, duplicate-risk preservation and event bounds. The user requires admin-configurable document retention, default **365 days**, scheduled cleanup and proper S3 bucket separation. Implementation uses existing QQQ StorageAction/S3 configuration with a dedicated printing bucket, tenant namespaces and immutable per-job storage bindings; exact cleanup scope and cross-tenant isolation require regression evidence before release. Active/uncertain jobs and history/receipts remain protected. Existing inline documents remain readable. No live storage configuration, purge or database migration has run. +Earlier fixes add replay-safe native processes, validated facility selectors, strict wire fields, duplicate-risk preservation, dedicated QQQ S3 storage and native admin retention with a **365-day default**. Active, failed and uncertain work remains protected. Stop older writers before admission-accounting migration; existing content is preserved. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). -Root owns publication and build/release gates; server/native-metadata agents own source and tests. Source, hosted provenance and independent security results remain pending. The installed Windows baseline is still 0.1.7; unsigned prereleases are explicitly accepted by the user. Do not infer deployed-cloud or new hardware acceptance from these source changes. +Draft PR #10 contains earlier audit/CI guard commits that passed hosted checks; final remediation commits need fresh checks. No live database migration, S3 provisioning, purge, merge, release or host deployment has occurred. Real PostgreSQL/S3, remote host authentication, multi-node operation and sustained physical throughput remain unqualified. + +Master coordination at `R:\agent-comms\qqq-cloud-printing.md` is acknowledged. Record Files owns indefinitely retained originals; printing owns its dedicated delivery copies. Their compiled source-reader/actor authorization contract remains pending. Standalone byte submission continues. Installed Windows baseline remains 0.1.7; unsigned prereleases are user-approved. ## Branded diagnostic documents — v0.1.3-rc.1 verified and published diff --git a/docs/TODO.md b/docs/TODO.md index 64b3cef..a4a03b2 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -4,6 +4,7 @@ - [x] Implement admin-configurable document retention (one-year default), bounded scheduled cleanup and existing QQQ object-storage integration, retaining history/receipts and protecting active or uncertain output. - [ ] Integrate Record Files' authorized immutable-version reader after its contract is implemented; define async source revocation/correlation and eliminate host-outbox byte copies for that path. - [ ] Verify audit release gates and provenance; complete independent security scan and deployed-host qualification. +- [x] Audit PR #10 at 1655d4e passes all hosted checks; download and verify JAR/POM artifacts. Tagged-release provenance, deep scan and deployment remain pending. - [x] Versioned shared contract and HTTP happy-path test. - [x] Durable enrollment, device-scoped authentication, inventory and printer approval. @@ -50,3 +51,5 @@ The Windows gate, MSI rebuild, installed UI acceptance, release evidence and fre - [x] Correct the Zebra machine driver defaults from 4.09 × 2-inch Thermal Transfer to 4 × 6-inch Thermal Direct, restore/resume the printer, and confirm one fresh built-in PDF printed correctly. No application code fix was needed. - [x] Verify published Windows 0.1.7 assets; all three built-in physical paths are confirmed: laser PDF, Zebra ZPL and Zebra PDF. - [ ] Obtain explicit barcode scan and measured alignment confirmation; successful spooling is not physical acceptance. + +- [ ] Complete independent review and deployment qualification after the five baseline security findings are remediated; current details are in SESSION-STATE.md. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingAdmission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingAdmission.java new file mode 100644 index 0000000..c81bf9b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingAdmission.java @@ -0,0 +1,289 @@ +/* + * 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.sql.Connection; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Set; + + +/******************************************************************************* + ** Tenant-serialized, payload-free resource accounting. Callers use one JDBC + ** connection and acquire this lock before device, document or inventory locks. + *******************************************************************************/ +final class PrintingAdmission +{ + static final long UNKNOWN_UPLOAD_BYTES = 20L * 1024 * 1024; + private static final String ACTIVE = "('QUEUED','CLAIMED','RECEIVED','SUBMITTING')"; + + + + /*************************************************************************** + ** Static SQL helpers only. + ***************************************************************************/ + private PrintingAdmission() + { + } + + + + /*************************************************************************** + ** Add accounting without purging old payloads or assuming unknown bytes zero. + ***************************************************************************/ + static void initialize(Connection connection, String tenant) throws SQLException + { + for(String sql : new String[] { + "ALTER TABLE printing_document_orphan ADD COLUMN IF NOT EXISTS device_id VARCHAR(36)", + "ALTER TABLE printing_document_orphan ADD COLUMN IF NOT EXISTS content_length BIGINT", + "ALTER TABLE printing_document_orphan ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(300)", + "CREATE TABLE IF NOT EXISTS printing_resource_usage (tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL, retained_bytes BIGINT NOT NULL, PRIMARY KEY(tenant_id,device_id))", + "CREATE TABLE IF NOT EXISTS printing_admission_event (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36), created_at VARCHAR(40) NOT NULL)", + "CREATE INDEX IF NOT EXISTS printing_admission_tenant_time_idx ON printing_admission_event(tenant_id,created_at)", + "CREATE INDEX IF NOT EXISTS printing_admission_device_time_idx ON printing_admission_event(tenant_id,device_id,created_at)", + "CREATE INDEX IF NOT EXISTS printing_job_outstanding_tenant_idx ON printing_job(tenant_id,status,content_length)", + "CREATE INDEX IF NOT EXISTS printing_job_outstanding_device_idx ON printing_job(tenant_id,device_id,status,content_length)", + "CREATE INDEX IF NOT EXISTS printing_orphan_device_idx ON printing_document_orphan(tenant_id,device_id)", + "CREATE INDEX IF NOT EXISTS printing_orphan_key_idx ON printing_document_orphan(tenant_id,idempotency_key)", + "CREATE INDEX IF NOT EXISTS printing_printer_inventory_idx ON printing_printer(device_id,available)", + "CREATE INDEX IF NOT EXISTS printing_printer_owner_idx ON printing_printer(tenant_id,device_id,id)" + }) + { + execute(connection, sql); + } + initializeTenant(connection, tenant); + } + + + + /*************************************************************************** + ** Existing schema may serve newly configured tenants without another DDL run. + ***************************************************************************/ + static void initializeTenant(Connection connection, String tenant) throws SQLException + { + connection.setAutoCommit(false); + if(number(connection, "SELECT COUNT(*) FROM printing_resource_usage WHERE tenant_id=? AND device_id=''", tenant) == 0) + { + try + { + execute(connection, "INSERT INTO printing_resource_usage VALUES (?, '', 0)", tenant); + } + catch(SQLException exception) + { + if(!"23505".equals(exception.getSQLState())) + { + throw exception; + } + connection.rollback(); + connection.setAutoCommit(true); + return; + } + try + { + lock(connection, tenant); + long bytes = number(connection, "SELECT COALESCE(SUM(content_length),0) FROM printing_job WHERE tenant_id=? AND content_available=1", tenant) + + orphanBytes(connection, tenant, null); + execute(connection, "UPDATE printing_resource_usage SET retained_bytes=? WHERE tenant_id=? AND device_id=''", bytes, tenant); + execute(connection, "INSERT INTO printing_resource_usage (tenant_id,device_id,retained_bytes) SELECT d.tenant_id,d.id,COALESCE((SELECT SUM(j.content_length) FROM printing_job j WHERE j.tenant_id=d.tenant_id AND j.device_id=d.id AND j.content_available=1),0)+COALESCE((SELECT SUM(COALESCE(o.content_length,20971520)) FROM printing_document_orphan o WHERE o.tenant_id=d.tenant_id AND o.device_id=d.id AND NOT EXISTS(SELECT 1 FROM printing_job j WHERE j.id=o.id)),0) FROM printing_device d WHERE d.tenant_id=?", tenant); + String cutoff = Instant.now().minusSeconds(3600).toString(); + execute(connection, "INSERT INTO printing_admission_event SELECT id,tenant_id,device_id,created_at FROM printing_job WHERE tenant_id=? AND created_at>=?", tenant, cutoff); + execute(connection, "INSERT INTO printing_admission_event SELECT o.id,o.tenant_id,o.device_id,o.created_at FROM printing_document_orphan o WHERE o.tenant_id=? AND o.created_at>=? AND NOT EXISTS(SELECT 1 FROM printing_admission_event a WHERE a.id=o.id)", tenant, cutoff); + connection.commit(); + } + catch(Exception exception) + { + connection.rollback(); + throw exception; + } + finally + { + connection.setAutoCommit(true); + } + } + else + { + connection.commit(); + connection.setAutoCommit(true); + } + } + + + + /*************************************************************************** + ** Shared lock serializes capacity decisions across devices and processes. + ***************************************************************************/ + static void lock(Connection connection, String tenant) throws SQLException + { + try(var statement = connection.prepareStatement("SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id=? AND device_id='' FOR UPDATE")) + { + statement.setString(1, tenant); + try(var rows = statement.executeQuery()) + { + if(!rows.next()) + { + throw new SQLException("Printing admission accounting has not been initialized"); + } + } + } + } + + + + /*************************************************************************** + ** Admission precedes every upload/inline insert; rollback restores all usage. + ***************************************************************************/ + static void reserve(Connection connection, String tenant, String device, String id, int bytes, PrintingAdmissionLimits limits) throws SQLException + { + if(number(connection, "SELECT COUNT(*) FROM printing_resource_usage WHERE tenant_id=? AND device_id=?", tenant, device) == 0) + { + execute(connection, "INSERT INTO printing_resource_usage VALUES (?, ?, 0)", tenant, device); + } + String cutoff = Instant.now().minusSeconds(3600).toString(); + execute(connection, "DELETE FROM printing_admission_event WHERE tenant_id=? AND created_at=?", rateScope); + String scopeName = device == null ? "tenant" : "device"; + if(jobs >= jobLimit || bytes > activeByteLimit || activeBytes > activeByteLimit - bytes) + { + throw new PrintingException(429, "The " + scopeName + " outstanding print-job capacity is full; wait for existing work or ask an administrator to adjust admission limits"); + } + if(bytes > retainedLimit || retained > retainedLimit - bytes) + { + throw new PrintingException(429, "The " + scopeName + " retained print-document capacity is full; review retention and storage capacity before retrying"); + } + if(rate >= hourlyLimit) + { + throw new PrintingException(429, "The " + scopeName + " hourly print-job admission limit is reached; retry later using the same request identity"); + } + } + + + + /*************************************************************************** + ** Failed upload reservations stay charged until exact confirmed removal. + ***************************************************************************/ + private static long orphanBytes(Connection connection, String tenant, String device) throws SQLException + { + String sql = "SELECT COALESCE(SUM(COALESCE(content_length,20971520)),0) FROM printing_document_orphan o WHERE tenant_id=?" + + (device == null ? "" : " AND device_id=?") + " AND NOT EXISTS(SELECT 1 FROM printing_job j WHERE j.id=o.id)"; + return (device == null ? number(connection, sql, tenant) : number(connection, sql, tenant, device)); + } + + + + /*************************************************************************** + ** Release only after deletion; an inconsistent counter fails closed. + ***************************************************************************/ + static void release(Connection connection, String tenant, String device, long bytes) throws SQLException + { + int expected = device == null ? 1 : 2; + String predicate = device == null ? "device_id=''" : "device_id IN ('',?)"; + Object[] values = device == null ? new Object[] {bytes, tenant, bytes} : new Object[] {bytes, tenant, device, bytes}; + if(execute(connection, "UPDATE printing_resource_usage SET retained_bytes=retained_bytes-? WHERE tenant_id=? AND " + predicate + " AND retained_bytes>=?", values) != expected) + { + throw new SQLException("Printing retained-byte accounting requires administrator reconciliation"); + } + } + + + + /*************************************************************************** + ** Cumulative IDs cannot bypass limits by rotating successive snapshots. + ***************************************************************************/ + static void inventory(Connection connection, String tenant, String device, Set incoming, PrintingAdmissionLimits limits) throws SQLException + { + int additions = 0; + for(String id : incoming) + { + if(number(connection, "SELECT COUNT(*) FROM printing_printer WHERE tenant_id=? AND device_id=? AND printer_id=?", tenant, device, id) == 0) + { + additions++; + } + } + if(additions > 0 && (number(connection, "SELECT COUNT(*) FROM printing_printer WHERE tenant_id=? AND device_id=?", tenant, device) > limits.devicePrinters() - additions + || number(connection, "SELECT COUNT(*) FROM printing_printer WHERE tenant_id=?", tenant) > limits.tenantPrinters() - additions)) + { + throw new PrintingException(429, "Printer history capacity is full; existing printers remain available to update. Ask an administrator to review the cumulative inventory limit"); + } + } + + + + /*************************************************************************** + ** Read one scalar; never materialize payloads or an unbounded row list. + ***************************************************************************/ + private static long number(Connection connection, String sql, Object... values) throws SQLException + { + try(var statement = connection.prepareStatement(sql)) + { + for(int index = 0; index < values.length; index++) + { + statement.setObject(index + 1, values[index]); + } + try(var rows = statement.executeQuery()) + { + if(!rows.next()) + { + throw new SQLException("Missing printing resource accounting row"); + } + return (rows.getLong(1)); + } + } + } + + + + /*************************************************************************** + ** All identifiers are fixed SQL; only values come from callers. + ***************************************************************************/ + private static int execute(Connection connection, String sql, Object... values) throws SQLException + { + try(var statement = connection.prepareStatement(sql)) + { + for(int index = 0; index < values.length; index++) + { + statement.setObject(index + 1, values[index]); + } + return (statement.executeUpdate()); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingAdmissionLimits.java b/src/main/java/com/kingsrook/qbits/printing/PrintingAdmissionLimits.java new file mode 100644 index 0000000..2ed6395 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingAdmissionLimits.java @@ -0,0 +1,54 @@ +/* + * 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; + + +/******************************************************************************* + ** Host-configurable logical resource ceilings, independent of retention age. + *******************************************************************************/ +public record PrintingAdmissionLimits(int deviceOutstandingJobs, int tenantOutstandingJobs, + long deviceOutstandingBytes, long tenantOutstandingBytes, long deviceRetainedBytes, long tenantRetainedBytes, + int deviceJobsPerHour, int tenantJobsPerHour, int devicePrinters, int tenantPrinters) +{ + /*************************************************************************** + ** Never silently disable a security boundary through zero/negative limits. + ***************************************************************************/ + public PrintingAdmissionLimits + { + if(deviceOutstandingJobs < 1 || tenantOutstandingJobs < 1 || deviceOutstandingBytes < 1 || tenantOutstandingBytes < 1 + || deviceRetainedBytes < 1 || tenantRetainedBytes < 1 || deviceJobsPerHour < 1 || tenantJobsPerHour < 1 || devicePrinters < 1 || tenantPrinters < 1) + { + throw new IllegalArgumentException("Printing admission limits must all be positive"); + } + } + + + + /*************************************************************************** + ** Capacity must be sized for document sizes and the deployment's retention. + ***************************************************************************/ + public static PrintingAdmissionLimits defaults() + { + long gibibyte = 1024L * 1024 * 1024; + return (new PrintingAdmissionLimits(10_000, 100_000, 10 * gibibyte, 100 * gibibyte, + 2048 * gibibyte, 20_480 * gibibyte, 2_000, 20_000, 1_000, 20_000)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java index 207565c..a0b0efa 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java @@ -31,6 +31,7 @@ public class PrintingQBitConfig implements QBitConfig { private String backendName; private transient PrintingService service; + private PrintingAdmissionLimits admissionLimits = PrintingAdmissionLimits.defaults(); private PrintingRetentionPolicy retentionPolicy = new PrintingRetentionPolicy(true, 365, 200); private String storageTableName; private String schedulerName; @@ -38,6 +39,27 @@ public class PrintingQBitConfig implements QBitConfig + /*************************************************************************** + ** Resource capacity is deployment-owned and independent of retention age. + ***************************************************************************/ + public PrintingAdmissionLimits getAdmissionLimits() + { + return (admissionLimits); + } + + + + /*************************************************************************** + ** Positive tenant/device limits apply at the shared admission boundary. + ***************************************************************************/ + public PrintingQBitConfig withAdmissionLimits(PrintingAdmissionLimits value) + { + admissionLimits = java.util.Objects.requireNonNull(value); + return (this); + } + + + /*************************************************************************** ** Reuse the host's facility selector and its existing record security. ***************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java index 3b9d35e..d4b10b0 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java @@ -79,7 +79,7 @@ public class PrintingQBitProducer implements QBitProducer { throw new QException("The initial printing QBit supports the printing namespace only"); } - config.getService().withRetentionPolicy(config.getRetentionPolicy()); + config.getService().withRetentionPolicy(config.getRetentionPolicy()).withAdmissionLimits(config.getAdmissionLimits()); config.getService().configureStorage(instance, config.getStorageTableName()); instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion(version()).withNamespace("printing").withConfig(config)); instance.addSecurityKeyType(new QSecurityKeyType().withName("printing_tenant")); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java index d206eea..3318b02 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java @@ -107,7 +107,8 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface config.routes.get(BASE + "/admin/devices/{deviceId}/printers", context -> handle(context, () -> { admin(context); - return new JSONObject().put("printers", service.printers(context.pathParam("deviceId"))); + String limit = context.queryParam("limit"); + return service.printersPage(context.pathParam("deviceId"), limit == null ? 200 : Integer.parseInt(limit), context.queryParam("before")); })); config.routes.put(BASE + "/admin/devices/{deviceId}/printers/{printerId}", context -> handle(context, () -> { diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index 1fbcdf2..a8c958c 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -52,11 +52,23 @@ public class PrintingService private final DataSource dataSource; private final String tenantId; private PrintingDocumentStorage documentStorage; + private PrintingAdmissionLimits admissionLimits = PrintingAdmissionLimits.defaults(); private String storageTableName; private PrintingRetentionPolicy defaultRetentionPolicy = new PrintingRetentionPolicy(true, 365, 200); + /*************************************************************************** + ** Deployment-owned limits apply equally to native, HTTP and outbox callers. + ***************************************************************************/ + public PrintingService withAdmissionLimits(PrintingAdmissionLimits limits) + { + admissionLimits = java.util.Objects.requireNonNull(limits); + return (this); + } + + + /*************************************************************************** ** Bind a host-owned storage table before exposing this service to requests. ***************************************************************************/ @@ -165,6 +177,7 @@ public class PrintingService statement.execute(); } } + PrintingAdmission.initialize(connection, tenantId); widenColumn(connection, "printing_device", "name", 1024); widenColumn(connection, "printing_enrollment", "device_name", 1024); widenColumn(connection, "printing_printer", "name", 1024); @@ -418,11 +431,21 @@ public class PrintingService } try(Connection connection = dataSource.getConnection()) { + requireDevice(connection, deviceId); + PrintingAdmission.initializeTenant(connection, tenantId); connection.setAutoCommit(false); try { + PrintingAdmission.lock(connection, tenantId); requireDevice(connection, deviceId); - update(connection, "UPDATE printing_printer SET status='UNKNOWN', available=0 WHERE device_id=?", deviceId); + query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); + Set incoming = new java.util.HashSet<>(); + for(int index = 0; index < printers.length(); index++) + { + incoming.add(required(printers.getJSONObject(index).optString("printerId"), "printerId")); + } + PrintingAdmission.inventory(connection, tenantId, deviceId, incoming, admissionLimits); + update(connection, "UPDATE printing_printer SET status='UNKNOWN', available=0 WHERE device_id=? AND available=1", deviceId); for(int index = 0; index < printers.length(); index++) { JSONObject printer = printers.getJSONObject(index); @@ -482,15 +505,7 @@ public class PrintingService ***************************************************************************/ public JSONObject submit(JSONObject request) throws SQLException { - String deviceId = required(request.optString("deviceId"), "deviceId"); - String printerId = required(request.optString("printerId"), "printerId"); - String key = required(request.optString("idempotencyKey"), "idempotencyKey"); - String title = required(request.optString("title"), "title"); String format = required(request.optString("format"), "format"); - if(!Set.of("PDF", "ZPL").contains(format)) - { - throw new PrintingException(400, "Only PDF and ZPL are supported"); - } byte[] content; try { @@ -512,47 +527,114 @@ public class PrintingService { throw new PrintingException(400, "PDF header is missing"); } + return (submitPrepared(request, hash(content), content.length, connection -> content)); + } + + + + /*************************************************************************** + ** Delay stored-document reads until atomic admission has succeeded. + ***************************************************************************/ + @FunctionalInterface + private interface DocumentLoader + { + /*********************************************************************** + ** Read the immutable document after capacity is reserved. + ***********************************************************************/ + byte[] load(Connection connection) throws SQLException; + } + + + + /*************************************************************************** + ** All producers share durable admission before object storage allocation. + ***************************************************************************/ + private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader) throws SQLException + { + String deviceId = required(request.optString("deviceId"), "deviceId"); + String printerId = required(request.optString("printerId"), "printerId"); + String key = required(request.optString("idempotencyKey"), "idempotencyKey"); + String title = required(request.optString("title"), "title"); + String format = required(request.optString("format"), "format"); + if(!Set.of("PDF", "ZPL").contains(format)) + { + throw new PrintingException(400, "Only PDF and ZPL are supported"); + } JSONObject options = printOptions(request); - String digest = hash(content); String id = UUID.randomUUID().toString(); String reference = storageTableName == null ? null : documentStorage.prefix() + id + "." + format.toLowerCase(java.util.Locale.ROOT); String binding = storageTableName == null ? null : documentStorage.binding(storageTableName); String uploadedId = null; try(Connection connection = dataSource.getConnection()) { + requireDevice(connection, deviceId); + PrintingAdmission.initializeTenant(connection, tenantId); connection.setAutoCommit(false); try { + long pendingDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30); + while(true) + { + PrintingAdmission.lock(connection, tenantId); + requireDevice(connection, deviceId); + query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); + List previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key); + if(!previous.isEmpty()) + { + JSONObject old = previous.getFirst(); + if(!old.getString("device_id").equals(deviceId) || !old.getString("printer_id").equals(printerId) || !old.getString("content_hash").equals(digest) || !old.getString("format").equals(format) || !new JSONObject(old.getString("options_json")).similar(options)) + { + throw new PrintingException(409, "Idempotency key was already used for a different request"); + } + connection.commit(); + return (job(old)); + } + if(query(connection, "SELECT id FROM printing_document_orphan WHERE tenant_id=? AND idempotency_key=? LIMIT 1", tenantId, key).isEmpty()) + { + break; + } + connection.commit(); + if(System.nanoTime() >= pendingDeadline) + { + throw new PrintingException(429, "This request already has an upload in progress or awaiting recovery; retry later using the same request identity"); + } + try + { + Thread.sleep(25); + } + catch(InterruptedException exception) + { + Thread.currentThread().interrupt(); + throw new SQLException("Interrupted while waiting for the existing submission", exception); + } + } + List printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1", deviceId, printerId); + if(printers.isEmpty() || !new JSONArray(printers.getFirst().getString("formats")).toList().contains(format)) + { + throw new PrintingException(400, "Printer is not enabled for this format"); + } + PrintingAdmission.reserve(connection, tenantId, deviceId, id, contentLength, admissionLimits); if(storageTableName != null) { - requireDevice(connection, deviceId); - reserveUpload(connection, id, storageTableName, reference, binding); + reserveUpload(connection, id, storageTableName, reference, binding, deviceId, contentLength, key); connection.commit(); uploadedId = id; if(query(connection, "SELECT id FROM printing_document_orphan WHERE id=? FOR UPDATE", id).isEmpty()) { throw new PrintingException(409, "Upload reservation expired; retry submission"); } - } - // Serialize submissions per device; the unique key also protects cross-device races. - requireDevice(connection, deviceId); - query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); - List previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key); - if(!previous.isEmpty()) - { - JSONObject old = previous.getFirst(); - if(!old.getString("device_id").equals(deviceId) || !old.getString("printer_id").equals(printerId) || !old.getString("content_hash").equals(digest) || !old.getString("format").equals(format) || !new JSONObject(old.getString("options_json")).similar(options)) + requireDevice(connection, deviceId); + query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); + printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1", deviceId, printerId); + if(printers.isEmpty() || !new JSONArray(printers.getFirst().getString("formats")).toList().contains(format)) { - throw new PrintingException(409, "Idempotency key was already used for a different request"); + throw new PrintingException(400, "Printer is not enabled for this format"); } - update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId); - connection.commit(); - return (job(old)); } - List printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1", deviceId, printerId); - if(printers.isEmpty() || !new JSONArray(printers.getFirst().getString("formats")).toList().contains(format)) + byte[] content = loader.load(connection); + if(content.length != contentLength || !hash(content).equals(digest)) { - throw new PrintingException(400, "Printer is not enabled for this format"); + throw new PrintingException(409, "Stored print document integrity validation failed"); } if(storageTableName != null) { @@ -676,7 +758,8 @@ public class PrintingService try(Connection connection = dataSource.getConnection()) { requireDevice(connection, deviceId); - row = deviceJob(connection, deviceId, jobId, true); + row = deviceJob(connection, deviceId, jobId); + row.put("content_available", query(connection, "SELECT content_available FROM printing_job WHERE id=? AND tenant_id=? AND device_id=?", jobId, tenantId, deviceId).getFirst().getInt("content_available")); List previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND device_id=? AND idempotency_key=?", tenantId, deviceId, retryKey); if(!previous.isEmpty()) { @@ -698,10 +781,10 @@ public class PrintingService { throw new PrintingException(409, "Inspect the printer and confirm the risk of duplicate output before reprinting"); } - return (submit(new JSONObject().put("deviceId", deviceId).put("printerId", row.getString("printer_id")) - .put("title", row.getString("title")).put("format", row.getString("format")).put("contentBase64", Base64.getEncoder().encodeToString(documentBytes(row))) + return (submitPrepared(new JSONObject().put("deviceId", deviceId).put("printerId", row.getString("printer_id")) + .put("title", row.getString("title")).put("format", row.getString("format")) .put("options", new JSONObject(row.getString("options_json"))) - .put("idempotencyKey", retryKey))); + .put("idempotencyKey", retryKey), row.getString("content_hash"), row.getInt("content_length"), connection -> documentBytes(deviceJob(connection, deviceId, jobId, true)))); } @@ -884,25 +967,68 @@ public class PrintingService /*************************************************************************** - ** Return printers for a device owned by this tenant. + ** Bounded inventory history for management clients. ***************************************************************************/ - public JSONArray printers(String deviceId) throws SQLException + public JSONObject printersPage(String deviceId, int limit, String before) throws SQLException { + if(limit < 1 || limit > 200) + { + throw new PrintingException(400, "Printer page limit must be between 1 and 200"); + } + String name = null; + String id = null; + if(before != null && !before.isEmpty()) + { + try + { + if(before.length() > 12000) + { + throw new IllegalArgumentException("Cursor too long"); + } + JSONObject cursor = new JSONObject(new String(Base64.getUrlDecoder().decode(before), StandardCharsets.UTF_8)); + name = optionalText(cursor.getString("name"), "cursor name", 1024); + id = UUID.fromString(cursor.getString("id")).toString(); + } + catch(RuntimeException exception) + { + throw new PrintingException(400, "Invalid printer cursor"); + } + } try(Connection connection = dataSource.getConnection()) { requireDevice(connection, deviceId); + String sql = "SELECT * FROM printing_printer WHERE tenant_id=? AND device_id=?"; + List rows = name == null ? query(connection, sql + " ORDER BY name,id LIMIT ?", tenantId, deviceId, limit + 1) + : query(connection, sql + " AND (name>? OR (name=? AND id>?)) ORDER BY name,id LIMIT ?", tenantId, deviceId, name, name, id, limit + 1); JSONArray result = new JSONArray(); - for(JSONObject row : query(connection, "SELECT * FROM printing_printer WHERE device_id=? ORDER BY name", deviceId)) + for(JSONObject row : rows.subList(0, Math.min(limit, rows.size()))) { result.put(new JSONObject().put("printerId", row.getString("printer_id")).put("name", row.getString("queue_name")).put("displayName", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location")) .put("driverName", row.optString("driver_name")).put("portName", row.optString("port_name")).put("status", row.getString("status")).put("formats", new JSONArray(row.getString("formats"))).put("enabled", row.getInt("enabled") == 1).put("available", row.getInt("available") == 1).put("lastSeenAt", row.getString("last_seen_at"))); } - return (result); + JSONObject response = new JSONObject().put("printers", result).put("nextCursor", JSONObject.NULL); + if(rows.size() > limit) + { + JSONObject last = rows.get(limit - 1); + String cursor = new JSONObject().put("name", last.getString("name")).put("id", last.getString("id")).toString(); + response.put("nextCursor", Base64.getUrlEncoder().withoutPadding().encodeToString(cursor.getBytes(StandardCharsets.UTF_8))); + } + return (response); } } + /*************************************************************************** + ** Existing callers retain their array contract with a bounded first page. + ***************************************************************************/ + public JSONArray printers(String deviceId) throws SQLException + { + return (printersPage(deviceId, 200, null).getJSONArray("printers")); + } + + + /*************************************************************************** ** Return recent jobs; content is excluded from management lists. ***************************************************************************/ @@ -1025,6 +1151,7 @@ public class PrintingService } String cutoff = Instant.now().minusSeconds(policy.days() * 86400L).toString(); { + PrintingAdmission.initializeTenant(connection, tenantId); connection.setAutoCommit(false); try { @@ -1033,7 +1160,8 @@ public class PrintingService for(JSONObject row : rows) { renewRetentionLease(connection, leaseToken); - List locked = query(connection, "SELECT id, format, content_storage_table, content_reference, content_binding FROM printing_job WHERE tenant_id=? AND id=? AND content_available=1 AND status IN ('SPOOLED','SPOOLER_COMPLETED') AND completed_at locked = query(connection, "SELECT id, device_id, content_length, format, content_storage_table, content_reference, content_binding FROM printing_job WHERE tenant_id=? AND id=? AND content_available=1 AND status IN ('SPOOLED','SPOOLER_COMPLETED') AND completed_at rows = query(connection, "SELECT * FROM printing_document_orphan WHERE tenant_id=? AND id=? FOR UPDATE", tenantId, id); if(rows.isEmpty()) { @@ -1293,6 +1430,7 @@ public class PrintingService throw new PrintingException(409, "Orphan reference does not match its owned upload identity"); } documentStorage.delete(orphan.getString("storage_table"), orphan.getString("reference")); + PrintingAdmission.release(connection, tenantId, orphan.isNull("device_id") ? null : orphan.getString("device_id"), orphan.isNull("content_length") ? PrintingAdmission.UNKNOWN_UPLOAD_BYTES : orphan.getLong("content_length")); } update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId); connection.commit(); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingAdmissionTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingAdmissionTest.java new file mode 100644 index 0000000..17424c1 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingAdmissionTest.java @@ -0,0 +1,601 @@ +/* + * 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.sql.SQLException; +import java.time.Instant; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Security regressions use isolated SQL and synthetic object storage only. + *******************************************************************************/ +class PrintingAdmissionTest +{ + private static final byte[] DOCUMENT = new byte[1024]; + + + + /*************************************************************************** + ** Tiny retry requests cannot accumulate unlimited retained terminal copies. + ***************************************************************************/ + @Test + void retainedRetryBudgetRejectsBeforeUploadAndPreservesReplayAfterCleanup() throws Exception + { + Fixture fixture = fixture(limits(10, 20, 100_000, 200_000, 2048, 4096, 100, 200, 10, 20)); + String original = fixture.service.submit(request(fixture.first, "original")).getString("jobId"); + spool(fixture.service, fixture.first, original); + JSONObject retryRequest = retry("same-retry"); + String copy = fixture.service.retry(fixture.first, original, retryRequest).getString("jobId"); + spool(fixture.service, fixture.first, copy); + int readsBeforeRejection = fixture.documents.gets.get(); + assertQuota(() -> fixture.service.retry(fixture.first, original, retry("fresh-retry"))); + assertEquals(readsBeforeRejection, fixture.documents.gets.get()); + assertEquals(copy, fixture.service.retry(fixture.first, original, retryRequest).getString("jobId")); + assertEquals(2, fixture.documents.puts.get()); + assertEquals(2, count(fixture.source, "printing_job")); + execute(fixture.source, "UPDATE printing_job SET completed_at=? WHERE id=?", Instant.now().minusSeconds(400L * 86400).toString(), original); + assertEquals(1, fixture.service.runRetention()); + assertEquals(copy, fixture.service.retry(fixture.first, original, retryRequest).getString("jobId")); + assertEquals(410, assertThrows(PrintingException.class, () -> fixture.service.retry(fixture.first, original, retry("expired-source"))).getStatus()); + fixture.service.submit(request(fixture.first, "after-cleanup")); + assertEquals(3, fixture.documents.puts.get()); + assertEquals(2, fixture.documents.objects.size()); + } + + + + /*************************************************************************** + ** Tenant count and byte budgets serialize across service instances/devices. + ***************************************************************************/ + @Test + void concurrentDevicesCannotExceedTenantCountOrByteBudgets() throws Exception + { + List budgets = List.of( + limits(10, 1, 100_000, 200_000, 100_000, 200_000, 100, 200, 10, 20), + limits(10, 20, 100_000, DOCUMENT.length, 100_000, 200_000, 100, 200, 10, 20), + limits(10, 20, 100_000, 200_000, 100_000, DOCUMENT.length, 100, 200, 10, 20)); + for(int attempt = 0; attempt < 10; attempt++) + { + for(PrintingAdmissionLimits budget : budgets) + { + Fixture fixture = fixture(budget); + PrintingService other = sibling(fixture, budget); + concurrentOneAccepted(() -> fixture.service.submit(request(fixture.first, "first")), () -> other.submit(request(fixture.second, "second"))); + assertEquals(1, fixture.documents.puts.get()); + assertEquals(1, count(fixture.source, "printing_job")); + assertEquals(0, count(fixture.source, "printing_document_orphan")); + } + } + } + + + + /*************************************************************************** + ** Device budgets remain effective even when tenant capacity is available. + ***************************************************************************/ + @Test + void outstandingDeviceCountAndBytesReleaseOnlyWhenWorkFinishes() throws Exception + { + for(PrintingAdmissionLimits budget : List.of( + limits(1, 20, 100_000, 200_000, 100_000, 200_000, 100, 200, 10, 20), + limits(10, 20, DOCUMENT.length, 200_000, 100_000, 200_000, 100, 200, 10, 20))) + { + Fixture fixture = fixture(budget); + String original = fixture.service.submit(request(fixture.first, "original")).getString("jobId"); + assertQuota(() -> fixture.service.submit(request(fixture.first, "overflow"))); + assertEquals(original, fixture.service.submit(request(fixture.first, "original")).getString("jobId")); + fixture.service.submit(request(fixture.second, "other-device")); + assertEquals(2, fixture.documents.puts.get()); + spool(fixture.service, fixture.first, original); + fixture.service.submit(request(fixture.first, "finished-slot")); + assertEquals(3, fixture.documents.puts.get()); + } + } + + + + /*************************************************************************** + ** Failed upload compensation keeps its full reservation until exact cleanup. + ***************************************************************************/ + @Test + void failedUploadIntentConsumesCapacityUntilCleanupSucceeds() throws Exception + { + Fixture fixture = fixture(limits(10, 20, 100_000, 200_000, DOCUMENT.length, DOCUMENT.length, 100, 200, 10, 20)); + fixture.documents.failUpload = true; + fixture.documents.failDelete = true; + assertThrows(SQLException.class, () -> fixture.service.submit(request(fixture.first, "failed-upload"))); + assertEquals(1, count(fixture.source, "printing_document_orphan")); + assertEquals(0, count(fixture.source, "printing_job")); + assertQuota(() -> fixture.service.submit(request(fixture.second, "blocked-by-reservation"))); + assertEquals(1, fixture.documents.puts.get()); + fixture.documents.failUpload = false; + fixture.documents.failDelete = false; + execute(fixture.source, "UPDATE printing_document_orphan SET created_at=?", Instant.now().minusSeconds(7200).toString()); + assertEquals(1, fixture.service.runRetention()); + fixture.service.submit(request(fixture.second, "after-recovery")); + assertEquals(2, fixture.documents.puts.get()); + assertEquals(1, fixture.documents.objects.size()); + assertEquals(0, count(fixture.source, "printing_document_orphan")); + } + + + + /*************************************************************************** + ** Legacy upload intents with unknown size reserve the maximum allowed bytes. + ***************************************************************************/ + @Test + void legacyUnknownUploadChargesTenantCapacityUntilExactCleanup() throws Exception + { + long unknownBytes = 20L * 1024 * 1024; + Fixture fixture = fixture(limits(10, 20, 100_000_000, 200_000_000, 100_000_000, unknownBytes, 100, 200, 10, 20)); + String orphan = UUID.randomUUID().toString(); + String reference = fixture.documents.prefix() + orphan + ".zpl"; + fixture.documents.objects.put(reference, DOCUMENT.clone()); + execute(fixture.source, "INSERT INTO printing_document_orphan (id,tenant_id,storage_table,reference,binding,created_at) VALUES (?,?,?,?,?,?)", + orphan, "tenant-a", "printing_documents", reference, "quota-fixture", Instant.now().minusSeconds(7200).toString()); + // Only isolated test accounting tables are removed to exercise upgrade backfill. + execute(fixture.source, "DROP TABLE printing_resource_usage"); + execute(fixture.source, "DROP TABLE printing_admission_event"); + fixture.service.initialize(); + assertEquals(Long.toString(unknownBytes), scalar(fixture.source, "SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id=? AND device_id=''", "tenant-a")); + assertEquals("0", scalar(fixture.source, "SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id=? AND device_id=?", "tenant-a", fixture.first)); + assertQuota(() -> fixture.service.submit(request(fixture.first, "blocked-by-legacy-upload"))); + assertEquals(0, fixture.documents.puts.get()); + assertEquals(0, fixture.documents.gets.get()); + assertEquals(1, fixture.documents.objects.size()); + assertTrue(fixture.documents.objects.containsKey(reference)); + assertEquals(1, count(fixture.source, "printing_document_orphan")); + assertEquals(0, count(fixture.source, "printing_job")); + assertEquals(1, fixture.service.runRetention()); + assertFalse(fixture.documents.objects.containsKey(reference)); + assertEquals("0", scalar(fixture.source, "SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id=? AND device_id=''", "tenant-a")); + fixture.service.submit(request(fixture.second, "after-legacy-cleanup")); + assertEquals(1, fixture.documents.puts.get()); + assertEquals(1, fixture.documents.objects.size()); + assertEquals(0, count(fixture.source, "printing_document_orphan")); + } + + + + /*************************************************************************** + ** Finished jobs do not permit unlimited small-request amplification per hour. + ***************************************************************************/ + @Test + void hourlyCreationBudgetIncludesFinishedRetriesAndDirectSubmission() throws Exception + { + Fixture fixture = fixture(limits(10, 20, 100_000, 200_000, 100_000, 200_000, 2, 100, 10, 20)); + String original = fixture.service.submit(request(fixture.first, "original")).getString("jobId"); + spool(fixture.service, fixture.first, original); + JSONObject same = retry("first-copy"); + String copy = fixture.service.retry(fixture.first, original, same).getString("jobId"); + spool(fixture.service, fixture.first, copy); + int readsBeforeRejection = fixture.documents.gets.get(); + assertQuota(() -> fixture.service.retry(fixture.first, original, retry("second-copy"))); + assertEquals(readsBeforeRejection, fixture.documents.gets.get()); + assertQuota(() -> fixture.service.submit(request(fixture.first, "direct-bypass"))); + assertEquals(copy, fixture.service.retry(fixture.first, original, same).getString("jobId")); + assertEquals(2, fixture.documents.puts.get()); + fixture.service.submit(request(fixture.second, "independent-device")); + assertEquals(3, fixture.documents.puts.get()); + } + + + + /*************************************************************************** + ** Rotated IDs cannot erase history or bypass cumulative device capacity. + ***************************************************************************/ + @Test + void inventoryRotationRejectsAtomicallyAndPreservesApprovalAndJobReference() throws Exception + { + Fixture fixture = fixture(limits(10, 20, 100_000, 200_000, 100_000, 200_000, 100, 200, 3, 20)); + fixture.service.inventory(fixture.first, new JSONArray().put(printer("queue").put("displayName", "Approved queue").put("description", "Historical description"))); + String job = fixture.service.submit(request(fixture.first, "history")).getString("jobId"); + String rowId = scalar(fixture.source, "SELECT printer_row_id FROM printing_job WHERE id=?", job); + fixture.service.inventory(fixture.first, new JSONArray().put(printer("new-a")).put(printer("new-b"))); + assertQuota(() -> + { + fixture.service.inventory(fixture.first, new JSONArray().put(printer("overflow"))); + return null; + }); + assertEquals("1", scalar(fixture.source, "SELECT available FROM printing_printer WHERE device_id=? AND printer_id=?", fixture.first, "new-a")); + assertEquals("1", scalar(fixture.source, "SELECT enabled FROM printing_printer WHERE id=?", rowId)); + assertEquals("Historical description", scalar(fixture.source, "SELECT description FROM printing_printer WHERE id=?", rowId)); + assertEquals(rowId, scalar(fixture.source, "SELECT printer_row_id FROM printing_job WHERE id=?", job)); + fixture.service.inventory(fixture.first, new JSONArray().put(printer("queue").put("displayName", "Updated approved queue"))); + assertEquals("Updated approved queue", scalar(fixture.source, "SELECT name FROM printing_printer WHERE id=?", rowId)); + assertEquals("1", scalar(fixture.source, "SELECT enabled FROM printing_printer WHERE id=?", rowId)); + assertEquals(4, count(fixture.source, "printing_printer")); + } + + + + /*************************************************************************** + ** Two devices cannot simultaneously claim the last tenant inventory slot. + ***************************************************************************/ + @Test + void concurrentInventoriesEnforceTenantCapacityAcrossServiceInstances() throws Exception + { + PrintingAdmissionLimits budget = limits(10, 20, 100_000, 200_000, 100_000, 200_000, 100, 200, 10, 3); + Fixture fixture = fixture(budget); + PrintingService other = sibling(fixture, budget); + concurrentOneAccepted(() -> + { + fixture.service.inventory(fixture.first, new JSONArray().put(printer("new-first"))); + return null; + }, () -> + { + other.inventory(fixture.second, new JSONArray().put(printer("new-second"))); + return null; + }); + assertEquals(3, count(fixture.source, "printing_printer")); + assertEquals("2", scalar(fixture.source, "SELECT COUNT(*) FROM printing_printer WHERE enabled=1")); + } + + + + /*************************************************************************** + ** Legacy over-budget history remains accessible through bounded pages. + ***************************************************************************/ + @Test + void existingOversizedInventoryIsPagedWithoutDeletingHistoricalRows() throws Exception + { + Fixture fixture = fixture(limits(10, 20, 100_000, 200_000, 100_000, 200_000, 100, 200, 1000, 2000)); + for(int batch = 0; batch < 2; batch++) + { + JSONArray printers = new JSONArray(); + for(int index = 0; index < 150; index++) + { + printers.put(printer("historic-" + (batch * 150 + index)).put("name", "倉庫".repeat(510) + String.format(java.util.Locale.ROOT, "%04d", batch * 150 + index))); + } + fixture.service.inventory(fixture.first, printers); + } + fixture.service.withAdmissionLimits(limits(10, 20, 100_000, 200_000, 100_000, 200_000, 100, 200, 3, 5)); + assertEquals(200, fixture.service.printers(fixture.first).length()); + Set ids = new HashSet<>(); + String cursor = null; + int pages = 0; + do + { + JSONObject page = fixture.service.printersPage(fixture.first, 73, cursor); + JSONArray rows = page.getJSONArray("printers"); + assertTrue(rows.length() <= 73); + for(int index = 0; index < rows.length(); index++) + { + assertTrue(ids.add(rows.getJSONObject(index).getString("printerId"))); + } + cursor = page.optString("nextCursor", ""); + assertTrue(++pages <= 10); + } + while(!cursor.isBlank()); + assertEquals(301, ids.size()); + assertEquals("0", scalar(fixture.source, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.INDEX_COLUMNS WHERE UPPER(TABLE_NAME)='PRINTING_PRINTER' AND UPPER(COLUMN_NAME)='NAME'")); + fixture.service.inventory(fixture.first, new JSONArray().put(printer("queue"))); + assertEquals(302, count(fixture.source, "printing_printer")); + assertQuota(() -> + { + fixture.service.inventory(fixture.first, new JSONArray().put(printer("extra"))); + return null; + }); + } + + + + /*************************************************************************** + ** Exercise two independent service objects against one tenant database. + ***************************************************************************/ + private static PrintingService sibling(Fixture fixture, PrintingAdmissionLimits limits) + { + PrintingService service = new PrintingService(fixture.source, "tenant-a").withAdmissionLimits(limits); + service.configureStorage(fixture.documents, "printing_documents"); + return (service); + } + + + + /*************************************************************************** + ** Synthetic storage and isolated SQL never reach an installed print agent. + ***************************************************************************/ + private static Fixture fixture(PrintingAdmissionLimits limits) throws Exception + { + DriverDataSource source = new DriverDataSource("jdbc:h2:mem:admission-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", ""); + PrintingService service = new PrintingService(source, "tenant-a").withAdmissionLimits(limits); + service.initialize(); + CountingDocuments documents = new CountingDocuments(); + service.configureStorage(documents, "printing_documents"); + return (new Fixture(source, service, documents, enroll(service, "first"), enroll(service, "second"))); + } + + + + /*************************************************************************** + ** Every printer approval remains explicit in the fixture. + ***************************************************************************/ + private static String enroll(PrintingService service, String name) throws Exception + { + String device = service.enroll(service.createEnrollment("warehouse", name).put("deviceName", name).put("agentVersion", "test")).getString("deviceId"); + service.inventory(device, new JSONArray().put(printer("queue"))); + service.enablePrinter(device, "queue", true); + return (device); + } + + + + /*************************************************************************** + ** A current queue identity, distinct from its display metadata. + ***************************************************************************/ + private static JSONObject printer(String id) + { + return (new JSONObject().put("printerId", id).put("name", "Synthetic " + id).put("status", "READY").put("formats", new JSONArray(List.of("ZPL")))); + } + + + + /*************************************************************************** + ** Exact synthetic bytes make byte budgets independent of transport encoding. + ***************************************************************************/ + private static JSONObject request(String device, String key) + { + return (new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Admission regression").put("format", "ZPL") + .put("contentBase64", Base64.getEncoder().encodeToString(DOCUMENT)).put("idempotencyKey", key)); + } + + + + /*************************************************************************** + ** Reprints retain explicit operator duplicate-risk consent. + ***************************************************************************/ + private static JSONObject retry(String key) + { + return (new JSONObject().put("requestId", key).put("confirmDuplicateRisk", true)); + } + + + + /*************************************************************************** + ** Use real event transitions so accounting sees the same production path. + ***************************************************************************/ + private static void spool(PrintingService service, String device, String job) throws Exception + { + assertEquals(job, service.claim(device).getJSONObject("job").getString("jobId")); + service.event(device, job, new JSONObject().put("eventId", UUID.randomUUID().toString()).put("status", "SPOOLED").put("spoolerJobId", "synthetic")); + } + + + + /*************************************************************************** + ** Over-budget requests must fail as operator-visible backpressure. + ***************************************************************************/ + private static void assertQuota(Callable operation) + { + PrintingException exception = assertThrows(PrintingException.class, operation::call); + assertEquals(429, exception.getStatus()); + assertFalse(exception.getMessage().isBlank()); + } + + + + /*************************************************************************** + ** Start both calls together; only one may consume the final shared slot. + ***************************************************************************/ + private static void concurrentOneAccepted(Callable first, Callable second) throws Exception + { + CountDownLatch start = new CountDownLatch(1); + try(var pool = Executors.newFixedThreadPool(2)) + { + var a = pool.submit(() -> outcome(start, first)); + var b = pool.submit(() -> outcome(start, second)); + start.countDown(); + assertEquals(List.of("accepted", "quota"), List.of(a.get(10, TimeUnit.SECONDS), b.get(10, TimeUnit.SECONDS)).stream().sorted().toList()); + } + } + + + + /*************************************************************************** + ** Unexpected exceptions remain test failures rather than rejected admissions. + ***************************************************************************/ + private static String outcome(CountDownLatch start, Callable operation) throws Exception + { + start.await(); + try + { + operation.call(); + return ("accepted"); + } + catch(PrintingException exception) + { + assertEquals(429, exception.getStatus()); + return ("quota"); + } + } + + + + /*************************************************************************** + ** Explicit arguments keep each boundary under test visible at its call site. + ***************************************************************************/ + private static PrintingAdmissionLimits limits(int deviceJobs, int tenantJobs, long deviceBytes, long tenantBytes, long deviceRetained, + long tenantRetained, int deviceRate, int tenantRate, int devicePrinters, int tenantPrinters) + { + return (new PrintingAdmissionLimits(deviceJobs, tenantJobs, deviceBytes, tenantBytes, deviceRetained, tenantRetained, deviceRate, tenantRate, devicePrinters, tenantPrinters)); + } + + + + /*************************************************************************** + ** Count only fixed test-owned tables. + ***************************************************************************/ + private static int count(DriverDataSource source, String table) throws Exception + { + return (Integer.parseInt(scalar(source, "SELECT COUNT(*) FROM " + table))); + } + + + + /*************************************************************************** + ** Parameterized inspection avoids document reads when checking receipts. + ***************************************************************************/ + private static String scalar(DriverDataSource source, String sql, Object... values) throws Exception + { + try(var connection = source.getConnection(); var statement = connection.prepareStatement(sql)) + { + for(int index = 0; index < values.length; index++) + { + statement.setObject(index + 1, values[index]); + } + try(var rows = statement.executeQuery()) + { + assertTrue(rows.next()); + return (rows.getString(1)); + } + } + } + + + + /*************************************************************************** + ** Age only isolated fixture rows to exercise retention recovery. + ***************************************************************************/ + private static void execute(DriverDataSource source, String sql, Object... values) throws Exception + { + try(var connection = source.getConnection(); var statement = connection.prepareStatement(sql)) + { + for(int index = 0; index < values.length; index++) + { + statement.setObject(index + 1, values[index]); + } + statement.executeUpdate(); + } + } + + + + /*************************************************************************** + ** Isolated state shared only by one test and its two service instances. + ***************************************************************************/ + private record Fixture(DriverDataSource source, PrintingService service, CountingDocuments documents, String first, String second) + { + } + + + + /*************************************************************************** + ** Count object writes and inject ambiguous storage failures without network. + ***************************************************************************/ + private static class CountingDocuments extends PrintingDocumentStorage + { + private final Map objects = new ConcurrentHashMap<>(); + private final AtomicInteger puts = new AtomicInteger(); + private final AtomicInteger gets = new AtomicInteger(); + private boolean failUpload; + private boolean failDelete; + + + + /*********************************************************************** + ** Reference ownership uses the same tenant prefix as production. + ***********************************************************************/ + CountingDocuments() + { + super(new QInstance(), "tenant-a", true); + } + + + + /*********************************************************************** + ** Metadata validation belongs to the separate native storage tests. + ***********************************************************************/ + @Override + public String binding(String table) + { + return ("quota-fixture"); + } + + + + /*********************************************************************** + ** A failing upload can still have created the entire remote object. + ***********************************************************************/ + @Override + public void put(String table, String reference, String format, byte[] content) throws SQLException + { + puts.incrementAndGet(); + objects.put(reference, content.clone()); + if(failUpload) + { + throw new SQLException("Synthetic upload acknowledgement failure"); + } + } + + + + /*********************************************************************** + ** Read immutable synthetic bytes for the retry path. + ***********************************************************************/ + @Override + public byte[] get(String table, String reference) throws SQLException + { + gets.incrementAndGet(); + byte[] content = objects.get(reference); + if(content == null) + { + throw new SQLException("Synthetic object was removed"); + } + return (content.clone()); + } + + + + /*********************************************************************** + ** Failed cleanup must keep its reservation; successful deletion is exact. + ***********************************************************************/ + @Override + public void delete(String table, String reference) throws SQLException + { + if(failDelete) + { + throw new SQLException("Synthetic delete failure"); + } + objects.remove(reference); + } + } +} -- 2.54.0 From 78e09115cb613a8b4746acd437b63002f98a040b Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 20:10:47 -0500 Subject: [PATCH 08/35] feat: connect authenticated QQQ screens to the local printing host Use native table authentication and scheduling with preserved paired H2 data, tenant authorization and strict browser-origin checks. Verify both H2 identifier conventions and account/session boundaries. Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 3 +- README.md | 8 +- docs/CI-COMPATIBILITY.md | 12 + docs/LOCAL-HOST.md | 65 +++++ docs/SESSION-STATE.md | 6 +- docs/TODO.md | 16 +- .../printing/PrintingLocalAuthentication.java | 78 +++++ .../qbits/printing/PrintingLocalHost.java | 246 ++++++++++++++++ .../qbits/printing/PrintingLocalSettings.java | 93 ++++++ .../qbits/printing/PrintingLocalHostTest.java | 268 ++++++++++++++++++ 10 files changed, 786 insertions(+), 9 deletions(-) create mode 100644 docs/CI-COMPATIBILITY.md create mode 100644 docs/LOCAL-HOST.md create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingLocalAuthentication.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingLocalSettings.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingLocalHostTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f9e52..38b48b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.1.4-rc.1 — unreleased +- Add an authenticated loopback QQQ host that shares durable paired devices/jobs with native screens and retention scheduling, preserving the separate synthetic UI demo. - Make native enrollment and test-print execution replay-safe and retain known duplicate-output risk after later failures. - Strictly validate print options, inventory formats/status and event bounds before persistence; recheck current queue format support at claim time. - Add dedicated S3 document storage through the host's QQQ storage API, tenant object namespaces and immutable destination/reference checks. @@ -9,7 +10,7 @@ - Use named facility choices during enrollment and revalidate them under the current user's tenant/record permissions. - Require release security/CodeQL gates, verified artifact promotion and build provenance; report the real packaged QBit version. - Enforce configurable atomic queue/storage/rate budgets and cumulative printer limits, with bounded management pagination and stable replay at capacity. -- Full local verification passed 62 tests with zero failures/errors/skips and Checkstyle violations. Final hosted checks remain pending; this entry does not claim release or live storage migration. +- Full local verification passed 65 tests with zero failures/errors/skips and Checkstyle violations. Final hosted checks remain pending; this entry does not claim release or live storage migration. ## 0.1.3-rc.1 — 2026-10-30 diff --git a/README.md b/README.md index 92485a0..b0e1e10 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ java -cp $uiClasspath com.kingsrook.qbits.printing.PrintingUiDemo Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator. Set JVM property `-Dprinting.ui.port=` before the main class to choose another port. This preview is distinct from the credential-protected protocol demo on port 18081; neither replaces deployment-specific authentication and authorization. +## Authenticated local application + +`PrintingLocalHost` combines the real QQQ dashboard, native processes, authenticated sign-in and device routes against one durable H2 database. It binds only to loopback, uses QQQ table-based authentication, and starts the native Quartz retention schedule through `QApplicationLauncher`. Existing pairing and job history are retained when the database and tenant are preserved. See [local host setup](docs/LOCAL-HOST.md) for explicit credentials, the required frontend runtime profile and a reviewed cutover procedure. + +The protocol-only `PrintingDemo` and synthetic, mock-authenticated `PrintingUiDemo` remain separate. Do not point the synthetic demo at real paired printer data. The local host retains database document storage until an actual dedicated S3 binding is configured in a deployment-owned application; it does not claim a remote cloud deployment. + ## Add to a QQQ application For a source checkout, `mvnw.cmd install` (or `bash ./mvnw install`) installs the verified library into your local Maven repository. For a GitHub release, download the JAR, matching POM and `SHA256SUMS`, verify the checksums, then install those artifacts locally or through your organization's artifact repository. Example using the release files in the current directory: @@ -87,7 +93,7 @@ new PrintingQBitProducer().withConfig(new PrintingQBitConfig() .withStorageTableName("printing_documents") // existing dedicated S3 storage table .withSchedulerName("appScheduler") // existing host scheduler .withService(printing)).produce(qInstance, "printing"); -server.addRouteProvider(new PrintingRoutes(printing, context -> hostPrintingAdminGuard(context))); +server.withAdditionalRouteProviders(List.of(new PrintingRoutes(printing, context -> hostPrintingAdminGuard(context)))); ``` `hostPrintingAdminGuard` is the host's own authenticated administrator policy, not a supplied library method. The service is bound to one tenant; use a separate host service instance for each tenant. Device token verification and job ownership are enforced on every device operation. Management service methods are intended for trusted application code; enforce user/facility authorization before calling them. diff --git a/docs/CI-COMPATIBILITY.md b/docs/CI-COMPATIBILITY.md new file mode 100644 index 0000000..5d94912 --- /dev/null +++ b/docs/CI-COMPATIBILITY.md @@ -0,0 +1,12 @@ +# Workflow compatibility — 2026-10-30 + +The `zizmor/self-repository` advisory recommends GitHub's newer `$/` syntax. This repository currently uses `./.github/workflows/...` **only for job-level reusable workflow calls**. GitHub documents that both forms select the same commit as the calling workflow. These are not step-level local actions that resolve from a potentially different workspace checkout. + +The latest released actionlint, **1.7.12**, rejects `$/` reusable workflow calls. This was reproduced locally with the pinned version; the unchanged `./` calls pass. The upstream compatibility issue is still open. Retaining the supported job-level form preserves full actionlint validation of the called workflow, including its inputs and permissions, without suppressing the syntax rule or disabling any security check. + +The advisory is reviewed and deferred for this specific use. No global zizmor exception is added, no branch requirement is removed, and no administrator bypass is used. External actions and workflows remain pinned to reviewed full commit hashes. + +Adopt `$/` when a reviewed actionlint release supports it, update the checksum-pinned tool, and verify all PR and release workflows. Reassess separately if a step-level local or composite action is introduced; this decision does not cover that case. + +Sources: [GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_iduses), [actionlint issue 711](https://github.com/rhysd/actionlint/issues/711), [current actionlint release](https://github.com/rhysd/actionlint/releases/tag/v1.7.12). + diff --git a/docs/LOCAL-HOST.md b/docs/LOCAL-HOST.md new file mode 100644 index 0000000..c6a6e59 --- /dev/null +++ b/docs/LOCAL-HOST.md @@ -0,0 +1,65 @@ +# Authenticated local QQQ printing host + +`com.kingsrook.qbits.printing.PrintingLocalHost` runs real QQQ screens and the printing protocol together. Native sign-in verifies a configured account with QQQ's table-based authentication. Only that verified account receives `printing_admin` and the configured `printing_tenant`. Authentication/session tables are protected by QQQ's system-table permissions, and passwords are stored only as native password hashes. This host always binds to `127.0.0.1`; remote deployment needs a separately configured QQQ application and HTTPS ingress. + +## Three distinct launch targets + +| Main class | Data and authentication | Purpose | +| --- | --- | --- | +| `PrintingDemo` | Durable H2, bearer-protected management API, no native QQQ UI | Protocol/hardware development | +| `PrintingUiDemo` | Isolated in-memory synthetic data, mock administrator, no device routes | Native UI demonstration only | +| `PrintingLocalHost` | Durable H2 shared by native QQQ and device routes, real password/session authentication | Authenticated local application | + +The local host adds its own `printing_admin_user` and `printing_admin_session` tables. It preserves existing printing tables, device credential hashes, receipt history and tenant identity. Physical table/column names are mapped to the existing H2 case convention; it does not rename an uppercase database or change `DATABASE_TO_UPPER`. Every host instance uses a distinct internal backend name to avoid cross-instance provider-cache collisions in QQQ RC20. + +## Required configuration + +| Environment variable | Meaning | +| --- | --- | +| `QQQ_PRINT_JDBC_URL` | Required `jdbc:h2:file:` URL. Use an absolute private path and `;IFEXISTS=TRUE` when upgrading an existing paired database. | +| `QQQ_PRINT_ADMIN_TOKEN` | Existing management bearer token, at least 24 characters. Separate from the browser password. | +| `QQQ_PRINT_UI_USERNAME` | Explicit local administrator username, up to 200 characters, with no colon/control characters. | +| `QQQ_PRINT_UI_INITIAL_PASSWORD` | Required only when the authentication user table is empty; 16–1024 characters. Existing passwords are never reset by this variable. Omit it after bootstrap. | +| `QQQ_PRINT_TENANT` | Default `demo`; preserve the exact tenant used by the paired service. | +| `QQQ_PRINT_PORT` | Default `18081`; loopback binding is mandatory. | +| `QQQ_PRINT_DB_USER` / `QQQ_PRINT_DB_PASSWORD` | Existing H2 credentials; defaults `sa` / empty for compatibility with the development database. Protect the database file with host filesystem permissions. | + +There is no default browser password and no account chosen automatically. Startup fails when the configured username differs from an initialized account. A password reset or account change needs an explicit administrative procedure; restarting with new environment values does not perform one. Do not place passwords or tokens in source files, shell history, command arguments or shared logs. Use the host's existing protected credential-loading mechanism. Bootstrap credentials are not retained in the application's settings after hashing. + +## Build and run + +Use Java 21 and the Maven wrapper. The `ui-demo` profile supplies the real frontend-next runtime assets for both UI-capable launch targets: + +```powershell +.\mvnw.cmd -Pui-demo verify dependency:build-classpath '-Dmdep.outputFile=target/local-host-classpath.txt' +$dependencies = (Get-Content target/local-host-classpath.txt -Raw).Trim() +& "$env:JAVA_HOME/bin/java.exe" -cp "target/classes;$dependencies" com.kingsrook.qbits.printing.PrintingLocalHost +``` + +Set the required environment values securely before launch. Maven contract tests require the documented sibling protocol fixture checkout or `printing.protocol.fixtures` override. Browse to `http://localhost:18081/` (or the configured port) and use the native username/password screen. The existing Windows connector continues using the same server origin and device token; it does not receive the browser password or management token. + +This command runs the host in the foreground. `QApplicationLauncher` owns graceful HTTP/scheduler shutdown. It does not install an operating-system service or auto-start registration; the deploying operator must provide supervision, protected credential loading, logs and backups for unattended use. + +## Cut over an existing paired protocol server + +1. Identify the exact existing database, tenant, management credential and connector URL. Inspect all pending and uncertain jobs through bounded management pages before a maintenance window. +2. Stop only that paired Java host. Back up its closed H2 database and protected configuration. Never copy a live H2 file and treat the copy as a guaranteed consistent backup. +3. Validate the new host against a separate backup copy and isolated port first. Keep device transport unreachable by real agents during that check; do not enqueue tests or seed synthetic work in the real database. +4. Provision the explicit browser account, then start the local host with the original database, tenant and port. Use `IFEXISTS=TRUE` to prevent a typo silently creating an empty database. The service's additive schema initialization runs during startup; older writers must remain stopped. +5. Verify sign-in, paired records in native Devices/Printers/Print jobs, connector heartbeat, named actions, retention settings and logout. Existing device enrollment must remain intact. Do not infer physical printing from queue acceptance. + +Existing facility IDs are retained. The standalone enrollment form offers the named **Default facility** for new enrollment. This local host does not invent names for existing facility codes or import a production facility directory; a full application should supply its actual facility possible-value source. + +## Browser and API boundaries + +QQQ supplies password verification, failed-sign-in throttling, HttpOnly sessions, session expiry and logout. The local authorization subclass calls native verification first and maps only the configured account to printing permissions. Other valid accounts cannot gain those permissions. Native QQQ permission and tenant checks still apply to every table/process. + +The host rejects non-loopback Host headers, cross-origin Origin headers and cross-site browser requests. Cookie-authenticated unsafe requests require an exact same-origin Origin header, including logout and native process actions. Explicit bearer/device API calls without ambient cookies remain usable from the connector. Use one loopback hostname consistently in the browser; `localhost` and `127.0.0.1` are different browser origins. These controls do not turn a local HTTP host into an Internet-facing service. + +## Retention, storage and remaining deployment work + +The native Quartz scheduler runs the existing bounded cleanup process every five minutes while the host runs. Settings and cleanup observations are persisted in the same database; the default is 365 days after successful completion. Active, failed, uncertain and unknown-completion work remains protected. Scheduler registration is rebuilt from QQQ metadata on restart; no new ad hoc timer or scheduler database is introduced. + +This local host deliberately leaves the S3 binding unconfigured. Native settings report legacy database storage. New and existing documents therefore remain in the database; merely running this host does not move documents to S3. A deployed application must supply its existing dedicated `printing_*` S3 table/backend and scheduler configuration, with bucket/IAM/tenant separation described in [storage and retention](STORAGE-AND-RETENTION.md). The host must retain historical storage bindings when changing configuration. + +Remote HTTPS deployment, real S3/IAM, production OAuth roles, PostgreSQL and operating-system supervision remain separate qualification work. Record Files source-version/actor integration remains pending its concrete public contract; standalone printing delivery storage must not own or delete canonical attachments. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 4123da7..82e2e2b 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -2,11 +2,13 @@ ## Audit candidate 0.1.4-rc.1 -Final isolated verification passed **62 tests, zero failures/errors/skips and zero Checkstyle violations**. The independent baseline scan's two server findings were remediated: configurable atomic tenant/device admission covers outstanding work, retained bytes and hourly allocations; cumulative printer limits and cursor pagination bound inventory. A fresh reviewer found a PostgreSQL wide-index regression, corrected before the final gate. Durable upload accounting, legacy unknown reservations, stable replay and single-connection cleanup have regression coverage. +Final isolated verification passed **65 tests, zero failures/errors/skips and zero Checkstyle violations**, including the frontend runtime profile. The independent baseline scan's two server findings were remediated: configurable atomic tenant/device admission covers outstanding work, retained bytes and hourly allocations; cumulative printer limits and cursor pagination bound inventory. A fresh reviewer found a PostgreSQL wide-index regression, corrected before the final gate. Durable upload accounting, legacy unknown reservations, stable replay and single-connection cleanup have regression coverage. + +The new authenticated `PrintingLocalHost` connects native QQQ screens and device routes to the same durable H2 database. It uses native table-based authentication, preserves the existing account/pairing, rejects cross-origin cookie mutations and DNS rebinding, and starts QQQ's existing retention scheduler. Tests cover uppercase and lowercase H2 identifiers, native login/logout and tenant access, API heartbeat, scheduler startup and unchanged password hashes. A fresh read-only review found no actionable confirmed finding. Browser rendering and an actual scheduled cleanup tick remain deployment checks. The synthetic `PrintingUiDemo` stays separate. See [local deployment](LOCAL-HOST.md). Earlier fixes add replay-safe native processes, validated facility selectors, strict wire fields, duplicate-risk preservation, dedicated QQQ S3 storage and native admin retention with a **365-day default**. Active, failed and uncertain work remains protected. Stop older writers before admission-accounting migration; existing content is preserved. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). -Draft PR #10 contains earlier audit/CI guard commits that passed hosted checks; final remediation commits need fresh checks. No live database migration, S3 provisioning, purge, merge, release or host deployment has occurred. Real PostgreSQL/S3, remote host authentication, multi-node operation and sustained physical throughput remain unqualified. +Draft PR #10 passed every required hosted check at `aeac1a6`; its CI JAR/POM checksums were verified. The authenticated local-host addition requires fresh hosted checks before deployment. No live database migration, S3 provisioning, purge, merge, release or host deployment has occurred. Real PostgreSQL/S3, remote host authentication, multi-node operation and sustained physical throughput remain unqualified. All main-branch requirements apply to administrators. The low-severity same-repository workflow advisory has a documented [compatibility assessment](CI-COMPATIBILITY.md); no security check is disabled. Master coordination at `R:\agent-comms\qqq-cloud-printing.md` is acknowledged. Record Files owns indefinitely retained originals; printing owns its dedicated delivery copies. Their compiled source-reader/actor authorization contract remains pending. Standalone byte submission continues. Installed Windows baseline remains 0.1.7; unsigned prereleases are user-approved. diff --git a/docs/TODO.md b/docs/TODO.md index a4a03b2..7b7706e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -3,8 +3,11 @@ - [x] Finish audit regressions for native-process replay, strict options/inventory validation, retained duplicate risk and event field bounds; full gate passes 53 tests. - [x] Implement admin-configurable document retention (one-year default), bounded scheduled cleanup and existing QQQ object-storage integration, retaining history/receipts and protecting active or uncertain output. - [ ] Integrate Record Files' authorized immutable-version reader after its contract is implemented; define async source revocation/correlation and eliminate host-outbox byte copies for that path. -- [ ] Verify audit release gates and provenance; complete independent security scan and deployed-host qualification. -- [x] Audit PR #10 at 1655d4e passes all hosted checks; download and verify JAR/POM artifacts. Tagged-release provenance, deep scan and deployment remain pending. +- [ ] Verify tagged audit release gates, published provenance and deployed-host qualification. +- [x] Audit PR #10 at aeac1a6 passes all hosted checks; JAR/POM artifacts downloaded and verified. Authenticated local-host addition needs fresh hosted checks. +- [x] Complete the independent baseline scan and current-source reviews of all five findings and remediations. +- [x] Add authenticated native QQQ local host sharing paired data with device routes and the existing scheduler; 65 tests/zero Checkstyle pass and independent source review found no blocker. +- [ ] Deploy the authenticated local host with a closed-database backup; verify browser rendering, preserved pairing and an actual scheduled cleanup tick. - [x] Versioned shared contract and HTTP happy-path test. - [x] Durable enrollment, device-scoped authentication, inventory and printer approval. @@ -20,11 +23,13 @@ - [x] Prepare public repository documentation, license, CI and GitHub prerelease artifacts. - [x] Verify remote CI/security and publish v0.1.0-rc.1 JAR/POM/checksum assets. - [x] Client-owned device/printer name, description and location synchronized into native QQQ metadata. -- [ ] Logical destinations and object-storage document backend. +- [x] Implement the dedicated QQQ object-storage document backend; live S3/IAM qualification remains pending configuration. +- [ ] Logical destination mappings. - [x] Run the coordinated Java gate for profile migration, long text and 10,000-row bounded-history regressions; included in the passing 28-test v0.1.3-rc.1 gate. - [ ] Qualify sustained throughput separately from synthetic bounded-history tests. - [x] Device-scoped test/retry/cancel/queued-job routes and native record/table cancellation/reprint review, explicit duplicate-risk consent and retry idempotency. -- [ ] Server-side job expiry/document retention and operational alerts. +- [x] Implement admin document retention and bounded scheduled cleanup, default 365 days. +- [ ] Server-side job expiry and operational alerts. - [ ] PostgreSQL integration and multi-node stress tests; migration/version management. - [ ] Resolve upstream/cached qbit-build-parent interpolation warning for Maven 3.10. - [x] Add native Printing home with scoped operational widgets and actionable links. @@ -52,4 +57,5 @@ The Windows gate, MSI rebuild, installed UI acceptance, release evidence and fre - [x] Verify published Windows 0.1.7 assets; all three built-in physical paths are confirmed: laser PDF, Zebra ZPL and Zebra PDF. - [ ] Obtain explicit barcode scan and measured alignment confirmation; successful spooling is not physical acceptance. -- [ ] Complete independent review and deployment qualification after the five baseline security findings are remediated; current details are in SESSION-STATE.md. +- [x] Complete independent review after the five baseline security findings are remediated; current details are in SESSION-STATE.md. +- [ ] Complete deployed-host and real S3 qualification; source tests are not deployment evidence. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingLocalAuthentication.java b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalAuthentication.java new file mode 100644 index 0000000..94d4f8e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalAuthentication.java @@ -0,0 +1,78 @@ +/* + * 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.util.Map; +import java.util.Set; +import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.TableBasedAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.TableBasedAuthenticationModule; + + +/******************************************************************************* + ** Authorization mapping only; password/session verification remains native QQQ. + *******************************************************************************/ +public class PrintingLocalAuthentication extends TableBasedAuthenticationModule +{ + /*************************************************************************** + ** Only the explicitly configured, successfully verified account gets access. + ***************************************************************************/ + @Override + public QSession createSession(QInstance instance, Map context) throws QAuthenticationException + { + QSession session = super.createSession(instance, context); + session.setPermissions(Set.of()); + if(!(instance.getAuthentication() instanceof Metadata metadata) || session.getUser() == null + || !metadata.username.equals(session.getUser().getIdReference())) + { + super.logout(instance, session.getUuid()); + throw new QAuthenticationException("This account is not authorized for local printing administration"); + } + session.setPermissions(Set.of("printing_admin")); + session.withSecurityKeyValue("printing_tenant", metadata.tenant); + return (session); + } + + + + /*************************************************************************** + ** Host-only identity mapping; no password or device credential in metadata. + ***************************************************************************/ + static class Metadata extends TableBasedAuthenticationMetaData + { + private final String username; + private final String tenant; + + + + /*********************************************************************** + ** Keep identity fields out of ordinary JavaBean frontend serialization. + ***********************************************************************/ + Metadata(String username, String tenant) + { + this.username = username; + this.tenant = tenant; + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java new file mode 100644 index 0000000..6e99cff --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java @@ -0,0 +1,246 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.Connection; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope; +import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.quartz.QuartzSchedulerMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.TableBasedAuthenticationModule; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; +import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig; +import io.javalin.http.Context; +import io.javalin.http.ForbiddenResponse; + + +/******************************************************************************* + ** Authenticated loopback host: native QQQ screens and paired device routes share + ** one durable database. This is not a remote production deployment template. + *******************************************************************************/ +public class PrintingLocalHost extends AbstractQQQApplication +{ + static final String USER_TABLE = "printing_admin_user"; + static final String SESSION_TABLE = "printing_admin_session"; + static final String BACKEND = "printing-local-db"; + static final String SCHEDULER = "printing-local-scheduler"; + private final String backendName = BACKEND + "-" + java.util.UUID.randomUUID(); + private final PrintingLocalSettings settings; + private final DriverDataSource source; + private final PrintingService service; + + + + /*************************************************************************** + ** No default password, mock identity or synthetic records are provisioned. + ***************************************************************************/ + public static void main(String[] args) throws Exception + { + PrintingLocalHost host = new PrintingLocalHost(PrintingLocalSettings.fromEnvironment(System.getenv())); + QApplicationLauncher.run(host, new QApplicationLauncherConfig() + .withServerCustomizer(server -> host.configure(server, true))); + } + + + + /*************************************************************************** + ** Construction opens only the explicitly configured database. + ***************************************************************************/ + PrintingLocalHost(PrintingLocalSettings settings) throws Exception + { + this.settings = new PrintingLocalSettings(settings.jdbcUrl(), settings.databaseUser(), settings.databasePassword(), settings.tenant(), + settings.port(), settings.administratorToken(), settings.username(), null); + source = new DriverDataSource(settings.jdbcUrl(), settings.databaseUser(), settings.databasePassword()); + service = new PrintingService(source, settings.tenant()); + service.initialize(); + initializeAuthentication(settings.initialPassword()); + } + + + + /*************************************************************************** + ** Native authentication and an instance-owned Quartz schedule are registered. + ***************************************************************************/ + @Override + public QInstance defineQInstance() throws QException + { + try + { + QInstance instance = new QInstance(); + instance.setFailOnMetaDataProducerError(true); + instance.setHomeAppName("printing"); + instance.setBranding(new QBrandingMetaData().withAppName("QQQ Printing")); + instance.addBackend(new RDBMSBackendMetaData().withName(backendName).withVendor("h2").withHostName("local") + .withDatabaseName("printing").withUsername(settings.databaseUser()).withPassword(settings.databasePassword()).withJdbcUrl(settings.jdbcUrl())); + PrintingLocalAuthentication.Metadata auth = new PrintingLocalAuthentication.Metadata(settings.username(), settings.tenant()); + auth.withUserTableName(USER_TABLE).withSessionTableName(SESSION_TABLE).withInactivityTimeoutSeconds(3600); + auth.withName("local-printing").withModuleCodeReference(new QCodeReference(PrintingLocalAuthentication.class)); + instance.registerAuthenticationProvider(AuthScope.instanceDefault(), auth); + instance.addTable(auth.defineStandardUserTable(backendName)); + instance.addTable(auth.defineStandardSessionTable(backendName)); + Properties properties = new Properties(); + properties.setProperty("org.quartz.scheduler.instanceName", SCHEDULER); + properties.setProperty("org.quartz.threadPool.threadCount", "2"); + instance.addScheduler(new QuartzSchedulerMetaData().withProperties(properties).withName(SCHEDULER)); + new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backendName).withService(service) + .withSchedulerName(SCHEDULER)).produce(instance, "printing"); + mapPhysicalNames(instance); + return (instance); + } + catch(Exception exception) + { + throw new QException("Unable to initialize the authenticated local printing host", exception); + } + } + + + + /*************************************************************************** + ** Public middleware integration; a test may omit frontend assets only. + ***************************************************************************/ + void configure(QApplicationJavalinServer server, boolean frontend) + { + PrintingRoutes routes = new PrintingRoutes(service, context -> MessageDigest.isEqual( + settings.administratorToken().getBytes(StandardCharsets.UTF_8), PrintingRoutes.bearer(context).getBytes(StandardCharsets.UTF_8))); + server.withPort(settings.port()).withServeFrontendNext(frontend).withServeFrontendMaterialDashboard(false) + .withAdditionalRouteProviders(List.of(routes)).withJavalinConfigCustomizer(config -> + { + config.jetty.host = "127.0.0.1"; + config.http.maxRequestSize = 29 * 1024 * 1024; + config.routes.before(PrintingLocalHost::guardOrigin); + }); + } + + + + /*************************************************************************** + ** Reject DNS rebinding and cross-origin cookie mutations on real printers. + ** Explicit API credentials without ambient cookies remain usable locally. + ***************************************************************************/ + static void guardOrigin(Context context) + { + String host = context.header("Host"); + int port = context.req().getLocalPort(); + Set hosts = port == 80 ? Set.of("localhost", "127.0.0.1", "localhost:80", "127.0.0.1:80") + : Set.of("localhost:" + port, "127.0.0.1:" + port); + if(host == null || !hosts.contains(host.toLowerCase(Locale.ROOT))) + { + throw new ForbiddenResponse("The local printing host accepts only loopback host names"); + } + String origin = context.header("Origin"); + if(origin != null && !origin.equals("http://" + host)) + { + throw new ForbiddenResponse("Cross-origin access to local printing is not allowed"); + } + if("cross-site".equalsIgnoreCase(context.header("Sec-Fetch-Site"))) + { + throw new ForbiddenResponse("Cross-site access to local printing is not allowed"); + } + boolean unsafe = !Set.of("GET", "HEAD", "OPTIONS").contains(context.method().name()); + boolean cookie = context.cookie("sessionUUID") != null || context.cookie("sessionId") != null; + if(unsafe && cookie && origin == null) + { + throw new ForbiddenResponse("Cookie-authenticated changes require a same-origin request"); + } + } + + + + /*************************************************************************** + ** Add native auth tables; an existing account is never implicitly reset. + ***************************************************************************/ + private void initializeAuthentication(String initialPassword) throws Exception + { + try(Connection connection = source.getConnection(); var statement = connection.createStatement()) + { + statement.execute("CREATE TABLE IF NOT EXISTS printing_admin_user (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, username VARCHAR(200) NOT NULL UNIQUE, passwordHash VARCHAR(1024) NOT NULL, fullName VARCHAR(512), createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)"); + statement.execute("CREATE TABLE IF NOT EXISTS printing_admin_session (id VARCHAR(100) PRIMARY KEY, userId INTEGER NOT NULL REFERENCES printing_admin_user(id), accessTimestamp TIMESTAMP WITH TIME ZONE, createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)"); + try(var users = statement.executeQuery("SELECT username FROM printing_admin_user")) + { + boolean found = false; + boolean any = false; + while(users.next()) + { + any = true; + found |= settings.username().equals(users.getString(1)); + } + if(found) + { + return; + } + if(any) + { + throw new IllegalArgumentException("The configured local administrator does not match the initialized account; no account was changed"); + } + } + String password = initialPassword; + if(password == null || password.length() < 16 || password.length() > 1024) + { + throw new IllegalArgumentException("First startup requires QQQ_PRINT_UI_INITIAL_PASSWORD of 16 to 1024 characters; no default password is provided"); + } + try(var insert = connection.prepareStatement("INSERT INTO printing_admin_user(username,passwordHash,fullName,createDate,modifyDate) VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)")) + { + insert.setString(1, settings.username()); + insert.setString(2, TableBasedAuthenticationModule.PasswordHasher.createHashedPassword(password)); + insert.setString(3, "Local printing administrator"); + insert.executeUpdate(); + } + } + } + + + + /*************************************************************************** + ** Preserve the existing H2 identifier convention without renaming live tables. + ***************************************************************************/ + private void mapPhysicalNames(QInstance instance) throws Exception + { + try(Connection connection = source.getConnection()) + { + boolean uppercase = connection.getMetaData().storesUpperCaseIdentifiers(); + for(QTableMetaData table : instance.getTables().values()) + { + if(backendName.equals(table.getBackendName())) + { + table.withBackendDetails(new RDBMSTableBackendDetails().withTableName(uppercase ? table.getName().toUpperCase(Locale.ROOT) : table.getName())); + for(var field : table.getFields().values()) + { + field.withBackendName(uppercase ? field.getName().toUpperCase(Locale.ROOT) : field.getName()); + } + } + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingLocalSettings.java b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalSettings.java new file mode 100644 index 0000000..16362d3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalSettings.java @@ -0,0 +1,93 @@ +/* + * 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.util.Map; + + +/******************************************************************************* + ** Explicit local-host configuration. Never render credentials in diagnostics. + *******************************************************************************/ +record PrintingLocalSettings(String jdbcUrl, String databaseUser, String databasePassword, String tenant, + int port, String administratorToken, String username, String initialPassword) +{ + /*************************************************************************** + ** Environment launch supports a durable local H2 file, never a remote bind. + ***************************************************************************/ + static PrintingLocalSettings fromEnvironment(Map environment) + { + String jdbc = required(environment, "QQQ_PRINT_JDBC_URL"); + if(!jdbc.startsWith("jdbc:h2:file:") || jdbc.contains("\n") || jdbc.contains("\r")) + { + throw new IllegalArgumentException("QQQ_PRINT_JDBC_URL must identify a durable local H2 file"); + } + int port = Integer.parseInt(environment.getOrDefault("QQQ_PRINT_PORT", "18081")); + if(port < 1 || port > 65535) + { + throw new IllegalArgumentException("QQQ_PRINT_PORT must be between 1 and 65535"); + } + return (new PrintingLocalSettings(jdbc, environment.getOrDefault("QQQ_PRINT_DB_USER", "sa"), + environment.getOrDefault("QQQ_PRINT_DB_PASSWORD", ""), environment.getOrDefault("QQQ_PRINT_TENANT", "demo"), port, + required(environment, "QQQ_PRINT_ADMIN_TOKEN"), required(environment, "QQQ_PRINT_UI_USERNAME"), environment.get("QQQ_PRINT_UI_INITIAL_PASSWORD"))); + } + + + + /*************************************************************************** + ** Tests may use isolated memory databases through this package-private API. + ***************************************************************************/ + PrintingLocalSettings + { + if(administratorToken == null || administratorToken.length() < 24 || username == null || username.isBlank() + || username.length() > 200 || username.contains(":") || username.chars().anyMatch(Character::isISOControl) + || tenant == null || tenant.isBlank() || tenant.length() > 200) + { + throw new IllegalArgumentException("Explicit administrator token, username and tenant are required"); + } + } + + + + /*************************************************************************** + ** Fail without including the supplied value. + ***************************************************************************/ + private static String required(Map values, String name) + { + String value = values.get(name); + if(value == null || value.isBlank()) + { + throw new IllegalArgumentException("Set " + name); + } + return (value); + } + + + + /*************************************************************************** + ** Records must not expose their secret components through default toString. + ***************************************************************************/ + @Override + public String toString() + { + return ("PrintingLocalSettings[loopbackPort=" + port + ", credentials=redacted]"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingLocalHostTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingLocalHostTest.java new file mode 100644 index 0000000..39b31d2 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingLocalHostTest.java @@ -0,0 +1,268 @@ +/* + * 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.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.TableBasedAuthenticationModule; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig; +import io.javalin.Javalin; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Isolated durable H2 and ephemeral HTTP prove native auth and shared records. + *******************************************************************************/ +class PrintingLocalHostTest +{ + private static final String TOKEN = "synthetic-admin-token-for-isolated-tests"; + private static final String PASSWORD = "synthetic-login-password-12345"; + + @TempDir + Path directory; + + + + /*************************************************************************** + ** Real native table auth, origin protection, scheduler and transport coexist. + ***************************************************************************/ + @Test + void nativeHostPreservesPairedDataAndRequiresVerifiedSameOriginAdministration() throws Exception + { + exerciseHost("legacy", ""); + } + + + + /*************************************************************************** + ** Explicit case-preserving H2 hosts retain their existing physical names too. + ***************************************************************************/ + @Test + void nativeHostSupportsCasePreservingDatabase() throws Exception + { + exerciseHost("case-preserving", ";DATABASE_TO_UPPER=FALSE"); + } + + + + /*************************************************************************** + ** The same acceptance checks apply to both supported H2 naming conventions. + ***************************************************************************/ + private void exerciseHost(String file, String options) throws Exception + { + String jdbc = "jdbc:h2:file:" + directory.resolve(file).toString().replace('\\', '/') + options; + DriverDataSource source = new DriverDataSource(jdbc, "sa", ""); + PrintingService paired = new PrintingService(source, "demo"); + paired.initialize(); + JSONObject enrolled = paired.enroll(paired.createEnrollment("legacy-facility", "Synthetic station").put("deviceName", "Synthetic station").put("agentVersion", "test")); + String device = enrolled.getString("deviceId"); + paired.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic laser").put("formats", new JSONArray(List.of("PDF"))))); + PrintingLocalHost host = new PrintingLocalHost(settings(jdbc, PASSWORD)); + AtomicReference javalin = new AtomicReference<>(); + QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false) + .withServerCustomizer(server -> + { + host.configure(server, false); + server.withJavalinConfigurationCustomizer(javalin::set); + })); + String base = "http://127.0.0.1:" + javalin.get().port(); + try(HttpClient client = HttpClient.newHttpClient()) + { + assertTrue(launcher.getStartedServiceNames().contains(QApplicationLauncher.SCHEDULE_MANAGER_SERVICE_NAME)); + assertEquals("0 */5 * * * ?", launcher.getQInstance().getProcess(PrintingRetentionProcesses.SCHEDULED).getSchedule().getCronExpression()); + assertRejectedHost(javalin.get().port()); + assertEquals(401, send(client, base, "/qqq/v1/table/printing_device/count", null, null, null).statusCode()); + assertEquals(401, send(client, base, "/qqq/v1/manageSession", basic("operator", "wrong"), null, null).statusCode()); + HttpResponse login = send(client, base, "/qqq/v1/manageSession", basic("operator", PASSWORD), null, base); + assertEquals(200, login.statusCode(), login.body()); + String cookie = login.headers().allValues("set-cookie").stream().filter(value -> value.startsWith("sessionUUID=")).findFirst().orElseThrow().split(";", 2)[0]; + String sessionId = cookie.substring("sessionUUID=".length()); + assertTrue(login.headers().allValues("set-cookie").stream().anyMatch(value -> value.toLowerCase(java.util.Locale.ROOT).contains("httponly"))); + assertFalse(login.body().contains("passwordHash")); + HttpResponse count = send(client, base, "/qqq/v1/table/printing_device/count", null, cookie, base); + assertEquals(200, count.statusCode(), count.body()); + assertEquals(1, new JSONObject(count.body()).getInt("count")); + assertEquals(403, send(client, base, "/qqq/v1/table/printing_device/count", null, cookie, "https://unrelated.invalid").statusCode()); + assertEquals(403, send(client, base, "/qqq/v1/table/printing_device/count", null, cookie, null).statusCode()); + assertEquals(403, send(client, base, "/qqq/v1/manageSession", basic("operator", PASSWORD), null, "null").statusCode()); + assertEquals(403, send(client, base, "/qqq/v1/table/printing_admin_user/count", null, cookie, base).statusCode()); + HttpResponse transport = client.send(HttpRequest.newBuilder(URI.create(base + "/printing/v1/admin/devices")).header("Authorization", "Bearer " + TOKEN).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, transport.statusCode()); + assertEquals(device, new JSONObject(transport.body()).getJSONArray("devices").getJSONObject(0).getString("deviceId")); + QContext.init(launcher.getQInstance(), null); + var session = new PrintingLocalAuthentication().createSession(launcher.getQInstance(), Map.of("sessionId", sessionId)); + assertTrue(session.hasPermission("printing_admin")); + QContext.init(launcher.getQInstance(), session); + assertEquals(device, new QueryAction().execute(new QueryInput().withTableName(PrintingTables.DEVICE)).getRecords().getFirst().getValueString("id")); + assertEquals("legacy-facility", new QueryAction().execute(new QueryInput().withTableName(PrintingTables.DEVICE)).getRecords().getFirst().getValueString("facility_id")); + QContext.clear(); + assertEquals(200, send(client, base, "/qqq/v1/logout", null, cookie, base).statusCode()); + assertEquals(401, send(client, base, "/qqq/v1/table/printing_device/count", null, cookie, base).statusCode()); + var heartbeat = HttpRequest.newBuilder(URI.create(base + "/printing/v1/device/heartbeat")) + .header("Authorization", "Bearer " + enrolled.getString("deviceToken")).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(new JSONObject().put("agentVersion", "test").toString())).build(); + assertEquals(200, client.send(heartbeat, HttpResponse.BodyHandlers.discarding()).statusCode()); + assertEquals(device, paired.authenticate(enrolled.getString("deviceToken"))); + assertEquals(0, paired.jobs().length()); + verifyUnprivilegedAccountRejected(source, launcher); + } + finally + { + QContext.clear(); + launcher.stop(); + } + String originalHash = passwordHash(source); + new PrintingLocalHost(settings(jdbc, "a-different-password-must-never-reset")); + assertEquals(originalHash, passwordHash(source)); + assertThrows(IllegalArgumentException.class, () -> new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, TOKEN, "different-user", PASSWORD))); + } + + + + /*************************************************************************** + ** A hostile DNS name cannot turn a loopback listener into a trusted origin. + ***************************************************************************/ + private static void assertRejectedHost(int port) throws Exception + { + try(var socket = new java.net.Socket("127.0.0.1", port)) + { + socket.setSoTimeout(5000); + socket.getOutputStream().write("GET / HTTP/1.1\r\nHost: unrelated.invalid\r\nConnection: close\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + var reader = new java.io.BufferedReader(new java.io.InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII)); + assertTrue(reader.readLine().contains("403")); + } + } + + + + /*************************************************************************** + ** A valid password alone never grants printing permissions to another user. + ***************************************************************************/ + private static void verifyUnprivilegedAccountRejected(DriverDataSource source, QApplicationLauncher launcher) throws Exception + { + try(var connection = source.getConnection(); var insert = connection.prepareStatement("INSERT INTO printing_admin_user(username,passwordHash,fullName) VALUES (?,?,?)")) + { + insert.setString(1, "viewer"); + insert.setString(2, TableBasedAuthenticationModule.PasswordHasher.createHashedPassword(PASSWORD)); + insert.setString(3, "Synthetic viewer"); + insert.executeUpdate(); + } + QContext.init(launcher.getQInstance(), null); + var verified = new TableBasedAuthenticationModule().createSession(launcher.getQInstance(), Map.of(TableBasedAuthenticationModule.BASIC_AUTH_KEY, basic("viewer", PASSWORD).substring(6))); + assertEquals("viewer", verified.getUser().getIdReference()); + assertThrows(QAuthenticationException.class, () -> new PrintingLocalAuthentication().createSession(launcher.getQInstance(), Map.of("sessionId", verified.getUuid()))); + QContext.clear(); + } + + + + /*************************************************************************** + ** Missing bootstrap credentials fail closed; no environment secret is echoed. + ***************************************************************************/ + @Test + void explicitConfigurationAndBootstrapAreRequired() throws Exception + { + assertThrows(IllegalArgumentException.class, () -> PrintingLocalSettings.fromEnvironment(Map.of())); + assertThrows(IllegalArgumentException.class, () -> PrintingLocalSettings.fromEnvironment(Map.of("QQQ_PRINT_JDBC_URL", "jdbc:h2:mem:wrong"))); + String jdbc = "jdbc:h2:file:" + directory.resolve("empty").toString().replace('\\', '/'); + assertThrows(IllegalArgumentException.class, () -> new PrintingLocalHost(settings(jdbc, null))); + assertFalse(settings(jdbc, PASSWORD).toString().contains(PASSWORD)); + assertFalse(settings(jdbc, PASSWORD).toString().contains(TOKEN)); + } + + + + /*************************************************************************** + ** One synthetic account and ephemeral port, never physical printer access. + ***************************************************************************/ + private static PrintingLocalSettings settings(String jdbc, String password) + { + return (new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, TOKEN, "operator", password)); + } + + + + /*************************************************************************** + ** Read only the synthetic hash to prove startup never changes it. + ***************************************************************************/ + private static String passwordHash(DriverDataSource source) throws Exception + { + try(var connection = source.getConnection(); var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT passwordHash FROM printing_admin_user WHERE username='operator'")) + { + rows.next(); + return (rows.getString(1)); + } + } + + + + /*************************************************************************** + ** Test credentials stay in request headers and never command-line arguments. + ***************************************************************************/ + private static String basic(String user, String password) + { + return ("Basic " + Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8))); + } + + + + /*************************************************************************** + ** Native routes use JSON POST and explicit cookies/origin in these fixtures. + ***************************************************************************/ + private static HttpResponse send(HttpClient client, String base, String path, String authorization, String cookie, String origin) throws Exception + { + var request = HttpRequest.newBuilder(URI.create(base + path)).header("Content-Type", "application/json"); + if(authorization != null) + { + request.header("Authorization", authorization); + } + if(cookie != null) + { + request.header("Cookie", cookie); + } + if(origin != null) + { + request.header("Origin", origin); + } + return (client.send(request.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString())); + } +} -- 2.54.0 From 60ce6e4f9335da72d580aca3aec63ee656ebdbec Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 20:43:47 -0500 Subject: [PATCH 09/35] ci: verify the complete printing host runtime inventory Resolve the selected UI profile once into a Maven CycloneDX graph and fail closed when Trivy or OSV omits packages or returns malformed evidence. Keep existing vulnerability and malicious-package gates. Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- .github/scripts/check-maven-inventory.py | 119 +++++++++++++++++++++++ .github/scripts/test-maven-inventory.py | 98 +++++++++++++++++++ .github/workflows/ci.yml | 2 +- .github/workflows/security.yml | 54 ++++++---- README.md | 6 +- docs/SESSION-STATE.md | 2 + docs/TODO.md | 3 + 7 files changed, 262 insertions(+), 22 deletions(-) create mode 100644 .github/scripts/check-maven-inventory.py create mode 100644 .github/scripts/test-maven-inventory.py diff --git a/.github/scripts/check-maven-inventory.py b/.github/scripts/check-maven-inventory.py new file mode 100644 index 0000000..77374ba --- /dev/null +++ b/.github/scripts/check-maven-inventory.py @@ -0,0 +1,119 @@ +"""Fail closed unless scanners cover Maven's selected UI-host runtime graph.""" +import argparse +from collections import Counter +import json +from pathlib import Path +import re +from urllib.parse import unquote, urlsplit + + +REQUIRED = { + "com.kingsrook.qqq:qqq-frontend-next", + "com.kingsrook.qqq:qqq-backend-core", + "com.kingsrook.qqq:qqq-backend-module-filesystem", + "com.google.guava:guava", +} +OMITTED = object() + + +def resolved(text): + packages = set() + for line in text.splitlines(): + line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip().split(" -- ", 1)[0].strip() + if not line or line == "The following files have been resolved:": + continue + parts = line.split(":") + if len(parts) not in (5, 6) or parts[-1] not in ("compile", "runtime"): + raise ValueError(f"Unexpected Maven runtime coordinate: {line}") + if not all(parts) or parts[2] != "jar": + raise ValueError(f"Invalid runtime JAR coordinate: {line}") + name = parts[0] + ":" + parts[1] + if parts[0].startswith(("org.junit", "org.opentest4j", "org.apiguardian")): + raise ValueError("Test library in runtime inventory") + packages.add((name, parts[-2])) + if not REQUIRED.issubset({name for name, _ in packages}): + raise ValueError("Maven inventory is empty or lacks required UI-host dependencies") + return packages + + +def components(report): + if not isinstance(report, dict) or report.get("bomFormat") != "CycloneDX": + raise ValueError("Expected CycloneDX inventory") + packages = set() + for component in report.get("components", []): + purl = urlsplit(component.get("purl", "")) + if purl.scheme != "pkg" or not purl.path.startswith("maven/"): + raise ValueError("Missing Maven package URL") + coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1) + group, artifact = coordinate.split("/", 1) + packages.add((unquote(group) + ":" + unquote(artifact), unquote(version))) + if not packages: + raise ValueError("Empty CycloneDX inventory") + return packages + + +def check(expected, bom, trivy=OMITTED): + actual = components(bom) + if actual != expected: + raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}") + if trivy is not OMITTED: + if not isinstance(trivy, dict) or not isinstance(trivy.get("Results"), list): + raise ValueError("Expected Trivy report object with Results array") + scanned = {(p["Name"], p["Version"]) for r in trivy.get("Results", []) for p in r.get("Packages", [])} + # Trivy also reports the root project represented by SBOM metadata. + root = bom.get("metadata", {}).get("component", {}) + allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))} + if not expected.issubset(scanned) or not scanned.issubset(allowed): + raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}") + return len(expected) + + +def check_osv(expected, report): + if not isinstance(report, dict) or not isinstance(report.get("results"), list): + raise ValueError("Expected OSV report object with results array") + # OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names: + # https://github.com/google/osv-scanner/issues/3099 + # Fail on ambiguous display coordinates instead of claiming full identity proof. + projected = Counter((name.split(":", 1)[1], version) for name, version in expected) + if any(count != 1 for count in projected.values()): + raise ValueError("Ambiguous Maven coordinates in pinned OSV report format") + scanned = Counter() + for result in report["results"]: + if not isinstance(result, dict) or not isinstance(result.get("source"), dict): + raise ValueError("Missing OSV source") + if result["source"].get("type") != "sbom" or not isinstance(result["source"].get("path"), str): + raise ValueError("Expected OSV SBOM source") + if not isinstance(result.get("packages"), list): + raise ValueError("Missing OSV package inventory; use --all-packages") + for entry in result["packages"]: + if not isinstance(entry, dict) or not isinstance(entry.get("package"), dict): + raise ValueError("Malformed OSV package") + package = entry["package"] + if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")): + raise ValueError("Invalid OSV Maven package coordinate") + if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list): + raise ValueError("Malformed OSV vulnerabilities") + scanned[(package["name"], package["version"])] += 1 + if scanned != projected: + raise ValueError("OSV --all-packages inventory differs from selected Maven graph") + return len(expected) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("resolved", type=Path) + parser.add_argument("bom", type=Path) + parser.add_argument("trivy", type=Path, nargs="?") + parser.add_argument("--osv", type=Path) + args = parser.parse_args() + expected = resolved(args.resolved.read_text(encoding="utf-8")) + count = check(expected, + json.loads(args.bom.read_text(encoding="utf-8")), + json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED) + if args.osv: + check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8"))) + print(f"Verified {count} Maven runtime dependencies, including the UI frontend.") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test-maven-inventory.py b/.github/scripts/test-maven-inventory.py new file mode 100644 index 0000000..f765f6b --- /dev/null +++ b/.github/scripts/test-maven-inventory.py @@ -0,0 +1,98 @@ +"""Synthetic regressions for missing, incorrect and empty scanner inventories.""" +import copy +import importlib.util +from pathlib import Path +import sys +import subprocess +import tempfile +import json +import unittest + + +sys.dont_write_bytecode = True +spec = importlib.util.spec_from_file_location("inventory", Path(__file__).with_name("check-maven-inventory.py")) +inventory = importlib.util.module_from_spec(spec) +spec.loader.exec_module(inventory) + + +class InventoryTests(unittest.TestCase): + def setUp(self): + self.expected = {(name, "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")} + self.text = "The following files have been resolved:\n" + "\n".join( + f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected) + self.bom = {"bomFormat": "CycloneDX", "components": [ + {"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"} + for name, version in self.expected]} + self.trivy = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]} + + def test_complete_graph_without_findings_passes(self): + self.assertEqual(self.expected, inventory.resolved(self.text)) + self.assertEqual(5, inventory.check(self.expected, self.bom, self.trivy)) + + def test_missing_frontend_and_empty_maven_fail(self): + for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)): + with self.assertRaises(ValueError): + inventory.resolved(text) + + def test_test_scope_or_test_jar_fails(self): + for extra in ("example:test:jar:1:test", "org.junit.jupiter:junit-jupiter:jar:5:runtime"): + with self.assertRaises(ValueError): + inventory.resolved(self.text + "\n" + extra) + + def test_empty_malformed_or_missing_component_fails(self): + for bom in ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]}, + {"bomFormat": "CycloneDX", "components": self.bom["components"][1:]}): + with self.assertRaises(ValueError): + inventory.check(self.expected, bom) + + def test_wrong_selected_version_fails(self): + bad = copy.deepcopy(self.trivy) + for package in bad["Results"][0]["Packages"]: + if package["Name"] == "org.jspecify:jspecify": + package["Version"] = "1.0.1" + with self.assertRaises(ValueError): + inventory.check(self.expected, self.bom, bad) + + def test_empty_or_dropped_trivy_package_fails(self): + for report in (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]}): + with self.assertRaises(ValueError): + inventory.check(self.expected, self.bom, report) + + def test_cli_supplied_null_trivy_fails(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + (directory / "resolved.txt").write_text(self.text, encoding="utf-8") + (directory / "bom.json").write_text(json.dumps(self.bom), encoding="utf-8") + (directory / "trivy.json").write_text("null", encoding="utf-8") + result = subprocess.run([sys.executable, str(Path(inventory.__file__)), + str(directory / "resolved.txt"), str(directory / "bom.json"), + str(directory / "trivy.json")], capture_output=True, text=True, check=False) + self.assertNotEqual(0, result.returncode) + self.assertIn("Expected Trivy report object", result.stderr) + + def osv(self): + return {"results": [{"source": {"path": "/src/printing.cdx.json", "type": "sbom"}, "packages": [ + {"package": {"name": name.split(":", 1)[1], "version": version, "ecosystem": "Maven"}} + for name, version in self.expected]}]} + + def test_osv_all_clean_packages_pass_without_vulnerabilities(self): + self.assertEqual(5, inventory.check_osv(self.expected, self.osv())) + + def test_osv_missing_malformed_or_partial_evidence_fails(self): + partial = self.osv() + partial["results"][0]["packages"].pop() + wrong = self.osv() + wrong["results"][0]["packages"][0]["package"]["version"] = "wrong" + duplicate = self.osv() + duplicate["results"][0]["packages"].append(duplicate["results"][0]["packages"][0]) + for report in (None, {}, [], {"results": None}, {"results": []}, {"results": [{}]}, partial, wrong, duplicate): + with self.subTest(report=report), self.assertRaises(ValueError): + inventory.check_osv(self.expected, report) + + def test_osv_ambiguous_group_projection_fails(self): + with self.assertRaises(ValueError): + inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 646fa97..67972af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: distribution: temurin java-version: '21' - name: Checkstyle, unit tests and HTTP contract tests - run: bash ./mvnw --batch-mode --no-transfer-progress verify + run: bash ./mvnw --batch-mode --no-transfer-progress -Pui-demo verify - name: Prepare JAR, POM and checksums shell: bash run: | diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ee0d615..87858bc 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,7 +1,7 @@ # Local copy of KofTwentyTwo/standards security.yml at # fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2). -# Scanner pins and security gates are preserved. SCA alone resolves Maven POMs -# first and mounts that fresh local repository read-only into pinned Trivy. +# Scanner pins and security gates are preserved. SCA scans Maven-selected runtime +# coordinates with ui-demo enabled; independent POM resolution can choose different versions. name: security on: @@ -59,16 +59,25 @@ jobs: with: persist-credentials: false - # Fresh Maven resolution supplies transitive POMs before Trivy scans. This - # avoids Maven Central throttling from the scanner's concurrent resolver. + # Resolve the actual deployed profile, including frontend assets. # Do not restore an Actions cache in this security job. - name: Set up Java uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: temurin java-version: '21' - - name: Resolve Maven dependencies - run: bash ./mvnw --batch-mode --no-transfer-progress dependency:resolve + - name: Test inventory guards + run: python3 .github/scripts/test-maven-inventory.py + - name: Resolve selected runtime graph and SBOM + run: | + set -euo pipefail + bash ./mvnw --batch-mode --no-transfer-progress -Pui-demo \ + dependency:list -DincludeScope=runtime -DoutputFile=target/runtime-dependencies.txt \ + org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeBom \ + -DincludeCompileScope=true -DincludeRuntimeScope=true \ + -DincludeProvidedScope=false -DincludeSystemScope=false -DincludeTestScope=false \ + -DoutputFormat=json -DoutputName=printing-runtime -Dcyclonedx.skipAttach=true + python3 .github/scripts/check-maven-inventory.py target/runtime-dependencies.txt target/printing-runtime.json # SARIF for code scanning carries MEDIUM and above for visibility; the gate # below fails only on the configured severities (HIGH and CRITICAL by default). @@ -77,19 +86,15 @@ jobs: TRIVY_IMAGE: ghcr.io/aquasecurity/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa run: | set -euo pipefail - docker run --rm -v "$PWD:/src" -e HOME=/root -v "$HOME/.m2/repository:/root/.m2/repository:ro" "$TRIVY_IMAGE" \ - fs --scanners vuln --severity MEDIUM,HIGH,CRITICAL \ - --cache-dir /src/.trivycache --skip-dirs .trivycache \ - --list-all-pkgs --format json --output /src/trivy.json --exit-code 0 /src - # Fail closed if Maven analysis did not include the actual dependency graph. - jq -e 'any(.Results[]?.Packages[]?; .Name == "com.kingsrook.qqq:qqq-backend-core") and - any(.Results[]?.Packages[]?; .Name == "com.kingsrook.qqq:qqq-backend-module-filesystem") and - any(.Results[]?.Packages[]?; .Name == "com.google.guava:guava")' trivy.json > /dev/null + docker run --rm -v "$PWD:/src" "$TRIVY_IMAGE" \ + sbom --scanners vuln --severity MEDIUM,HIGH,CRITICAL \ + --cache-dir /src/.trivycache --list-all-pkgs --format json \ + --output /src/trivy.json --exit-code 0 /src/target/printing-runtime.json + python3 .github/scripts/check-maven-inventory.py target/runtime-dependencies.txt target/printing-runtime.json trivy.json docker run --rm -v "$PWD:/src" "$TRIVY_IMAGE" \ convert --format sarif --output /src/trivy.sarif /src/trivy.json - docker run --rm -v "$PWD:/src" "$TRIVY_IMAGE" \ - convert --format cyclonedx --output /src/printing.cdx.json /src/trivy.json - jq -e '.components | length > 0' printing.cdx.json > /dev/null + # OSV reads the identical verified Maven graph, without another resolver. + cp target/printing-runtime.json printing.cdx.json - name: Upload SARIF to code scanning # Fork pull requests get a read-only token, so only same-repository runs upload. @@ -110,12 +115,13 @@ jobs: run: | set -euo pipefail status=0 - docker run --rm -v "$PWD:/src:ro" "$OSV_IMAGE" scan source --lockfile /src/printing.cdx.json --format json > osv.json || status=$? + docker run --rm -v "$PWD:/src:ro" "$OSV_IMAGE" scan source --lockfile /src/printing.cdx.json --all-packages --format json > osv.json || status=$? # 0: nothing found, 1: vulnerabilities found. Other statuses fail closed. case "$status" in 0|1) ;; *) echo "::error title=OSV-Scanner failed::exit code $status"; exit 1 ;; esac + python3 .github/scripts/check-maven-inventory.py target/runtime-dependencies.txt target/printing-runtime.json trivy.json --osv osv.json malicious=$(jq -r ' [ .results[]?.packages[]? | .package as $p @@ -139,6 +145,18 @@ jobs: docker run --rm -v "$PWD:/src:ro" "$TRIVY_IMAGE" \ convert --severity HIGH,CRITICAL --format table --exit-code 1 /src/trivy.json + - name: Retain dependency scan evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: maven-runtime-security-inventory + path: | + target/runtime-dependencies.txt + target/printing-runtime.json + trivy.json + osv.json + if-no-files-found: error + workflows: name: security / workflows runs-on: ubuntu-24.04 diff --git a/README.md b/README.md index b0e1e10..4b6ddb6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. CI ```powershell .\mvnw.cmd verify -.\mvnw.cmd dependency:build-classpath '-Dmdep.outputFile=target/runtime-classpath.txt' '-Dmdep.includeScope=runtime' +.\mvnw.cmd dependency:build-classpath '-Dmdep.outputFile=target/runtime-classpath.txt' '-DincludeScope=runtime' ``` On Linux/macOS use `bash ./mvnw verify` and the corresponding wrapper commands. First builds download dependencies; `-o` is only appropriate after they are cached. @@ -46,7 +46,7 @@ For a directly managed Java process, classpath is `target/classes;` followed by A separate local demo serves the real QQQ frontend with synthetic device/printer/job records. It binds only to `127.0.0.1:18082`, uses mock authentication and registers no device transport routes, so its synthetic jobs cannot be claimed by a physical connector. ```powershell -.\mvnw.cmd -Pui-demo package dependency:build-classpath '-Dmdep.outputFile=target/ui-classpath.txt' '-Dmdep.includeScope=runtime' +.\mvnw.cmd -Pui-demo package dependency:build-classpath '-Dmdep.outputFile=target/ui-classpath.txt' '-DincludeScope=runtime' $uiClasspath = 'target/classes;' + (Get-Content target/ui-classpath.txt -Raw).Trim() java -cp $uiClasspath com.kingsrook.qbits.printing.PrintingUiDemo ``` @@ -144,7 +144,7 @@ On 2026-10-29, both the development Windows connector and the installed LocalSys ## CI and releases -Pull requests and `main` pushes run Java 21/Maven wrapper verification with the immutable sibling protocol checkout. A tag matching the POM version runs build/tests and the secrets, workflow, dependency and malicious-package security gates before publishing the tested JAR, POM and `SHA256SUMS` as GitHub release assets. Maven resolves the dependency graph before Trivy scans it; OSV consumes that same graph as CycloneDX. Empty dependency analysis fails. Hyphenated versions create prereleases. The workflow does not publish to Maven Central, deploy a QQQ application or print to hardware. The [0.1.0-rc.1 release](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.0-rc.1) passed these hosted gates. +Pull requests and `main` pushes run Java 21/Maven wrapper verification with the immutable sibling protocol checkout. A tag matching the POM version runs build/tests and the secrets, workflow, dependency and malicious-package security gates before publishing the tested JAR, POM and `SHA256SUMS` as GitHub release assets. Build verification enables the `ui-demo` profile used by the local host. Maven generates its selected compile/runtime graph as a CycloneDX SBOM, including frontend assets. Trivy and OSV scan that graph; a guard rejects missing or different package versions, missing frontend assets, and empty inventories. CI retains the resolved graph and scanner evidence. OSV uses `--all-packages`; its pinned 2.6.0 reporter [omits Maven groups](https://github.com/google/osv-scanner/issues/3099), so the guard checks every reported artifact/version and rejects ambiguous names while the verified input retains full package URLs. Hyphenated versions create prereleases. The workflow does not publish to Maven Central, deploy a QQQ application or print to hardware. The [0.1.0-rc.1 release](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.0-rc.1) passed these hosted gates. ## Printing home diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 82e2e2b..e74a65f 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -2,6 +2,8 @@ ## Audit candidate 0.1.4-rc.1 +Runtime inventory follow-up: the authenticated host commit `78e0911` passed hosted checks, but a local scanner comparison found that the original POM scan omitted the frontend profile and selected a different transitive version from Maven. The pending workflow fix enables `ui-demo` for verification and generates a pinned CycloneDX Maven runtime SBOM for both Trivy and OSV. Exact coordinate/version guards reject incomplete or divergent inventories. Locally, all **204** selected runtime dependencies match the SBOM and Trivy inventory; ten new guard tests, six existing SARIF tests, actionlint and offline zizmor medium checks pass. Local vulnerability checks used the existing cached database; fresh hosted gates are still required for these workflow changes. The corrected deployment dependency payload excludes test libraries; no host was deployed by this follow-up. + Final isolated verification passed **65 tests, zero failures/errors/skips and zero Checkstyle violations**, including the frontend runtime profile. The independent baseline scan's two server findings were remediated: configurable atomic tenant/device admission covers outstanding work, retained bytes and hourly allocations; cumulative printer limits and cursor pagination bound inventory. A fresh reviewer found a PostgreSQL wide-index regression, corrected before the final gate. Durable upload accounting, legacy unknown reservations, stable replay and single-connection cleanup have regression coverage. The new authenticated `PrintingLocalHost` connects native QQQ screens and device routes to the same durable H2 database. It uses native table-based authentication, preserves the existing account/pairing, rejects cross-origin cookie mutations and DNS rebinding, and starts QQQ's existing retention scheduler. Tests cover uppercase and lowercase H2 identifiers, native login/logout and tenant access, API heartbeat, scheduler startup and unchanged password hashes. A fresh read-only review found no actionable confirmed finding. Browser rendering and an actual scheduled cleanup tick remain deployment checks. The synthetic `PrintingUiDemo` stays separate. See [local deployment](LOCAL-HOST.md). diff --git a/docs/TODO.md b/docs/TODO.md index 7b7706e..03ca852 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,8 @@ # Follow-up +- [x] Reconcile UI-profile Maven runtime dependencies with scanner inventories; 204 exact coordinates, ten guard regressions and workflow lint pass locally. +- [ ] Run fresh hosted security/build gates for the runtime-SBOM workflow correction before authenticated-host deployment. + - [x] Finish audit regressions for native-process replay, strict options/inventory validation, retained duplicate risk and event field bounds; full gate passes 53 tests. - [x] Implement admin-configurable document retention (one-year default), bounded scheduled cleanup and existing QQQ object-storage integration, retaining history/receipts and protecting active or uncertain output. - [ ] Integrate Record Files' authorized immutable-version reader after its contract is implemented; define async source revocation/correlation and eliminate host-outbox byte copies for that path. -- 2.54.0 From d52dfd722bb2042308d7eb101d8e8e93cfb5eb67 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 20:55:15 -0500 Subject: [PATCH 10/35] fix: consume device request bodies before queue mutations Claim and cancel could complete before reading valid JSON, causing Jetty to close the persistent connection before a subsequent status event. Consume the bounded body after authentication. A split-body HTTP regression fails before this change; full isolated UI-profile verification passes 66 tests and Checkstyle. Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- docs/SESSION-STATE.md | 4 ++ docs/TODO.md | 2 + .../qbits/printing/PrintingRoutes.java | 14 ++++++- .../qbits/printing/PrintingContractTest.java | 39 +++++++++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index e74a65f..1fe871e 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -2,6 +2,10 @@ ## Audit candidate 0.1.4-rc.1 +HTTP follow-up: the runtime-inventory commit `60ce6e4` passed hosted security, CodeQL and PR gates, but its UI-profile build exposed a connection failure on the first status event after a claim. A deterministic split-body regression proved claim returned before consuming its request body. Claim and cancel now consume the existing bounded JSON body after device authentication and before mutation. The focused regression fails before the change and passes afterward; full isolated UI-profile verification passes **66 tests and zero Checkstyle violations**. The exact Ubuntu timing failure was not reproduced locally; fresh hosted verification is required. No retries or assertions were weakened. + +Native deployment qualification is still in progress. Exact-artifact startup and native authentication/query/retention checks pass against fresh closed-backup copies, with the default 365-day policy. A prepared launch against a particular earlier copy returned a missing-settings-table error, while changing only the copy's filename passed; the cause remains under investigation. This is not claimed to be a fixed application defect. The real paired host remains unchanged, and no cleanup or physical printing was submitted by these checks. + Runtime inventory follow-up: the authenticated host commit `78e0911` passed hosted checks, but a local scanner comparison found that the original POM scan omitted the frontend profile and selected a different transitive version from Maven. The pending workflow fix enables `ui-demo` for verification and generates a pinned CycloneDX Maven runtime SBOM for both Trivy and OSV. Exact coordinate/version guards reject incomplete or divergent inventories. Locally, all **204** selected runtime dependencies match the SBOM and Trivy inventory; ten new guard tests, six existing SARIF tests, actionlint and offline zizmor medium checks pass. Local vulnerability checks used the existing cached database; fresh hosted gates are still required for these workflow changes. The corrected deployment dependency payload excludes test libraries; no host was deployed by this follow-up. Final isolated verification passed **65 tests, zero failures/errors/skips and zero Checkstyle violations**, including the frontend runtime profile. The independent baseline scan's two server findings were remediated: configurable atomic tenant/device admission covers outstanding work, retained bytes and hourly allocations; cumulative printer limits and cursor pagination bound inventory. A fresh reviewer found a PostgreSQL wide-index regression, corrected before the final gate. Durable upload accounting, legacy unknown reservations, stable replay and single-connection cleanup have regression coverage. diff --git a/docs/TODO.md b/docs/TODO.md index 03ca852..ceab602 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,7 @@ # Follow-up +- [x] Consume bounded claim/cancel bodies before side effects; split-body regression fails before fix, full isolated UI-profile gate passes 66 tests. +- [ ] Verify the HTTP follow-up on fresh hosted checks and resolve the isolated prepared-copy retention discrepancy before live cutover. - [x] Reconcile UI-profile Maven runtime dependencies with scanner inventories; 204 exact coordinates, ten guard regressions and workflow lint pass locally. - [ ] Run fresh hosted security/build gates for the runtime-SBOM workflow correction before authenticated-host deployment. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java index 3318b02..fb3dcd6 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java @@ -76,11 +76,21 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface return accepted(); })); config.routes.post(BASE + "/device/heartbeat", context -> handle(context, () -> service.heartbeat(device(context), body(context)))); - config.routes.post(BASE + "/device/jobs/claim", context -> handle(context, () -> service.claim(device(context)))); + config.routes.post(BASE + "/device/jobs/claim", context -> handle(context, () -> + { + String device = device(context); + body(context); + return service.claim(device); + })); config.routes.get(BASE + "/device/jobs/queued", context -> handle(context, () -> new JSONObject().put("jobs", service.queuedJobs(device(context))))); config.routes.post(BASE + "/device/test-print", context -> handle(context, () -> service.testPrint(device(context), body(context)))); config.routes.post(BASE + "/device/jobs/{jobId}/retry", context -> handle(context, () -> service.retry(device(context), context.pathParam("jobId"), body(context)))); - config.routes.post(BASE + "/device/jobs/{jobId}/cancel", context -> handle(context, () -> service.cancel(device(context), context.pathParam("jobId")))); + config.routes.post(BASE + "/device/jobs/{jobId}/cancel", context -> handle(context, () -> + { + String device = device(context); + body(context); + return service.cancel(device, context.pathParam("jobId")); + })); config.routes.get(BASE + "/device/jobs/{jobId}/content", context -> handle(context, () -> { byte[] bytes = service.content(device(context), context.pathParam("jobId")); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java index cd60556..65fb5b3 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java @@ -21,10 +21,13 @@ package com.kingsrook.qbits.printing; +import java.net.Socket; +import java.net.SocketTimeoutException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; @@ -36,6 +39,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -122,6 +126,41 @@ class PrintingContractTest + /*************************************************************************** + ** Claim waits for its bounded JSON body before committing the response. + ***************************************************************************/ + @Test + void claimConsumesSplitRequestBodyBeforeResponding() throws Exception + { + PrintingService service = new PrintingService(new DriverDataSource("jdbc:h2:mem:split-body-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""), "contract-tenant"); + service.initialize(); + String code = service.createEnrollment("default", "Synthetic device").getString("enrollmentCode"); + String token = service.enroll(fixture("enroll.request.json").put("enrollmentCode", code)).getString("deviceToken"); + PrintingRoutes routes = new PrintingRoutes(service, context -> false); + Javalin app = Javalin.create(routes::acceptJavalinConfig).start("127.0.0.1", 0); + try(Socket socket = new Socket("127.0.0.1", app.port())) + { + socket.setSoTimeout(250); + String headers = "POST " + PrintingRoutes.BASE + "/device/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:" + app.port() + + "\r\nAuthorization: Bearer " + token + "\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{"; + socket.getOutputStream().write(headers.getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().flush(); + assertThrows(SocketTimeoutException.class, () -> socket.getInputStream().read()); + socket.setSoTimeout(5000); + socket.getOutputStream().write('}'); + socket.getOutputStream().flush(); + String response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(response.startsWith("HTTP/1.1 200")); + assertTrue(response.contains("\"job\":null")); + } + finally + { + app.stop(); + } + } + + + /*************************************************************************** ** Load the same fixture consumed by the Windows protocol tests. ***************************************************************************/ -- 2.54.0 From fb99fafbbf235304d3b2aec6d8a030d42f96d22c Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 21:29:26 -0500 Subject: [PATCH 11/35] fix: use H2 with durable close-time compaction Pin H2 2.4.240 for upstream compaction data loss. A closed legacy fixture lost newly created audit tables after reopening with 2.3.232; replacing only the driver preserves them. Add cold-reopen durability coverage and document backup/runtime qualification. Full isolated verification passes 67 tests and all 204 dependency inventories agree. Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 5 ++- README.md | 2 +- docs/LOCAL-HOST.md | 8 +++- docs/SESSION-STATE.md | 6 ++- docs/TODO.md | 6 ++- pom.xml | 3 +- .../qbits/printing/PrintingMigrationTest.java | 39 +++++++++++++++++++ 7 files changed, 61 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b48b7..ea96a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ - Use named facility choices during enrollment and revalidate them under the current user's tenant/record permissions. - Require release security/CodeQL gates, verified artifact promotion and build provenance; report the real packaged QBit version. - Enforce configurable atomic queue/storage/rate budgets and cumulative printer limits, with bounded management pagination and stable replay at capacity. -- Full local verification passed 65 tests with zero failures/errors/skips and Checkstyle violations. Final hosted checks remain pending; this entry does not claim release or live storage migration. +- Consume bounded claim/cancel request bodies before mutation so a persistent HTTP connection remains usable for the subsequent status event. +- Pin H2 2.4.240 to fix close-time compaction data loss; verify file-backed schema/settings across cold reopens and document closed-backup upgrade qualification. +- Verify the exact frontend-inclusive runtime inventory in Maven, Trivy and OSV; fail on malformed or incomplete scanner evidence. +- Full local verification passed 67 tests with zero failures/errors/skips and Checkstyle violations. Final dependency-change hosted checks remain pending; this entry does not claim release or live storage migration. ## 0.1.3-rc.1 — 2026-10-30 diff --git a/README.md b/README.md index 4b6ddb6..00270c5 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator ## Authenticated local application -`PrintingLocalHost` combines the real QQQ dashboard, native processes, authenticated sign-in and device routes against one durable H2 database. It binds only to loopback, uses QQQ table-based authentication, and starts the native Quartz retention schedule through `QApplicationLauncher`. Existing pairing and job history are retained when the database and tenant are preserved. See [local host setup](docs/LOCAL-HOST.md) for explicit credentials, the required frontend runtime profile and a reviewed cutover procedure. +`PrintingLocalHost` combines the real QQQ dashboard, native processes, authenticated sign-in and device routes against one durable H2 database. It binds only to loopback, uses QQQ table-based authentication, and starts the native Quartz retention schedule through `QApplicationLauncher`. Existing pairing and job history are retained when the database and tenant are preserved. Use the pinned H2 2.4.240 runtime; older 2.3.232 file-backed deployments require a closed backup and cold-reopen qualification. See [local host setup](docs/LOCAL-HOST.md) for explicit credentials, the required frontend/runtime dependencies and a reviewed cutover procedure. The protocol-only `PrintingDemo` and synthetic, mock-authenticated `PrintingUiDemo` remain separate. Do not point the synthetic demo at real paired printer data. The local host retains database document storage until an actual dedicated S3 binding is configured in a deployment-owned application; it does not claim a remote cloud deployment. diff --git a/docs/LOCAL-HOST.md b/docs/LOCAL-HOST.md index c6a6e59..16fdfc1 100644 --- a/docs/LOCAL-HOST.md +++ b/docs/LOCAL-HOST.md @@ -31,7 +31,7 @@ There is no default browser password and no account chosen automatically. Startu Use Java 21 and the Maven wrapper. The `ui-demo` profile supplies the real frontend-next runtime assets for both UI-capable launch targets: ```powershell -.\mvnw.cmd -Pui-demo verify dependency:build-classpath '-Dmdep.outputFile=target/local-host-classpath.txt' +.\mvnw.cmd -Pui-demo verify dependency:build-classpath '-Dmdep.outputFile=target/local-host-classpath.txt' '-DincludeScope=runtime' $dependencies = (Get-Content target/local-host-classpath.txt -Raw).Trim() & "$env:JAVA_HOME/bin/java.exe" -cp "target/classes;$dependencies" com.kingsrook.qbits.printing.PrintingLocalHost ``` @@ -40,6 +40,12 @@ Set the required environment values securely before launch. Maven contract tests This command runs the host in the foreground. `QApplicationLauncher` owns graceful HTTP/scheduler shutdown. It does not install an operating-system service or auto-start registration; the deploying operator must provide supervision, protected credential loading, logs and backups for unattended use. +## Embedded database runtime + +Use the pinned **H2 2.4.240** runtime for file-backed local hosts. H2 2.3.232 has a confirmed close-time compaction data-loss defect ([issue 4247](https://github.com/h2database/h2database/issues/4247), [fix 4249](https://github.com/h2database/h2database/pull/4249)). Keep exactly one H2 driver JAR on the classpath; replacing the application JAR alone does not replace its runtime dependencies. Regenerate the runtime classpath from the current POM and remove the old driver from the new deployment payload. + +Before upgrading, back up the closed database and validate a separate copy through startup, full shutdown and cold reopen with the fixed driver. Confirm paired records, retention settings and job history after reopening, without rerunning initialization merely to make missing tables reappear. Keep the pre-upgrade backup for rollback; do not point an older driver at a database already opened by the new runtime. A successful fresh-database test does not certify every existing database file. Normal compaction remains enabled; no warm-up, retry or delay is part of the fix. + ## Cut over an existing paired protocol server 1. Identify the exact existing database, tenant, management credential and connector URL. Inspect all pending and uncertain jobs through bounded management pages before a maintenance window. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 1fe871e..7f05ddb 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -2,9 +2,11 @@ ## Audit candidate 0.1.4-rc.1 +H2 follow-up: isolated file-backed acceptance identified the upstream H2 2.3.232 close-time compaction data-loss defect (#4247/#4249). All migration statements succeeded and the four audit tables existed before closing, but disappeared on reopening. Identical restored-copy comparisons passed with diagnostic-only compaction disablement or with **only H2 replaced by 2.4.240** using normal compaction. The POM now pins the fixed runtime; no warm-up/retry/delay workaround is added. Full isolated frontend-profile verification passes **67 tests, zero failures/errors/skips and zero Checkstyle violations**. All **204** selected dependencies match Maven/SBOM/Trivy/OSV; refreshed Trivy reports zero HIGH/CRITICAL and OSV reports zero malicious findings. Independent review found no blocker. Fresh hosted checks and exact-artifact native/cold-restart qualification remain before live cutover. The public cold-reopen test is durability coverage, not a claim that every fresh synthetic database reproduces the old-driver defect. + HTTP follow-up: the runtime-inventory commit `60ce6e4` passed hosted security, CodeQL and PR gates, but its UI-profile build exposed a connection failure on the first status event after a claim. A deterministic split-body regression proved claim returned before consuming its request body. Claim and cancel now consume the existing bounded JSON body after device authentication and before mutation. The focused regression fails before the change and passes afterward; full isolated UI-profile verification passes **66 tests and zero Checkstyle violations**. The exact Ubuntu timing failure was not reproduced locally; fresh hosted verification is required. No retries or assertions were weakened. -Native deployment qualification is still in progress. Exact-artifact startup and native authentication/query/retention checks pass against fresh closed-backup copies, with the default 365-day policy. A prepared launch against a particular earlier copy returned a missing-settings-table error, while changing only the copy's filename passed; the cause remains under investigation. This is not claimed to be a fixed application defect. The real paired host remains unchanged, and no cleanup or physical printing was submitted by these checks. +Native deployment qualification is still in progress. Authentication/query/retention checks and an actual scheduled zero-removal tick pass on isolated copies, with the default 365-day policy. The prepared-copy missing-table failure was traced to the H2 defect above; filename, helper/bootstrap and generic legacy-schema theories were independently ruled out. The real paired host remains unchanged. No manual cleanup or physical printing was submitted by these checks. Runtime inventory follow-up: the authenticated host commit `78e0911` passed hosted checks, but a local scanner comparison found that the original POM scan omitted the frontend profile and selected a different transitive version from Maven. The pending workflow fix enables `ui-demo` for verification and generates a pinned CycloneDX Maven runtime SBOM for both Trivy and OSV. Exact coordinate/version guards reject incomplete or divergent inventories. Locally, all **204** selected runtime dependencies match the SBOM and Trivy inventory; ten new guard tests, six existing SARIF tests, actionlint and offline zizmor medium checks pass. Local vulnerability checks used the existing cached database; fresh hosted gates are still required for these workflow changes. The corrected deployment dependency payload excludes test libraries; no host was deployed by this follow-up. @@ -14,7 +16,7 @@ The new authenticated `PrintingLocalHost` connects native QQQ screens and device Earlier fixes add replay-safe native processes, validated facility selectors, strict wire fields, duplicate-risk preservation, dedicated QQQ S3 storage and native admin retention with a **365-day default**. Active, failed and uncertain work remains protected. Stop older writers before admission-accounting migration; existing content is preserved. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). -Draft PR #10 passed every required hosted check at `aeac1a6`; its CI JAR/POM checksums were verified. The authenticated local-host addition requires fresh hosted checks before deployment. No live database migration, S3 provisioning, purge, merge, release or host deployment has occurred. Real PostgreSQL/S3, remote host authentication, multi-node operation and sustained physical throughput remain unqualified. All main-branch requirements apply to administrators. The low-severity same-repository workflow advisory has a documented [compatibility assessment](CI-COMPATIBILITY.md); no security check is disabled. +Draft PR #10 passed every required hosted check at `d52dfd7`, including the authenticated host, exact runtime inventory and HTTP follow-up; its CI JAR/POM checksums were verified. The H2 dependency correction requires fresh hosted checks before deployment. No live database migration, S3 provisioning, purge, merge, release or host deployment has occurred. Real PostgreSQL/S3, remote host authentication, multi-node operation and sustained physical throughput remain unqualified. All main-branch requirements apply to administrators. The low-severity same-repository workflow advisory has a documented [compatibility assessment](CI-COMPATIBILITY.md); no security check is disabled. Master coordination at `R:\agent-comms\qqq-cloud-printing.md` is acknowledged. Record Files owns indefinitely retained originals; printing owns its dedicated delivery copies. Their compiled source-reader/actor authorization contract remains pending. Standalone byte submission continues. Installed Windows baseline remains 0.1.7; unsigned prereleases are user-approved. diff --git a/docs/TODO.md b/docs/TODO.md index ceab602..ebd4bdf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,9 +1,11 @@ # Follow-up - [x] Consume bounded claim/cancel bodies before side effects; split-body regression fails before fix, full isolated UI-profile gate passes 66 tests. -- [ ] Verify the HTTP follow-up on fresh hosted checks and resolve the isolated prepared-copy retention discrepancy before live cutover. +- [x] Verify the HTTP follow-up on fresh hosted checks at d52dfd7. +- [x] Identify and fix the prepared-copy retention failure by pinning H2 2.4.240; controlled old/fixed-driver comparison and full 67-test gate pass. +- [ ] Verify fresh H2-change hosted checks and the exact dependency payload through native startup, shutdown and cold reopen before live cutover. - [x] Reconcile UI-profile Maven runtime dependencies with scanner inventories; 204 exact coordinates, ten guard regressions and workflow lint pass locally. -- [ ] Run fresh hosted security/build gates for the runtime-SBOM workflow correction before authenticated-host deployment. +- [x] Run hosted security/build gates for the runtime-SBOM workflow correction at d52dfd7; dependency follow-up requires its own fresh checks. - [x] Finish audit regressions for native-process replay, strict options/inventory validation, retained duplicate risk and event field bounds; full gate passes 53 tests. - [x] Implement admin-configurable document retention (one-year default), bounded scheduled cleanup and existing QQQ object-storage integration, retaining history/receipts and protecting active or uncertain output. diff --git a/pom.xml b/pom.xml index 6d947f4..2ec00bb 100644 --- a/pom.xml +++ b/pom.xml @@ -24,7 +24,8 @@ com.kingsrook.qqqqqq-backend-module-rdbms${qqq.version} com.kingsrook.qqqqqq-backend-module-filesystem${qqq.version} com.kingsrook.qqqqqq-middleware-javalin${qqq.version} - com.h2databaseh22.3.232runtime + + com.h2databaseh22.4.240runtime org.junit.jupiterjunit-jupiter6.0.1test ui-demo diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingMigrationTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingMigrationTest.java index 8470478..febb21d 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingMigrationTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingMigrationTest.java @@ -21,6 +21,7 @@ package com.kingsrook.qbits.printing; +import java.nio.file.Path; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; @@ -30,8 +31,10 @@ import java.util.UUID; import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* @@ -100,6 +103,42 @@ class PrintingMigrationTest + /*************************************************************************** + ** File-backed state survives last-connection closure without reinitialization. + ** This checks cold reopen durability, not reproduction of every old-driver file. + ***************************************************************************/ + @Test + void preservesAuditSchemaAndSettingsAcrossColdReopens(@TempDir Path directory) throws Exception + { + String url = "jdbc:h2:file:" + directory.resolve("cold-reopen").toAbsolutePath(); + DriverDataSource source = new DriverDataSource(url, "sa", ""); + PrintingService service = new PrintingService(source, "tenant-a"); + service.initialize(); + assertEquals(365, service.getRetentionSettings().getInt("days")); + service.saveRetentionSettings(400, false); + JSONObject device = service.enroll(service.createEnrollment("default", "Synthetic station") + .put("deviceName", "Synthetic station").put("agentVersion", "test")); + for(int restart = 0; restart < 3; restart++) + { + DriverDataSource reopened = new DriverDataSource(url + ";IFEXISTS=TRUE", "sa", ""); + PrintingService restarted = new PrintingService(reopened, "tenant-a"); + assertEquals(400, restarted.getRetentionSettings().getInt("days")); + assertEquals(device.getString("deviceId"), restarted.authenticate(device.getString("deviceToken"))); + try(Connection connection = reopened.getConnection()) + { + for(String table : List.of("printing_settings", "printing_document_orphan", "printing_resource_usage", "printing_admission_event")) + { + try(PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM " + table); java.sql.ResultSet rows = statement.executeQuery()) + { + assertTrue(rows.next()); + } + } + } + } + } + + + /*************************************************************************** ** Mixed namespaces are not silently merged or discarded. ***************************************************************************/ -- 2.54.0 From d1821bedf2df64e80e4668377d0ff60d7e681d0d Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 21:49:38 -0500 Subject: [PATCH 12/35] fix(ci): constrain reviewed H2 license scope Require the reviewed H2 runtime in the selected Maven inventory and reject absent or different versions. Record unchanged H2 licensing and exact-artifact native cold-restart acceptance while keeping the central license exception and live deployment pending. Validation: 11 inventory guard tests and 6 SARIF guard tests pass; workflow lint and independent review passed. Production Java remains the verified 67-test H2-fixed candidate. Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- .github/scripts/check-maven-inventory.py | 6 ++++++ .github/scripts/test-maven-inventory.py | 13 +++++++++--- NOTICE | 6 ++++++ docs/H2-LICENSE-REVIEW.md | 25 ++++++++++++++++++++++++ docs/SESSION-STATE.md | 18 ++++++----------- docs/TODO.md | 18 ++++++++--------- 6 files changed, 62 insertions(+), 24 deletions(-) create mode 100644 docs/H2-LICENSE-REVIEW.md diff --git a/.github/scripts/check-maven-inventory.py b/.github/scripts/check-maven-inventory.py index 77374ba..945aeb2 100644 --- a/.github/scripts/check-maven-inventory.py +++ b/.github/scripts/check-maven-inventory.py @@ -12,7 +12,9 @@ REQUIRED = { "com.kingsrook.qqq:qqq-backend-core", "com.kingsrook.qqq:qqq-backend-module-filesystem", "com.google.guava:guava", + "com.h2database:h2", } +REVIEWED_H2_VERSION = "2.4.240" OMITTED = object() @@ -33,6 +35,10 @@ def resolved(text): packages.add((name, parts[-2])) if not REQUIRED.issubset({name for name, _ in packages}): raise ValueError("Maven inventory is empty or lacks required UI-host dependencies") + # The pinned dependency-review license exception matcher ignores versions. + # Keep any H2 metadata exception limited to the reviewed, unmodified runtime. + if {version for name, version in packages if name == "com.h2database:h2"} != {REVIEWED_H2_VERSION}: + raise ValueError("H2 runtime must match reviewed version " + REVIEWED_H2_VERSION) return packages diff --git a/.github/scripts/test-maven-inventory.py b/.github/scripts/test-maven-inventory.py index f765f6b..8b745ac 100644 --- a/.github/scripts/test-maven-inventory.py +++ b/.github/scripts/test-maven-inventory.py @@ -17,7 +17,7 @@ spec.loader.exec_module(inventory) class InventoryTests(unittest.TestCase): def setUp(self): - self.expected = {(name, "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")} + self.expected = {(name, "2.4.240" if name == "com.h2database:h2" else "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")} self.text = "The following files have been resolved:\n" + "\n".join( f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected) self.bom = {"bomFormat": "CycloneDX", "components": [ @@ -27,7 +27,14 @@ class InventoryTests(unittest.TestCase): def test_complete_graph_without_findings_passes(self): self.assertEqual(self.expected, inventory.resolved(self.text)) - self.assertEqual(5, inventory.check(self.expected, self.bom, self.trivy)) + self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy)) + + def test_missing_or_unreviewed_h2_version_fails(self): + without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line) + for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"), + self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"): + with self.subTest(text=text), self.assertRaises(ValueError): + inventory.resolved(text) def test_missing_frontend_and_empty_maven_fail(self): for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)): @@ -76,7 +83,7 @@ class InventoryTests(unittest.TestCase): for name, version in self.expected]}]} def test_osv_all_clean_packages_pass_without_vulnerabilities(self): - self.assertEqual(5, inventory.check_osv(self.expected, self.osv())) + self.assertEqual(6, inventory.check_osv(self.expected, self.osv())) def test_osv_missing_malformed_or_partial_evidence_fails(self): partial = self.osv() diff --git a/NOTICE b/NOTICE index 3285275..4931277 100644 --- a/NOTICE +++ b/NOTICE @@ -10,3 +10,9 @@ Copyright 2020 The JetBrains Mono Project Authors. The font is licensed under the SIL Open Font License, Version 1.1; see src/main/resources/com/kingsrook/qbits/printing/fonts/OFL.txt. The QQQ logo is supplied by the QQQ framework, Copyright Kingsrook, LLC. + +The local host runtime uses the unmodified H2 Database Engine, Copyright +2004-2025 H2 Group. H2 is dual licensed under MPL 2.0 or EPL 1.0; this +runtime uses the MPL 2.0 option. H2 is provided without warranty. +License: https://github.com/h2database/h2database/blob/version-2.4.240/LICENSE.txt +Corresponding source: https://github.com/h2database/h2database/tree/version-2.4.240 diff --git a/docs/H2-LICENSE-REVIEW.md b/docs/H2-LICENSE-REVIEW.md new file mode 100644 index 0000000..b61fde7 --- /dev/null +++ b/docs/H2-LICENSE-REVIEW.md @@ -0,0 +1,25 @@ +# H2 license review — 2026-10-30 + +Status: evidence and proposed correction only. No license exception is enabled by this document. + +The dependency-review job for `fb99faf` reported `com.h2database:h2@2.4.240` as `LicenseRef-scancode-unknown AND MPL-2.0`. Its vulnerability check found no advisories at the configured low-or-higher threshold. The package upgrade fixes the confirmed embedded database compaction defect; reverting the driver would restore that defect. + +## Published terms and artifact + +The upstream [2.4.240 license](https://github.com/h2database/h2database/blob/version-2.4.240/LICENSE.txt) expressly offers a choice of MPL-2.0 or EPL-1.0. Its [license FAQ](https://github.com/h2database/h2database/blob/version-2.4.240/h2/src/docsrc/html/license.html) describes unmodified binary redistribution and attribution. The published Maven POMs for [2.4.240](https://repo.maven.apache.org/maven2/com/h2database/h2/2.4.240/h2-2.4.240.pom) and [2.3.232](https://repo.maven.apache.org/maven2/com/h2database/h2/2.3.232/h2-2.3.232.pom) both declare MPL 2.0 and EPL 1.0. The upgrade does not introduce a new license choice. + +The local host uses the unmodified Maven Central H2 JAR under the MPL-2.0 option. The reviewed 2.4.240 binary SHA-256 is `29b70e427cc1c40cdc376283adbb0cc62853073797bb5fe5761f81fe73d57ce0`. Preserve H2 attribution, license information and access to the corresponding [source tag](https://github.com/h2database/h2database/tree/version-2.4.240) when distributing a host runtime. The QBit's own Apache-2.0 license does not replace third-party terms. + +## Applicable policy and scope + +`K22-DEP-02` in the [dependency standard](https://github.com/KofTwentyTwo/standards/blob/main/standards/dependencies.md) permits MPL-2.0 when used unmodified. However, the pinned reusable [`pr.yml`](https://github.com/KofTwentyTwo/standards/blob/fdf5ed029603c938615a32b1beca3628b8787945/.github/workflows/pr.yml) requires every `allow-dependencies-licenses` entry to have a record in `exceptions/register.md`. The [exception policy](https://github.com/KofTwentyTwo/standards/blob/main/policies/exceptions.md) requires that record to pass normal review. No existing record was found covering this repository and H2. + +The substantive license is permitted; correcting the scanner result still needs the recorded workflow exception. This review does not authorize changing shared policy, skipping a required check or marking the license gate successful without that correction. + +## Narrow proposed correction + +After the applicable exception is recorded, use the reusable workflow's package-specific `allow-dependencies-licenses` input for `pkg:maven/com.h2database/h2`. Preserve `license-check: true`, `vulnerability-check: true`, the low advisory threshold and every other package's existing license policy. Do not globally allow unknown licenses or broaden the MPL allowlist. + +The pinned action's [license matcher](https://github.com/actions/dependency-review-action/blob/a1d282b36b6f3519aa1f3fc636f609c47dddb294/src/licenses.ts) calls [`purlsMatch`](https://github.com/actions/dependency-review-action/blob/a1d282b36b6f3519aa1f3fc636f609c47dddb294/src/purl.ts), which intentionally ignores package versions. Adding `@2.4.240` to the input does **not** enforce a version restriction. The required security inventory gate now rejects absent H2, any version other than the reviewed `2.4.240`, and multiple H2 versions. It compares the selected Maven graph with both scanner inventories. A later upgrade must update both the pin and this reviewed-version guard after a renewed license assessment. Cite the approved exception ID beside the workflow input and give the record a review/expiry date. + +This document leaves that configuration unapplied pending the required record. The shared standards repository was not modified. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 7f05ddb..5c2217a 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -2,23 +2,17 @@ ## Audit candidate 0.1.4-rc.1 -H2 follow-up: isolated file-backed acceptance identified the upstream H2 2.3.232 close-time compaction data-loss defect (#4247/#4249). All migration statements succeeded and the four audit tables existed before closing, but disappeared on reopening. Identical restored-copy comparisons passed with diagnostic-only compaction disablement or with **only H2 replaced by 2.4.240** using normal compaction. The POM now pins the fixed runtime; no warm-up/retry/delay workaround is added. Full isolated frontend-profile verification passes **67 tests, zero failures/errors/skips and zero Checkstyle violations**. All **204** selected dependencies match Maven/SBOM/Trivy/OSV; refreshed Trivy reports zero HIGH/CRITICAL and OSV reports zero malicious findings. Independent review found no blocker. Fresh hosted checks and exact-artifact native/cold-restart qualification remain before live cutover. The public cold-reopen test is durability coverage, not a claim that every fresh synthetic database reproduces the old-driver defect. +Exact source `fb99faf` passed hosted [build](https://github.com/QRun-IO/qbit-printing/actions/runs/41160240077), [CodeQL](https://github.com/QRun-IO/qbit-printing/actions/runs/41160240076) and [security](https://github.com/QRun-IO/qbit-printing/actions/runs/41160240078). The isolated frontend-profile gate passes **67 Java tests, zero failures/errors/skips and zero Checkstyle violations**. All **204** runtime dependencies match the selected Maven graph and scanner inventories, with exactly one H2 2.4.240 JAR. Refreshed Trivy reports zero HIGH/CRITICAL findings; OSV reports zero malicious findings. -HTTP follow-up: the runtime-inventory commit `60ce6e4` passed hosted security, CodeQL and PR gates, but its UI-profile build exposed a connection failure on the first status event after a claim. A deterministic split-body regression proved claim returned before consuming its request body. Claim and cancel now consume the existing bounded JSON body after device authentication and before mutation. The focused regression fails before the change and passes afterward; full isolated UI-profile verification passes **66 tests and zero Checkstyle violations**. The exact Ubuntu timing failure was not reproduced locally; fresh hosted verification is required. No retries or assertions were weakened. +The exact CI application JAR and verified runtime payload passed isolated native acceptance against a fresh private copy of a closed database: real frontend, native authentication, bounded table counts/queries, denied requests, logout and retention settings at the **365-day default**. The native scheduler actually ran at `2026-10-31T02:45:00.087433600Z` and removed zero documents. After identity-verified forced termination, an independent SELECT-only read-only H2 inspection **before application initialization** found all 11 tables, including the four audit tables, with retention and record counts preserved. Restarting the same exact artifact passed the native checks again at `2026-10-31T02:47:10Z`. This qualifies the tested forced-termination/cold-reopen path; graceful shutdown remains a separate qualification. No live paired database, remote host, S3 storage or printer was changed by these checks. -Native deployment qualification is still in progress. Authentication/query/retention checks and an actual scheduled zero-removal tick pass on isolated copies, with the default 365-day policy. The prepared-copy missing-table failure was traced to the H2 defect above; filename, helper/bootstrap and generic legacy-schema theories were independently ruled out. The real paired host remains unchanged. No manual cleanup or physical printing was submitted by these checks. +The prepared-copy failure was the upstream H2 2.3.232 close-time compaction defect (#4247/#4249). Replacing only H2 with **2.4.240** preserves the migrated schema using normal compaction. No warm-up, retry, delay or disabled-compaction production workaround is present. The public fresh-file cold-reopen test checks durability; it does not claim to reproduce every affected old-driver database. See [local runtime and backup guidance](LOCAL-HOST.md). -Runtime inventory follow-up: the authenticated host commit `78e0911` passed hosted checks, but a local scanner comparison found that the original POM scan omitted the frontend profile and selected a different transitive version from Maven. The pending workflow fix enables `ui-demo` for verification and generates a pinned CycloneDX Maven runtime SBOM for both Trivy and OSV. Exact coordinate/version guards reject incomplete or divergent inventories. Locally, all **204** selected runtime dependencies match the SBOM and Trivy inventory; ten new guard tests, six existing SARIF tests, actionlint and offline zizmor medium checks pass. Local vulnerability checks used the existing cached database; fresh hosted gates are still required for these workflow changes. The corrected deployment dependency payload excludes test libraries; no host was deployed by this follow-up. +The [PR gate](https://github.com/QRun-IO/qbit-printing/actions/runs/41160240342) remains blocked **only by H2 license metadata classification**; dependency vulnerability review passed. Tagged H2 terms are unchanged and permit the unmodified MPL-2.0 option. The reviewed inventory guard now requires exactly H2 2.4.240; **17 Python guard tests** pass, with workflow lint and independent review complete. See [license evidence](H2-LICENSE-REVIEW.md). The proposed central exception record and package-only workflow input remain unapplied pending approval; no active workflow exception, global license allowance or security bypass is enabled. Fresh hosted checks are required for the guard/documentation commit. -Final isolated verification passed **65 tests, zero failures/errors/skips and zero Checkstyle violations**, including the frontend runtime profile. The independent baseline scan's two server findings were remediated: configurable atomic tenant/device admission covers outstanding work, retained bytes and hourly allocations; cumulative printer limits and cursor pagination bound inventory. A fresh reviewer found a PostgreSQL wide-index regression, corrected before the final gate. Durable upload accounting, legacy unknown reservations, stable replay and single-connection cleanup have regression coverage. +The audit also fixes native-process replay, tenant-scoped facility selectors, duplicate-output risk preservation, atomic queue/retained-byte/rate admission and bounded printer history. Dedicated QQQ document storage and scheduled retention protect active, failed and uncertain work. Stop older writers before schema/accounting migration and preserve a closed backup. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). Real PostgreSQL/S3, remote authentication, multi-node operation and sustained physical throughput remain unqualified. Live cutover, audit release and shared-standards publication have not occurred. -The new authenticated `PrintingLocalHost` connects native QQQ screens and device routes to the same durable H2 database. It uses native table-based authentication, preserves the existing account/pairing, rejects cross-origin cookie mutations and DNS rebinding, and starts QQQ's existing retention scheduler. Tests cover uppercase and lowercase H2 identifiers, native login/logout and tenant access, API heartbeat, scheduler startup and unchanged password hashes. A fresh read-only review found no actionable confirmed finding. Browser rendering and an actual scheduled cleanup tick remain deployment checks. The synthetic `PrintingUiDemo` stays separate. See [local deployment](LOCAL-HOST.md). - -Earlier fixes add replay-safe native processes, validated facility selectors, strict wire fields, duplicate-risk preservation, dedicated QQQ S3 storage and native admin retention with a **365-day default**. Active, failed and uncertain work remains protected. Stop older writers before admission-accounting migration; existing content is preserved. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). - -Draft PR #10 passed every required hosted check at `d52dfd7`, including the authenticated host, exact runtime inventory and HTTP follow-up; its CI JAR/POM checksums were verified. The H2 dependency correction requires fresh hosted checks before deployment. No live database migration, S3 provisioning, purge, merge, release or host deployment has occurred. Real PostgreSQL/S3, remote host authentication, multi-node operation and sustained physical throughput remain unqualified. All main-branch requirements apply to administrators. The low-severity same-repository workflow advisory has a documented [compatibility assessment](CI-COMPATIBILITY.md); no security check is disabled. - -Master coordination at `R:\agent-comms\qqq-cloud-printing.md` is acknowledged. Record Files owns indefinitely retained originals; printing owns its dedicated delivery copies. Their compiled source-reader/actor authorization contract remains pending. Standalone byte submission continues. Installed Windows baseline remains 0.1.7; unsigned prereleases are user-approved. +Record Files' **21:38 America/Chicago checkpoint on 2026-10-30** is acknowledged. Its latest typed reader and replacement-session/current-actor fixtures are candidate source with focused tests; the complete installed artifact is still pending, and the earlier snapshot is not the final contract. The teams agree on the consumer-owned verified observation boundary. Printing still needs its durable accepted source/initiator/job/destination binding and tenant-scoped committed event receipt before an adapter can be enabled. No adapter is enabled, no browser session or fabricated authority is retained, and spool completion is not physical-output confirmation. Standalone byte submission remains independent. ## Branded diagnostic documents — v0.1.3-rc.1 verified and published diff --git a/docs/TODO.md b/docs/TODO.md index ebd4bdf..c36878c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,20 +1,20 @@ # Follow-up -- [x] Consume bounded claim/cancel bodies before side effects; split-body regression fails before fix, full isolated UI-profile gate passes 66 tests. -- [x] Verify the HTTP follow-up on fresh hosted checks at d52dfd7. -- [x] Identify and fix the prepared-copy retention failure by pinning H2 2.4.240; controlled old/fixed-driver comparison and full 67-test gate pass. -- [ ] Verify fresh H2-change hosted checks and the exact dependency payload through native startup, shutdown and cold reopen before live cutover. -- [x] Reconcile UI-profile Maven runtime dependencies with scanner inventories; 204 exact coordinates, ten guard regressions and workflow lint pass locally. -- [x] Run hosted security/build gates for the runtime-SBOM workflow correction at d52dfd7; dependency follow-up requires its own fresh checks. +- [x] Verify the H2 2.4.240 candidate at `fb99faf`: 67 Java tests, zero Checkstyle violations, hosted build/CodeQL/security pass, and all 204 selected runtime dependencies verified. +- [x] Qualify the exact CI artifact on an isolated closed-data copy: native frontend/authentication/query/denial/logout/retention checks, actual scheduled zero-removal tick, forced termination, read-only inspection before initialization, and successful cold restart; all 11 tables and retained state preserved. +- [x] Add the exact reviewed H2 version guard and license attribution/evidence; 17 Python guard tests and workflow lint pass. No active package license exception is enabled. +- [ ] Obtain approval for the prepared central H2 metadata exception record, then apply only its package-scoped workflow input and require fresh complete hosted gates. +- [ ] Qualify graceful application shutdown separately from the passing forced-termination/cold-reopen check. +- [ ] Perform authorized live paired-host cutover from a closed backup after all gates pass; preserve pairing and verify the real native records and connector heartbeat. - [x] Finish audit regressions for native-process replay, strict options/inventory validation, retained duplicate risk and event field bounds; full gate passes 53 tests. - [x] Implement admin-configurable document retention (one-year default), bounded scheduled cleanup and existing QQQ object-storage integration, retaining history/receipts and protecting active or uncertain output. -- [ ] Integrate Record Files' authorized immutable-version reader after its contract is implemented; define async source revocation/correlation and eliminate host-outbox byte copies for that path. +- [ ] Integrate Record Files only after its latest candidate becomes a verified installed artifact; implement the durable accepted-source/actor binding and committed observation receipt against the agreed public types. Adapter remains disabled. - [ ] Verify tagged audit release gates, published provenance and deployed-host qualification. -- [x] Audit PR #10 at aeac1a6 passes all hosted checks; JAR/POM artifacts downloaded and verified. Authenticated local-host addition needs fresh hosted checks. +- [x] Historical audit PR #10 checkpoint aeac1a6 passed hosted checks; current H2/native qualification and remaining license gate are recorded above. - [x] Complete the independent baseline scan and current-source reviews of all five findings and remediations. - [x] Add authenticated native QQQ local host sharing paired data with device routes and the existing scheduler; 65 tests/zero Checkstyle pass and independent source review found no blocker. -- [ ] Deploy the authenticated local host with a closed-database backup; verify browser rendering, preserved pairing and an actual scheduled cleanup tick. +- [ ] Complete live authenticated-host deployment after the isolated native acceptance above; keep remote/S3 qualification separate. - [x] Versioned shared contract and HTTP happy-path test. - [x] Durable enrollment, device-scoped authentication, inventory and printer approval. -- 2.54.0 From 00dd2dda2d6ebbf043057e400dd8618870996207 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 21:57:39 -0500 Subject: [PATCH 13/35] fix(build): package license and attribution notices Use standard Maven resources to include root LICENSE and NOTICE under META-INF while retaining existing runtime assets. No implementation or dependency changes. Validation: isolated frontend-profile verify passes 67 tests with zero Checkstyle violations. JAR inspection verifies exact notice bytes, all 38 implementation classes unchanged, font/license/logo resources retained, and unchanged dependency declarations. Independent review found no issue. Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 3 ++- README.md | 2 +- docs/H2-LICENSE-REVIEW.md | 2 +- pom.xml | 12 +++++++++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea96a08..1f4144a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ - Consume bounded claim/cancel request bodies before mutation so a persistent HTTP connection remains usable for the subsequent status event. - Pin H2 2.4.240 to fix close-time compaction data loss; verify file-backed schema/settings across cold reopens and document closed-backup upgrade qualification. - Verify the exact frontend-inclusive runtime inventory in Maven, Trivy and OSV; fail on malformed or incomplete scanner evidence. -- Full local verification passed 67 tests with zero failures/errors/skips and Checkstyle violations. Final dependency-change hosted checks remain pending; this entry does not claim release or live storage migration. +- Include repository LICENSE and NOTICE under META-INF using standard Maven resources; preserve font license, provenance and diagnostic assets. +- Full local verification passed 67 tests with zero failures/errors/skips and Checkstyle violations. The H2-fixed artifact passed hosted build/security/CodeQL and isolated native cold-restart checks. Fresh packaging checks and the license-metadata PR gate remain pending; this entry does not claim release or live storage migration. ## 0.1.3-rc.1 — 2026-10-30 diff --git a/README.md b/README.md index 00270c5..5afd83c 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ On 2026-10-29, both the development Windows connector and the installed LocalSys ## CI and releases -Pull requests and `main` pushes run Java 21/Maven wrapper verification with the immutable sibling protocol checkout. A tag matching the POM version runs build/tests and the secrets, workflow, dependency and malicious-package security gates before publishing the tested JAR, POM and `SHA256SUMS` as GitHub release assets. Build verification enables the `ui-demo` profile used by the local host. Maven generates its selected compile/runtime graph as a CycloneDX SBOM, including frontend assets. Trivy and OSV scan that graph; a guard rejects missing or different package versions, missing frontend assets, and empty inventories. CI retains the resolved graph and scanner evidence. OSV uses `--all-packages`; its pinned 2.6.0 reporter [omits Maven groups](https://github.com/google/osv-scanner/issues/3099), so the guard checks every reported artifact/version and rejects ambiguous names while the verified input retains full package URLs. Hyphenated versions create prereleases. The workflow does not publish to Maven Central, deploy a QQQ application or print to hardware. The [0.1.0-rc.1 release](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.0-rc.1) passed these hosted gates. +Pull requests and `main` pushes run Java 21/Maven wrapper verification with the immutable sibling protocol checkout. A tag matching the POM version runs build/tests and the secrets, workflow, dependency and malicious-package security gates before publishing the tested JAR, POM and `SHA256SUMS` as GitHub release assets. Build verification enables the `ui-demo` profile used by the local host. Maven generates its selected compile/runtime graph as a CycloneDX SBOM, including frontend assets. Trivy and OSV scan that graph; a guard rejects missing or different package versions, missing frontend assets, and empty inventories. CI retains the resolved graph and scanner evidence. OSV uses `--all-packages`; its pinned 2.6.0 reporter [omits Maven groups](https://github.com/google/osv-scanner/issues/3099), so the guard checks every reported artifact/version and rejects ambiguous names while the verified input retains full package URLs. Hyphenated versions create prereleases. The packaged JAR includes the repository LICENSE and NOTICE under `META-INF`, alongside the existing font license and diagnostic assets. The workflow does not publish to Maven Central, deploy a QQQ application or print to hardware. The [0.1.0-rc.1 release](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.0-rc.1) passed these hosted gates. ## Printing home diff --git a/docs/H2-LICENSE-REVIEW.md b/docs/H2-LICENSE-REVIEW.md index b61fde7..fa4e880 100644 --- a/docs/H2-LICENSE-REVIEW.md +++ b/docs/H2-LICENSE-REVIEW.md @@ -8,7 +8,7 @@ The dependency-review job for `fb99faf` reported `com.h2database:h2@2.4.240` as The upstream [2.4.240 license](https://github.com/h2database/h2database/blob/version-2.4.240/LICENSE.txt) expressly offers a choice of MPL-2.0 or EPL-1.0. Its [license FAQ](https://github.com/h2database/h2database/blob/version-2.4.240/h2/src/docsrc/html/license.html) describes unmodified binary redistribution and attribution. The published Maven POMs for [2.4.240](https://repo.maven.apache.org/maven2/com/h2database/h2/2.4.240/h2-2.4.240.pom) and [2.3.232](https://repo.maven.apache.org/maven2/com/h2database/h2/2.3.232/h2-2.3.232.pom) both declare MPL 2.0 and EPL 1.0. The upgrade does not introduce a new license choice. -The local host uses the unmodified Maven Central H2 JAR under the MPL-2.0 option. The reviewed 2.4.240 binary SHA-256 is `29b70e427cc1c40cdc376283adbb0cc62853073797bb5fe5761f81fe73d57ce0`. Preserve H2 attribution, license information and access to the corresponding [source tag](https://github.com/h2database/h2database/tree/version-2.4.240) when distributing a host runtime. The QBit's own Apache-2.0 license does not replace third-party terms. +The local host uses the unmodified Maven Central H2 JAR under the MPL-2.0 option. The reviewed 2.4.240 binary SHA-256 is `29b70e427cc1c40cdc376283adbb0cc62853073797bb5fe5761f81fe73d57ce0`. Preserve H2 attribution, license information and access to the corresponding [source tag](https://github.com/h2database/h2database/tree/version-2.4.240) when distributing a host runtime. The QBit's own Apache-2.0 license does not replace third-party terms. Standard Maven resources include the repository LICENSE and NOTICE under `META-INF` in the QBit JAR; the existing font license/provenance assets remain included. The earlier d1821be artifact omitted the root notices, so its source attribution alone did not demonstrate packaged attribution. ## Applicable policy and scope diff --git a/pom.xml b/pom.xml index 2ec00bb..97838f3 100644 --- a/pom.xml +++ b/pom.xml @@ -31,7 +31,17 @@ ui-demo com.kingsrook.qqqqqq-frontend-next1.0.0-RC.37runtime - + + + src/main/resources + + ${project.basedir} + META-INF + LICENSENOTICE + false + + + org.apache.maven.pluginsmaven-checkstyle-plugin3.3.1 com.puppycrawl.toolscheckstyle10.12.6 checkstyle/config.xmltruetruewarningtrue -- 2.54.0 From 45261d5508922b1fab7f369d7eb7e97815e10a5d Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 30 Oct 2026 23:09:20 -0500 Subject: [PATCH 14/35] fix(storage): preserve documents when S3 key layouts change Bind external print documents to the reviewed native S3 key layout and separator. Serialize destination components as ordered JSON so valid multiline prefixes cannot collide. Refuse incompatible reads, retries and cleanup while preserving content availability and retained-byte accounting; validate new retries before upload reservation and keep existing retry receipts and inline documents compatible. Validation: isolated Java 21 verify passes 72 tests and zero Checkstyle violations. The multiline-prefix regression fails before the fix; independent review found no remaining issue. Actual Windows-to-Linux S3 qualification returns 409 before read/retry/cleanup IO, preserves the exact object and counters, and records zero transmissions with a positive-control observer. Five documentation files pass Markdown lint. Fresh hosted checks and the existing H2 metadata approval remain pending. Co-authored-by: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 4 +- docs/AUDIT-2026-10-30.md | 11 +- docs/SESSION-STATE.md | 18 +- docs/STORAGE-AND-RETENTION.md | 10 + docs/TODO.md | 7 +- .../printing/PrintingDocumentStorage.java | 12 +- .../qbits/printing/PrintingService.java | 8 +- .../printing/PrintingStorageBindingTest.java | 430 ++++++++++++++++++ 8 files changed, 484 insertions(+), 16 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingStorageBindingTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4144a..55f2c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Make native enrollment and test-print execution replay-safe and retain known duplicate-output risk after later failures. - Strictly validate print options, inventory formats/status and event bounds before persistence; recheck current queue format support at claim time. - Add dedicated S3 document storage through the host's QQQ storage API, tenant object namespaces and immutable destination/reference checks. +- Bind S3 documents to the reviewed native key layout and platform separator using unambiguous ordered identity components, including multiline base paths; reject incompatible or old experimental bindings before historical object access and preserve cleanup accounting. Validate new retry sources before reserving uploads while retaining replay and inline database compatibility. Portable upstream keys and historical-object migration remain separate work. - Add admin retention settings with a 365-day default, reviewed cleanup and bounded scheduled maintenance; preserve job history and uncertain work. - Use named facility choices during enrollment and revalidate them under the current user's tenant/record permissions. - Require release security/CodeQL gates, verified artifact promotion and build provenance; report the real packaged QBit version. @@ -14,7 +15,7 @@ - Pin H2 2.4.240 to fix close-time compaction data loss; verify file-backed schema/settings across cold reopens and document closed-backup upgrade qualification. - Verify the exact frontend-inclusive runtime inventory in Maven, Trivy and OSV; fail on malformed or incomplete scanner evidence. - Include repository LICENSE and NOTICE under META-INF using standard Maven resources; preserve font license, provenance and diagnostic assets. -- Full local verification passed 67 tests with zero failures/errors/skips and Checkstyle violations. The H2-fixed artifact passed hosted build/security/CodeQL and isolated native cold-restart checks. Fresh packaging checks and the license-metadata PR gate remain pending; this entry does not claim release or live storage migration. +- Full local verification passed 72 tests with zero failures/errors/skips and Checkstyle violations, including incompatible S3 bindings, multiline prefix retargeting, retry accounting and inline document compatibility. Actual cross-platform refusal preserves objects and accounting with zero SDK requests. The H2-fixed artifact passed hosted build/security/CodeQL and isolated native cold-restart checks. Fresh S3 guard CI remains pending; this entry does not claim release or live storage migration. ## 0.1.3-rc.1 — 2026-10-30 @@ -23,7 +24,6 @@ - Add Letter/A4 and 4 × 6 profile options with explicit 203/300/600-dpi label resolution, compatible defaults and validation. - Add font/media, deterministic generation, ZPL bitmap reconstruction and operator action regression coverage. - ## 0.1.2-rc.1 — 2026-10-30 - Synchronize client-managed device/printer display names, descriptions and locations; preserve physical computer/queue names and stable identifiers. diff --git a/docs/AUDIT-2026-10-30.md b/docs/AUDIT-2026-10-30.md index 8fae253..6c5793a 100644 --- a/docs/AUDIT-2026-10-30.md +++ b/docs/AUDIT-2026-10-30.md @@ -1,6 +1,6 @@ # Server printing audit -Target: 0.1.4-rc.1. Final local implementation verification passed **62 tests, zero failures/errors/skips and zero Checkstyle violations**. The independent baseline scan and source remediation review are complete. Earlier audit and CI guard commits passed hosted checks; final remediation commits require fresh verification. No live database, object storage, enrollment or printer was changed. +Target: 0.1.4-rc.1. Current local implementation verification passed **72 tests, zero failures/errors/skips and zero Checkstyle violations**. The independent baseline scan and subsequent source remediation reviews are complete. Earlier audit and CI guard commits passed hosted checks; the latest S3 correction requires fresh hosted verification. No live database, customer bucket, enrollment or printer was changed. ## Corrected behavior @@ -9,6 +9,7 @@ Target: 0.1.4-rc.1. Final local implementation verification passed **62 tests, z - A later failure retains known submission risk. Reprint requires explicit consent when output might already exist, and retry replay resolves its existing receipt even if the original content has expired. - Enrollment offers authorized named facility choices, with a host-source option and a named standalone default. Backend validation rejects forged and foreign-tenant selections. - Print documents can use the existing QQQ S3 storage API with a dedicated printing bucket, tenant namespace and exact job/reference/destination validation. Legacy database documents remain readable. +- Immutable S3 bindings include the reviewed physical key layout and host separator. Ordered JSON preserves component boundaries, including multiline base paths. Incompatible historical content is rejected before reads, new retry reservations or cleanup; document availability and accounting remain intact. - Durable upload intents and exact orphan cleanup account for failed uploads/queue commits. Cleanup commits each confirmed document removal independently. A renewable lease prevents normal overlapping maintenance; all work reuses a single connection and passes real one-/two-connection pool concurrency regressions. - Native administration exposes the default 365-day policy, read-only storage scope, backlog and last cleanup result. A host scheduler runs bounded batches every five minutes. Age starts at server-observed successful completion; active, failed, uncertain and unknown-completion jobs remain protected. - Release publication requires build/security/CodeQL checks, verified artifact promotion and build provenance. Packaged QBit metadata uses the JAR's real Implementation-Version. @@ -19,12 +20,18 @@ Target: 0.1.4-rc.1. Final local implementation verification passed **62 tests, z The gate exercises real H2 persistence and bounded pools, QQQ StorageAction with an in-memory backend, native process review/resume, current-session record security and real QApplicationJavalinServer route registration/authentication over loopback. Failure tests cover partial upload/deletion recovery, per-document commit boundaries, conflicting/foreign references, destination retargeting, overlapping cleanup and original-content expiry during retry replay. The built JAR manifest reports 0.1.4-rc.1. +Separate disposable S3-compatible fixtures exercise the actual QQQ S3StorageAction and AWS SDK. Windows and Linux same-platform controls each passed 65 checks, including PDF/ZPL, multipart upload, integrity, tenant separation and retention. A controlled Windows-to-Linux migration then reproduced the RC.20 path defect: the old binding matched, reads returned S3 404, and cleanup marked content unavailable while the original Windows-key object remained. + +The corrected runtime rejects that migration with HTTP 409 for reads and retries and a recorded cleanup error with zero removals. A native SDK transmission observer, checked against a successful HEAD request, recorded zero requests for the refused read, retry and cleanup. The original object, content availability, tenant/device byte accounting and job/admission/orphan counts remained unchanged. Golden binding tests and a regression for the reproduced multiline-prefix collision also pass. This qualifies refusal of the incompatible layout; it does not implement portable upstream keys or historical-object migration. + +H2 2.4.240 fixes the observed close-time compaction schema loss. Exact earlier CI artifacts passed native authentication/query/denial checks, an actual scheduled retention tick, forced termination and cold restart, plus a separate graceful registered-hook shutdown. Independent read-only inspection before application initialization preserved all required tables, settings and counts. These results remain tied to their tested artifacts; they do not qualify Windows reboot or unattended supervision. + Schema initialization adds consumed-enrollment receipts, storage references/content availability, completion timestamps, retention settings/lease and upload intents. Existing inline bytes are retained; completion dates are backfilled only from durable successful events. No automatic inline-to-S3 copy or live purge occurs. Schema changes should be qualified against a backed-up deployment database before rollout. Admission accounting also backfills retained bytes and recent allocations. Stop older writers before this migration; mixed-version writers bypass accounting and are unsupported. Regression cases cover concurrent tenant admissions, retained terminal payloads, replay at capacity, failed cleanup, unknown legacy uploads, rotating inventory and long-name pagination. See [operations](OPERATIONS.md) for configurable limits and recovery. ## Remaining boundaries -Live S3 endpoints, IAM/bucket policies, external bucket users, PostgreSQL, multi-node lease-expiry behavior, production scheduler execution, remote TLS deployment and sustained 10,000-print/day throughput are not qualified by these tests. SQL and object deletion are not one transaction; crashes between them require idempotent recovery. S3 versioning and unfinished multipart parts have separate lifecycle semantics. See [storage and retention](STORAGE-AND-RETENTION.md). +Production S3 endpoints, IAM/bucket policies, external bucket users, PostgreSQL, multi-node lease-expiry behavior, remote TLS deployment and sustained 10,000-print/day throughput are not qualified by these tests. The private native scheduler check is separate from live deployment. SQL and object deletion are not one transaction; crashes between them require idempotent recovery. S3 versioning and unfinished multipart parts have separate lifecycle semantics. See [storage and retention](STORAGE-AND-RETENTION.md). The completed independent deep scan covers the frozen pre-audit baseline, not the subsequently added storage feature. Its two server findings were remediated and received a fresh independent bypass/regression review; that review's wide-index compatibility finding was corrected before the final gate. Separate review and regressions cover the new storage boundaries. These results do not prove absence of defects or qualify the live deployment boundaries above. No numerical quality score is assigned. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 5c2217a..0a2fea7 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -2,17 +2,23 @@ ## Audit candidate 0.1.4-rc.1 -Exact source `fb99faf` passed hosted [build](https://github.com/QRun-IO/qbit-printing/actions/runs/41160240077), [CodeQL](https://github.com/QRun-IO/qbit-printing/actions/runs/41160240076) and [security](https://github.com/QRun-IO/qbit-printing/actions/runs/41160240078). The isolated frontend-profile gate passes **67 Java tests, zero failures/errors/skips and zero Checkstyle violations**. All **204** runtime dependencies match the selected Maven graph and scanner inventories, with exactly one H2 2.4.240 JAR. Refreshed Trivy reports zero HIGH/CRITICAL findings; OSV reports zero malicious findings. +The latest S3 safeguard passes **72 Java tests, zero failures/errors/skips and zero Checkstyle violations**, with independent source review complete. It binds documents to the reviewed physical key layout and native separator using unambiguous ordered JSON. Historical reads, new retries and cleanup reject incompatible bindings before object IO; inline database content and prior retry receipts remain compatible. Old experimental S3 bindings are not silently adopted. -The exact CI application JAR and verified runtime payload passed isolated native acceptance against a fresh private copy of a closed database: real frontend, native authentication, bounded table counts/queries, denied requests, logout and retention settings at the **365-day default**. The native scheduler actually ran at `2026-10-31T02:45:00.087433600Z` and removed zero documents. After identity-verified forced termination, an independent SELECT-only read-only H2 inspection **before application initialization** found all 11 tables, including the four audit tables, with retention and record counts preserved. Restarting the same exact artifact passed the native checks again at `2026-10-31T02:47:10Z`. This qualifies the tested forced-termination/cold-reopen path; graceful shutdown remains a separate qualification. No live paired database, remote host, S3 storage or printer was changed by these checks. +Actual isolated Windows-to-Linux qualification passed on local candidate JAR SHA256 `9163b5fb89c981b70d2bd416ca3920a17bcbda2fe61eaa7190f050cc27759495` with all 204 runtime libraries verified on both hosts. Read and retry returned 409; cleanup recorded an error with zero removals. A positive-control-checked native SDK observer recorded zero transmissions for those refusals. Content availability, tenant/device retained bytes, admission/orphan/job counts and the exact original object stayed unchanged. The earlier candidate reproduced false expiration of a Windows-key object from Linux; this correction prevents that loss of availability. Portable upstream keys, explicit historical migration and production IAM remain separate work. Fresh hosted checks for this correction are pending. + +Packaged candidate `00dd2dd` passed hosted [build](https://github.com/QRun-IO/qbit-printing/actions/runs/41162272066), [CodeQL](https://github.com/QRun-IO/qbit-printing/actions/runs/41162272168) and [security](https://github.com/QRun-IO/qbit-printing/actions/runs/41162272058). The isolated frontend-profile gate passes **67 Java tests, zero failures/errors/skips and zero Checkstyle violations**. All **204** runtime dependencies match the selected Maven graph and scanner inventories, with exactly one H2 2.4.240 JAR. Refreshed Trivy reports zero HIGH/CRITICAL findings; OSV reports zero malicious findings. The exact CI package includes matching LICENSE/NOTICE resources; all 38 implementation classes and existing runtime assets match the earlier `fb99faf` candidate. + +The exact `fb99faf` CI application JAR and verified runtime payload passed isolated native acceptance against a fresh private copy of a closed database: real frontend, native authentication, bounded table counts/queries, denied requests, logout and retention settings at the **365-day default**. The native scheduler actually ran at `2026-10-31T02:45:00.087433600Z` and removed zero documents. After identity-verified forced termination, an independent SELECT-only read-only H2 inspection **before application initialization** found all 11 tables, including the four audit tables, with retention and record counts preserved. Restarting the same exact artifact passed the native checks again at `2026-10-31T02:47:10Z`. This qualifies the tested forced-termination/cold-reopen path. The graceful-shutdown check is recorded below. No live paired database, remote host, S3 storage or printer was changed by these checks. + +Graceful shutdown is now separately verified on the exact packaged `00dd2dd` CI artifact. A fresh isolated closed-data copy passed native acceptance. A minimal private Java Attach agent invoked `System.exit(0)` after the owned process identity was checked; the real registered launcher hook stopped Quartz and HTTP, and the supervisor recorded JVM exit 0 at `2026-10-31T03:09:26.8635088Z`. Read-only JDBC inspection before reinitialization confirmed all required tables, retention and counts. The separate Attach client timed out waiting for a reply after server exit and was cleaned up; the server itself exited normally. All private children stopped and no physical output was requested. This does not qualify Windows reboot, service-manager shutdown or unattended supervision. The prepared-copy failure was the upstream H2 2.3.232 close-time compaction defect (#4247/#4249). Replacing only H2 with **2.4.240** preserves the migrated schema using normal compaction. No warm-up, retry, delay or disabled-compaction production workaround is present. The public fresh-file cold-reopen test checks durability; it does not claim to reproduce every affected old-driver database. See [local runtime and backup guidance](LOCAL-HOST.md). -The [PR gate](https://github.com/QRun-IO/qbit-printing/actions/runs/41160240342) remains blocked **only by H2 license metadata classification**; dependency vulnerability review passed. Tagged H2 terms are unchanged and permit the unmodified MPL-2.0 option. The reviewed inventory guard now requires exactly H2 2.4.240; **17 Python guard tests** pass, with workflow lint and independent review complete. See [license evidence](H2-LICENSE-REVIEW.md). The proposed central exception record and package-only workflow input remain unapplied pending approval; no active workflow exception, global license allowance or security bypass is enabled. Fresh hosted checks are required for the guard/documentation commit. +The [PR gate](https://github.com/QRun-IO/qbit-printing/actions/runs/41162272419) remains blocked **only by H2 license metadata classification**; dependency vulnerability review passed. Tagged H2 terms are unchanged and permit the unmodified MPL-2.0 option. The reviewed inventory guard now requires exactly H2 2.4.240; **17 Python guard tests** pass, with workflow lint and independent review complete. See [license evidence](H2-LICENSE-REVIEW.md). The guard and packaging follow-ups passed hosted build/security/CodeQL checks. The proposed central exception record and package-only workflow input remain unapplied pending approval; no active workflow exception, global license allowance or security bypass is enabled. Applying that input requires fresh complete hosted checks before release. The audit also fixes native-process replay, tenant-scoped facility selectors, duplicate-output risk preservation, atomic queue/retained-byte/rate admission and bounded printer history. Dedicated QQQ document storage and scheduled retention protect active, failed and uncertain work. Stop older writers before schema/accounting migration and preserve a closed backup. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). Real PostgreSQL/S3, remote authentication, multi-node operation and sustained physical throughput remain unqualified. Live cutover, audit release and shared-standards publication have not occurred. -Record Files' **21:38 America/Chicago checkpoint on 2026-10-30** is acknowledged. Its latest typed reader and replacement-session/current-actor fixtures are candidate source with focused tests; the complete installed artifact is still pending, and the earlier snapshot is not the final contract. The teams agree on the consumer-owned verified observation boundary. Printing still needs its durable accepted source/initiator/job/destination binding and tenant-scoped committed event receipt before an adapter can be enabled. No adapter is enabled, no browser session or fabricated authority is retained, and spool completion is not physical-output confirmation. Standalone byte submission remains independent. +Record Files' **21:54 America/Chicago checkpoint on 2026-10-30** is acknowledged. Its latest typed reader and configured reporting authority are candidate source with focused tests; the verified installed artifact is still pending, and the earlier snapshot is not the final contract. The teams agree on the consumer-owned observation boundary and are resolving identifier bounds and optional spooler-ID canonicalization. Printing still needs its durable accepted source/initiator/job/destination binding, tenant-scoped committed event receipt and legitimate current native reporting identity before an adapter can be enabled. No adapter is enabled, no browser session or fabricated authority is retained, and spool completion is not physical-output confirmation. Standalone byte submission remains independent. ## Branded diagnostic documents — v0.1.3-rc.1 verified and published @@ -36,9 +42,9 @@ Final Windows MSI rebuild is in progress. Actual elevated upgrade/interactive ac ## Historical publication and implementation evidence -Published: https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.0-rc.1 at f7f451a. Tagged build/test/Checkstyle, secrets, workflow, dependency and malicious-package checks all passed before publication. JAR, POM and SHA256SUMS assets are public. Guava is fixed at 33.7.2-jre. The security workflow resolves Maven dependencies first, verifies the scanned graph and feeds the resulting CycloneDX inventory to OSV; this avoids Maven throttling and the upstream parent-POM placeholder parsing issue without skipping checks. Main protection requires verify and all three security jobs. Release JAR SHA-256: fefce42496a534f3227f496a0e5f9a6f8e9209e7175ddf14b18166968a6ad4d6. +Published: [v0.1.0-rc.1](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.0-rc.1) at f7f451a. Tagged build/test/Checkstyle, secrets, workflow, dependency and malicious-package checks all passed before publication. JAR, POM and SHA256SUMS assets are public. Guava is fixed at 33.7.2-jre. The security workflow resolves Maven dependencies first, verifies the scanned graph and feeds the resulting CycloneDX inventory to OSV; this avoids Maven throttling and the upstream parent-POM placeholder parsing issue without skipping checks. Main protection requires verify and all three security jobs. Release JAR SHA-256: fefce42496a534f3227f496a0e5f9a6f8e9209e7175ddf14b18166968a6ad4d6. -Live frontend RC.37 acceptance: dashboard links and the six operational printer columns were inspected; selected-printer Print a test label review/submit completed successfully against synthetic data; record actions were verified. Safe screenshots and instructions are in UI-WALKTHROUGH.md. No additional physical print was sent by this UI demo. At that earlier acceptance checkpoint, the local preview ran at http://127.0.0.1:18084 (override of default 18082); inspect current processes before assuming it is still running. +Live frontend RC.37 acceptance: dashboard links and the six operational printer columns were inspected; selected-printer Print a test label review/submit completed successfully against synthetic data; record actions were verified. Safe screenshots and instructions are in UI-WALKTHROUGH.md. No additional physical print was sent by this UI demo. At that earlier acceptance checkpoint, the local preview ran at `http://127.0.0.1:18084` (override of default 18082); inspect current processes before assuming it is still running. 2026-10-29: Initial QQQ 4.1 printing QBit and durable protocol server prepared for 0.1.0-rc.1 publication. QQQ baseline 4.1.0-RC.20. Server scope includes JDBC storage, scoped device credentials, inventory approval, queue submission, replay-safe events, metadata, native enrollment/enable/test-print processes and durable enqueue target. Root owns commits/publication; this checkpoint does not assert remote publication. diff --git a/docs/STORAGE-AND-RETENTION.md b/docs/STORAGE-AND-RETENTION.md index 68d6f9f..e4de0b5 100644 --- a/docs/STORAGE-AND-RETENTION.md +++ b/docs/STORAGE-AND-RETENTION.md @@ -12,6 +12,16 @@ The code validates the selected S3 metadata, detects conflicting bucket bindings Application metadata cannot prove that a bucket is unused by another application or that deployed IAM/bucket policies are correct. Deployment qualification must check those external controls, public-access blocking, transport/encryption policy and the intended customer/environment boundary. No live bucket has been created or changed by the audit. +## S3 key layout and migration + +QQQ filesystem 4.1.0-RC.20 builds S3 keys with the host filesystem separator. With empty base paths, Windows writes two leading backslashes before the logical `printing//.ext` reference; Linux writes the canonical reference. Same-platform object operations have been qualified in isolated S3-compatible storage. The logical reference alone is therefore insufficient for IAM prefix rules or migration between platforms. + +Printing fingerprints the reviewed `qqq-s3-filesystem-v1` layout and the actual `File.separatorChar` code point (47 or 92), alongside the endpoint, bucket, region, path-style setting and backend/table base paths. Ordered JSON encoding preserves the identity component boundaries, including valid multiline base paths. A different layout rejects historical reads, new retries and cleanup with a destination-mismatch error before accessing those objects. New retries whose source binding is already incompatible fail before reserving another upload; the document loader also rechecks the binding when reading. An already-created retry still returns its idempotent receipt. Cleanup preserves content availability, retained-byte accounting and orphan reservations on mismatch. + +This S3 feature is unreleased. Old experimental fingerprints without the layout marker are deliberately rejected, even on their original platform. Startup never rewrites those bindings or moves/deletes their objects. Preserve the original database and objects until an explicitly reviewed migration or disposal; do not replace hashes merely to bypass the guard. Inline database documents remain readable and can be explicitly retried into the configured destination without any automatic storage migration. + +This guard does not make QQQ keys portable. The upstream owner must provide portable key construction and legacy-key handling. Adopting that change, including a change on the same operating system, requires an explicit layout-marker review and a migration plan for historical bindings and exact objects. The marker is a reviewed compatibility contract, not automatic detection of dependency implementation changes. Cross-platform migration and production IAM remain separate qualification work. + ## Retained originals and delivery copies Record Files owns original versions and attachments; document generation owns rendering. Printing owns a separate delivery payload and queue receipts. Never bind printing cleanup to a Record Files bucket or pass an original's storage key as a printing-owned reference. Printing expiration must not remove a source attachment or weaken its retention policy. diff --git a/docs/TODO.md b/docs/TODO.md index c36878c..5b5b1db 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,15 +1,18 @@ # Follow-up +- [x] Correct S3 layout and multiline-prefix binding ambiguity; full isolated gate passes 72 tests with zero Checkstyle violations and independent review complete. +- [x] Reproduce the actual Windows-to-Linux false-expiration defect, then qualify the corrected runtime: zero SDK requests for refused reads/retries/cleanup and preserved objects, availability and accounting. +- [ ] Require fresh hosted gates for the S3 correction; portable upstream keys, historical migration and production IAM remain separate qualification work. - [x] Verify the H2 2.4.240 candidate at `fb99faf`: 67 Java tests, zero Checkstyle violations, hosted build/CodeQL/security pass, and all 204 selected runtime dependencies verified. - [x] Qualify the exact CI artifact on an isolated closed-data copy: native frontend/authentication/query/denial/logout/retention checks, actual scheduled zero-removal tick, forced termination, read-only inspection before initialization, and successful cold restart; all 11 tables and retained state preserved. - [x] Add the exact reviewed H2 version guard and license attribution/evidence; 17 Python guard tests and workflow lint pass. No active package license exception is enabled. - [ ] Obtain approval for the prepared central H2 metadata exception record, then apply only its package-scoped workflow input and require fresh complete hosted gates. -- [ ] Qualify graceful application shutdown separately from the passing forced-termination/cold-reopen check. +- [x] Qualify registered JVM-hook shutdown on exact `00dd2dd`: native acceptance, Quartz/HTTP shutdown, exit 0 and independent read-only cold inspection before initialization pass. Windows reboot/service-manager behavior remains separate. - [ ] Perform authorized live paired-host cutover from a closed backup after all gates pass; preserve pairing and verify the real native records and connector heartbeat. - [x] Finish audit regressions for native-process replay, strict options/inventory validation, retained duplicate risk and event field bounds; full gate passes 53 tests. - [x] Implement admin-configurable document retention (one-year default), bounded scheduled cleanup and existing QQQ object-storage integration, retaining history/receipts and protecting active or uncertain output. -- [ ] Integrate Record Files only after its latest candidate becomes a verified installed artifact; implement the durable accepted-source/actor binding and committed observation receipt against the agreed public types. Adapter remains disabled. +- [ ] Implement the optional Record Files adapter against its verified frozen contract: durable accepted-source/actor binding, legitimate current reporting identity and committed observation receipt. Adapter remains disabled; standalone printing does not depend on it. - [ ] Verify tagged audit release gates, published provenance and deployed-host qualification. - [x] Historical audit PR #10 checkpoint aeac1a6 passed hosted checks; current H2/native qualification and remaining license gate are recorded above. - [x] Complete the independent baseline scan and current-source reviews of all five findings and remediations. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java index c2647d8..de7f8a9 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java @@ -35,6 +35,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData; import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails; +import org.json.JSONArray; /******************************************************************************* @@ -43,6 +44,7 @@ import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBack *******************************************************************************/ public class PrintingDocumentStorage { + private static final String S3_KEY_LAYOUT = "qqq-s3-filesystem-v1"; private final QInstance instance; private final String prefix; private final boolean allowMemoryTest; @@ -140,6 +142,9 @@ public class PrintingDocumentStorage /*************************************************************************** ** Validate dedicated bucket use visible in this host's metadata. External ** bucket policies and unrelated applications still require deployment review. + ** RC.20 keys use File.separator; changing that layout requires explicit + ** binding review and migration, not silently adopting historical objects. + ** Ordered JSON preserves component boundaries, including multiline paths. ***************************************************************************/ public String binding(String table) { @@ -163,8 +168,9 @@ public class PrintingDocumentStorage throw new IllegalArgumentException("Printing bucket is shared with a non-printing table; configure a dedicated bucket"); } } - return (digest(identity + "\n" + java.util.Objects.toString(s3.getRegion(), "") + "\n" + java.util.Objects.toString(s3.getPathStyleAccess(), "false") - + "\n" + java.util.Objects.toString(s3.getBasePath(), "") + "\n" + java.util.Objects.toString(details.getBasePath(), ""))); + return (digest(new JSONArray().put(identity).put(java.util.Objects.toString(s3.getRegion(), "")).put(java.util.Objects.toString(s3.getPathStyleAccess(), "false")) + .put(java.util.Objects.toString(s3.getBasePath(), "")).put(java.util.Objects.toString(details.getBasePath(), "")) + .put(S3_KEY_LAYOUT).put((int) java.io.File.separatorChar).toString())); } @@ -221,7 +227,7 @@ public class PrintingDocumentStorage endpoint = uri.getScheme().toLowerCase(java.util.Locale.ROOT) + "://" + uri.getHost().toLowerCase(java.util.Locale.ROOT) + (uri.getPort() < 0 || ("https".equalsIgnoreCase(uri.getScheme()) && uri.getPort() == 443) || ("http".equalsIgnoreCase(uri.getScheme()) && uri.getPort() == 80) ? "" : ":" + uri.getPort()) + java.util.Objects.toString(uri.getPath(), "").replaceAll("/+$", ""); } - return (endpoint + "\n" + backend.getBucketName()); + return (new JSONArray().put(endpoint).put(java.util.Objects.toString(backend.getBucketName())).toString()); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index a8c958c..7da7652 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -759,7 +759,9 @@ public class PrintingService { requireDevice(connection, deviceId); row = deviceJob(connection, deviceId, jobId); - row.put("content_available", query(connection, "SELECT content_available FROM printing_job WHERE id=? AND tenant_id=? AND device_id=?", jobId, tenantId, deviceId).getFirst().getInt("content_available")); + JSONObject document = query(connection, "SELECT content_available, content_storage_table, content_reference, content_binding FROM printing_job WHERE id=? AND tenant_id=? AND device_id=?", jobId, tenantId, deviceId).getFirst(); + row.put("content_available", document.getInt("content_available")).put("content_storage_table", document.opt("content_storage_table")) + .put("content_reference", document.opt("content_reference")).put("content_binding", document.opt("content_binding")); List previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND device_id=? AND idempotency_key=?", tenantId, deviceId, retryKey); if(!previous.isEmpty()) { @@ -781,6 +783,10 @@ public class PrintingService { throw new PrintingException(409, "Inspect the printer and confirm the risk of duplicate output before reprinting"); } + if(!row.isNull("content_reference")) + { + verifyStoredReference(row); + } return (submitPrepared(new JSONObject().put("deviceId", deviceId).put("printerId", row.getString("printer_id")) .put("title", row.getString("title")).put("format", row.getString("format")) .put("options", new JSONObject(row.getString("options_json"))) diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingStorageBindingTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingStorageBindingTest.java new file mode 100644 index 0000000..5aed4ae --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingStorageBindingTest.java @@ -0,0 +1,430 @@ +/* + * 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.io.File; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Persisted S3 binding contracts use real metadata and the production guard. + ** Only object IO is recorded in memory; no endpoint or credentials are used. + *******************************************************************************/ +class PrintingStorageBindingTest +{ + private static final byte[] DOCUMENT = "^XA^FDLayout contract^FS^XZ".getBytes(StandardCharsets.US_ASCII); + private static final String LEGACY_BINDING = "120fbf86e305b3457c0ef9fd62829d974730506ceae267b2b96503475724935d"; + private static final String POSIX_BINDING = "dd336d2ddced413ee7a9a35ffa92e40fc20eb0ab2bbced01cba76081482738a1"; + private static final String WINDOWS_BINDING = "2981f67f05b02d4e32822a910e948d28ac565410780defe47f5ba1fd9bfd3667"; + + + + /*************************************************************************** + ** Golden hashes represent AWS/print-documents, us-east-1, false path-style, + ** empty base paths, and reviewed qqq-s3-filesystem-v1 separators 47 or 92. + ** Identity components use ordered JSON; the untagged hash predates this + ** layout contract and is never adopted. + ***************************************************************************/ + @Test + void rejectsUntaggedAndOtherPlatformBindings() + { + RecordingStorage storage = new RecordingStorage(); + for(String incompatible : incompatibleBindings()) + { + assertEquals(409, assertThrows(PrintingException.class, () -> storage.verifyBinding("printing_documents", incompatible)).getStatus()); + } + String current = File.separatorChar == '\\' ? WINDOWS_BINDING : POSIX_BINDING; + assertEquals(current, storage.binding("printing_documents")); + storage.verifyBinding("printing_documents", current); + assertTrue(storage.calls.isEmpty()); + } + + + + /*************************************************************************** + ** Valid multiline prefixes must not move delimiters between identity parts. + ** RC.20 resolves these pairs to different physical S3 keys. + ***************************************************************************/ + @Test + void rejectsMultilinePrefixRetargeting() + { + QInstance instance = s3Instance(); + S3BackendMetaData backend = (S3BackendMetaData) instance.getBackend("s3"); + S3TableBackendDetails details = (S3TableBackendDetails) instance.getTable("printing_documents").getBackendDetails(); + backend.setEndpoint("https://storage.example.invalid"); + backend.setPathStyleAccess(true); + backend.setBasePath("alpha\nbeta"); + details.setBasePath("gamma"); + PrintingDocumentStorage storage = new PrintingDocumentStorage(instance, "tenant-a"); + String persisted = storage.binding("printing_documents"); + storage.verifyBinding("printing_documents", persisted); + + backend.setBasePath("alpha"); + details.setBasePath("beta\ngamma"); + assertEquals(409, assertThrows(PrintingException.class, () -> storage.verifyBinding("printing_documents", persisted)).getStatus()); + } + + + + /*************************************************************************** + ** A wrong-layout source cannot be read, copied or acknowledged as deleted. + ** A rejected retry must not allocate or compensate another upload intent. + ***************************************************************************/ + @Test + void preservesHistoricalContentAndAccountingOnBindingMismatch() throws Exception + { + for(String incompatible : incompatibleBindings()) + { + Fixture fixture = fixture(); + String id = fixture.service.submit(request(fixture.device, "original")).getString("jobId"); + String reference = fixture.storage.prefix() + id + ".zpl"; + ageSuccess(fixture, id); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, id)); + execute(fixture.source, "UPDATE printing_job SET content_binding=? WHERE id=?", incompatible, id); + fixture.storage.calls.clear(); + + assertEquals(409, assertThrows(PrintingException.class, () -> fixture.service.content(fixture.device, id)).getStatus()); + assertEquals(409, assertThrows(PrintingException.class, () -> fixture.service.retry(fixture.device, id, retryRequest())).getStatus()); + assertEquals(409, assertThrows(PrintingException.class, fixture.service::runRetention).getStatus()); + assertTrue(fixture.storage.calls.isEmpty()); + assertArrayEquals(DOCUMENT, fixture.storage.objects.get(reference)); + assertEquals(1, fixture.storage.objects.size()); + JSONObject stored = row(fixture.source, "SELECT content_available,content_binding,content_reference FROM printing_job WHERE id=?", id); + assertEquals(1, stored.getInt("CONTENT_AVAILABLE")); + assertEquals(incompatible, stored.getString("CONTENT_BINDING")); + assertEquals(reference, stored.getString("CONTENT_REFERENCE")); + assertEquals(1, row(fixture.source, "SELECT COUNT(*) AS total FROM printing_job").getInt("TOTAL")); + assertEquals(0, row(fixture.source, "SELECT COUNT(*) AS total FROM printing_document_orphan").getInt("TOTAL")); + assertEquals(1, row(fixture.source, "SELECT COUNT(*) AS total FROM printing_admission_event").getInt("TOTAL")); + assertRetainedBytes(fixture, DOCUMENT.length); + assertEquals(0, fixture.service.getRetentionSettings().getInt("lastRunCount")); + assertFalse(fixture.service.getRetentionSettings().getString("lastRunError").isBlank()); + + execute(fixture.source, "UPDATE printing_job SET content_binding=? WHERE id=?", fixture.storage.binding("printing_documents"), id); + String retry = fixture.service.retry(fixture.device, id, retryRequest()).getString("jobId"); + execute(fixture.source, "UPDATE printing_job SET content_binding=? WHERE id=?", incompatible, id); + fixture.storage.calls.clear(); + assertEquals(retry, fixture.service.retry(fixture.device, id, retryRequest()).getString("jobId")); + assertTrue(fixture.storage.calls.isEmpty()); + execute(fixture.source, "UPDATE printing_job SET content_binding=? WHERE id=?", fixture.storage.binding("printing_documents"), id); + fixture.service.claim(fixture.device); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, retry)); + assertEquals(1, fixture.service.runRetention()); + assertFalse(fixture.storage.objects.containsKey(reference)); + assertRetainedBytes(fixture, DOCUMENT.length); + } + } + + + + /*************************************************************************** + ** Abandoned uploads retain both their exact object and quota on mismatch. + ***************************************************************************/ + @Test + void preservesOrphanReservationBeforeStorageIo() throws Exception + { + for(String incompatible : incompatibleBindings()) + { + Fixture fixture = fixture(); + fixture.storage.failUpload = true; + fixture.storage.failDelete = true; + assertThrows(SQLException.class, () -> fixture.service.submit(request(fixture.device, "orphan"))); + JSONObject orphan = row(fixture.source, "SELECT * FROM printing_document_orphan"); + String reference = orphan.getString("REFERENCE"); + execute(fixture.source, "UPDATE printing_document_orphan SET binding=?,created_at=?", incompatible, Instant.now().minusSeconds(7200).toString()); + fixture.storage.failUpload = false; + fixture.storage.failDelete = false; + fixture.storage.calls.clear(); + + assertEquals(409, assertThrows(PrintingException.class, fixture.service::runRetention).getStatus()); + assertTrue(fixture.storage.calls.isEmpty()); + JSONObject retained = row(fixture.source, "SELECT * FROM printing_document_orphan"); + assertEquals(orphan.getString("ID"), retained.getString("ID")); + assertEquals(reference, retained.getString("REFERENCE")); + assertEquals(incompatible, retained.getString("BINDING")); + assertEquals(orphan.getString("IDEMPOTENCY_KEY"), retained.getString("IDEMPOTENCY_KEY")); + assertArrayEquals(DOCUMENT, fixture.storage.objects.get(reference)); + assertRetainedBytes(fixture, DOCUMENT.length); + assertEquals(0, fixture.service.getRetentionSettings().getInt("lastRunCount")); + + execute(fixture.source, "UPDATE printing_document_orphan SET binding=?", fixture.storage.binding("printing_documents")); + assertEquals(1, fixture.service.runRetention()); + assertTrue(fixture.storage.objects.isEmpty()); + assertEquals(0, row(fixture.source, "SELECT COUNT(*) AS total FROM printing_document_orphan").getInt("TOTAL")); + assertRetainedBytes(fixture, 0); + } + } + + + + /*************************************************************************** + ** Old inline documents do not require an external binding after enabling S3. + ***************************************************************************/ + @Test + void keepsInlineDatabaseDocumentsReadableAndRetryable() throws Exception + { + Fixture fixture = fixture(); + PrintingService inline = new PrintingService(fixture.source, "tenant-a"); + String id = inline.submit(request(fixture.device, "inline")).getString("jobId"); + ageSuccess(fixture, id); + assertArrayEquals(DOCUMENT, inline.content(fixture.device, id)); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, id)); + assertTrue(fixture.storage.calls.isEmpty()); + JSONObject original = row(fixture.source, "SELECT content_reference,content_binding FROM printing_job WHERE id=?", id); + assertTrue(original.isNull("CONTENT_REFERENCE")); + assertTrue(original.isNull("CONTENT_BINDING")); + String retry = fixture.service.retry(fixture.device, id, retryRequest()).getString("jobId"); + fixture.service.claim(fixture.device); + assertArrayEquals(DOCUMENT, fixture.service.content(fixture.device, retry)); + fixture.storage.calls.clear(); + assertEquals(1, fixture.service.runRetention()); + assertTrue(fixture.storage.calls.isEmpty()); + assertEquals(1, fixture.storage.objects.size()); + assertRetainedBytes(fixture, DOCUMENT.length); + } + + + + /*************************************************************************** + ** Persisted foreign/experimental fixtures are never generated by the guard. + ***************************************************************************/ + private static List incompatibleBindings() + { + return (List.of(LEGACY_BINDING, File.separatorChar == '\\' ? POSIX_BINDING : WINDOWS_BINDING)); + } + + + + /*************************************************************************** + ** Exercise real service transactions with an actual S3 metadata fingerprint. + ***************************************************************************/ + private static Fixture fixture() throws Exception + { + DriverDataSource source = new DriverDataSource("jdbc:h2:mem:binding-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + PrintingService service = new PrintingService(source, "tenant-a"); + service.initialize(); + RecordingStorage storage = new RecordingStorage(); + service.configureStorage(storage, "printing_documents"); + String device = service.enroll(service.createEnrollment("warehouse", "station").put("deviceName", "station").put("agentVersion", "test")).getString("deviceId"); + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic").put("formats", new JSONArray(List.of("ZPL"))))); + service.enablePrinter(device, "queue", true); + return (new Fixture(source, service, storage, device)); + } + + + + /*************************************************************************** + ** Native metadata identity shared by the persisted golden fixtures. + ***************************************************************************/ + private static QInstance s3Instance() + { + QInstance instance = new QInstance(); + S3BackendMetaData backend = new S3BackendMetaData().withName("s3").withBucketName("print-documents").withRegion("us-east-1"); + backend.setPathStyleAccess(false); + instance.addBackend(backend); + instance.addTable(new QTableMetaData().withName("printing_documents").withBackendName("s3").withBackendDetails(new S3TableBackendDetails())); + return (instance); + } + + + + /*************************************************************************** + ** Synthetic requests never contact a printer. + ***************************************************************************/ + private static JSONObject request(String device, String key) + { + return (new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Synthetic").put("format", "ZPL") + .put("contentBase64", Base64.getEncoder().encodeToString(DOCUMENT)).put("idempotencyKey", key)); + } + + + + /*************************************************************************** + ** A fresh action identity must attempt the original document read. + ***************************************************************************/ + private static JSONObject retryRequest() + { + return (new JSONObject().put("requestId", "retry").put("confirmDuplicateRisk", true)); + } + + + + /*************************************************************************** + ** Synthetic completion makes one job eligible without a physical print. + ***************************************************************************/ + private static void ageSuccess(Fixture fixture, String id) throws Exception + { + execute(fixture.source, "UPDATE printing_job SET status='SPOOLED',completed_at=? WHERE id=?", Instant.now().minusSeconds(400 * 86400L).toString(), id); + } + + + + /*************************************************************************** + ** Both tenant and device accounting must preserve the held bytes. + ***************************************************************************/ + private static void assertRetainedBytes(Fixture fixture, long expected) throws Exception + { + for(String device : List.of("", fixture.device)) + { + assertEquals(expected, row(fixture.source, "SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant-a' AND device_id=?", device).getLong("RETAINED_BYTES")); + } + } + + + + /*************************************************************************** + ** Mutate only isolated persisted fixtures to represent migration boundaries. + ***************************************************************************/ + private static void execute(DriverDataSource source, String sql, Object... values) throws Exception + { + try(var connection = source.getConnection(); var statement = connection.prepareStatement(sql)) + { + for(int index = 0; index < values.length; index++) + { + statement.setObject(index + 1, values[index]); + } + statement.executeUpdate(); + } + } + + + + /*************************************************************************** + ** Inspect durable state independently of service response summaries. + ***************************************************************************/ + private static JSONObject row(DriverDataSource source, String sql, Object... values) throws Exception + { + try(var connection = source.getConnection(); var statement = connection.prepareStatement(sql)) + { + for(int index = 0; index < values.length; index++) + { + statement.setObject(index + 1, values[index]); + } + try(var rows = statement.executeQuery()) + { + assertTrue(rows.next()); + JSONObject result = new JSONObject(); + for(int index = 1; index <= rows.getMetaData().getColumnCount(); index++) + { + result.put(rows.getMetaData().getColumnLabel(index), rows.getObject(index) == null ? JSONObject.NULL : rows.getObject(index)); + } + return (result); + } + } + } + + + + /*************************************************************************** + ** Keep each tenant, database and object collection isolated. + ***************************************************************************/ + private record Fixture(DriverDataSource source, PrintingService service, RecordingStorage storage, String device) + { + } + + + + /*************************************************************************** + ** Preserve production binding checks; replace only external object IO. + ***************************************************************************/ + private static class RecordingStorage extends PrintingDocumentStorage + { + private final Map objects = new HashMap<>(); + private final List calls = new ArrayList<>(); + private boolean failUpload; + private boolean failDelete; + + + + /************************************************************************ + ** No memory-backend bypass or layout override is involved. + ************************************************************************/ + private RecordingStorage() + { + super(s3Instance(), "tenant-a"); + } + + + + /************************************************************************ + ** Record an upload, optionally losing its acknowledgement afterwards. + ************************************************************************/ + @Override + public void put(String table, String reference, String format, byte[] content) throws SQLException + { + calls.add("PUT " + reference); + objects.put(reference, content.clone()); + if(failUpload) + { + throw new SQLException("Synthetic upload acknowledgement failure"); + } + } + + + + /************************************************************************ + ** Unexpected reads are observable independently of returned bytes. + ************************************************************************/ + @Override + public byte[] get(String table, String reference) + { + calls.add("GET " + reference); + return (objects.get(reference).clone()); + } + + + + /************************************************************************ + ** A missing-object delete succeeds, matching the dangerous S3 behavior. + ************************************************************************/ + @Override + public void delete(String table, String reference) throws SQLException + { + calls.add("DELETE " + reference); + if(failDelete) + { + throw new SQLException("Synthetic deletion failure"); + } + objects.remove(reference); + } + } +} -- 2.54.0 From 09e099501b2ce689dc8d6eb67fe0be1b7d581302 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 03:40:35 -0500 Subject: [PATCH 15/35] fix(ci): apply approved H2 license metadata exception Reference merged standards EX-0013 while preserving the required reviewed-version inventory guard and every other license and vulnerability check. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- .github/workflows/pr-gates.yml | 5 +++++ docs/H2-LICENSE-REVIEW.md | 12 ++++++------ docs/TODO.md | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml index a052210..f652a19 100644 --- a/.github/workflows/pr-gates.yml +++ b/.github/workflows/pr-gates.yml @@ -15,6 +15,11 @@ concurrency: jobs: pr: uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 + with: + # EX-0013: reviewed unmodified H2 2.4.240; docs/H2-LICENSE-REVIEW.md. + # The required SCA inventory guard enforces the reviewed version because + # dependency-review's package license matcher ignores PURL versions. + allow-dependencies-licenses: pkg:maven/com.h2database/h2 permissions: contents: read # read the commits being merged pull-requests: read # read the pull request title and dependency diff diff --git a/docs/H2-LICENSE-REVIEW.md b/docs/H2-LICENSE-REVIEW.md index fa4e880..1d1453f 100644 --- a/docs/H2-LICENSE-REVIEW.md +++ b/docs/H2-LICENSE-REVIEW.md @@ -1,6 +1,6 @@ # H2 license review — 2026-10-30 -Status: evidence and proposed correction only. No license exception is enabled by this document. +Status: [EX-0013](https://github.com/KofTwentyTwo/standards/blob/7d758b13449057e442d658a089a3c55aa3a6d171/exceptions/register.md#ex-0013) was approved in [standards PR #54](https://github.com/KofTwentyTwo/standards/pull/54) on 2026-10-31. The caller enables only the recorded H2 package license-metadata exception; fresh hosted gates remain required. The dependency-review job for `fb99faf` reported `com.h2database:h2@2.4.240` as `LicenseRef-scancode-unknown AND MPL-2.0`. Its vulnerability check found no advisories at the configured low-or-higher threshold. The package upgrade fixes the confirmed embedded database compaction defect; reverting the driver would restore that defect. @@ -12,14 +12,14 @@ The local host uses the unmodified Maven Central H2 JAR under the MPL-2.0 option ## Applicable policy and scope -`K22-DEP-02` in the [dependency standard](https://github.com/KofTwentyTwo/standards/blob/main/standards/dependencies.md) permits MPL-2.0 when used unmodified. However, the pinned reusable [`pr.yml`](https://github.com/KofTwentyTwo/standards/blob/fdf5ed029603c938615a32b1beca3628b8787945/.github/workflows/pr.yml) requires every `allow-dependencies-licenses` entry to have a record in `exceptions/register.md`. The [exception policy](https://github.com/KofTwentyTwo/standards/blob/main/policies/exceptions.md) requires that record to pass normal review. No existing record was found covering this repository and H2. +`K22-DEP-02` in the [dependency standard](https://github.com/KofTwentyTwo/standards/blob/main/standards/dependencies.md) permits MPL-2.0 when used unmodified. The pinned reusable [`pr.yml`](https://github.com/KofTwentyTwo/standards/blob/fdf5ed029603c938615a32b1beca3628b8787945/.github/workflows/pr.yml) requires every `allow-dependencies-licenses` entry to have a record in `exceptions/register.md`. EX-0013 passed the [exception policy](https://github.com/KofTwentyTwo/standards/blob/main/policies/exceptions.md)'s normal gates and was merged at `7d758b13449057e442d658a089a3c55aa3a6d171`. -The substantive license is permitted; correcting the scanner result still needs the recorded workflow exception. This review does not authorize changing shared policy, skipping a required check or marking the license gate successful without that correction. +EX-0013 covers only this repository and the unmodified H2 2.4.240 binary identified above. It expires on 2027-04-30 or an H2 version/license change or correction of GitHub's classification, whichever occurs first. It does not permit changing shared license policy, skipping vulnerability review or bypassing a required check. -## Narrow proposed correction +## Package-specific correction -After the applicable exception is recorded, use the reusable workflow's package-specific `allow-dependencies-licenses` input for `pkg:maven/com.h2database/h2`. Preserve `license-check: true`, `vulnerability-check: true`, the low advisory threshold and every other package's existing license policy. Do not globally allow unknown licenses or broaden the MPL allowlist. +The caller uses only the reusable workflow's package-specific `allow-dependencies-licenses` input for `pkg:maven/com.h2database/h2`. It preserves `license-check: true`, `vulnerability-check: true`, the low advisory threshold and every other package's existing license policy. Unknown licenses are not globally allowed, and the MPL allowlist is not broadened. The pinned action's [license matcher](https://github.com/actions/dependency-review-action/blob/a1d282b36b6f3519aa1f3fc636f609c47dddb294/src/licenses.ts) calls [`purlsMatch`](https://github.com/actions/dependency-review-action/blob/a1d282b36b6f3519aa1f3fc636f609c47dddb294/src/purl.ts), which intentionally ignores package versions. Adding `@2.4.240` to the input does **not** enforce a version restriction. The required security inventory gate now rejects absent H2, any version other than the reviewed `2.4.240`, and multiple H2 versions. It compares the selected Maven graph with both scanner inventories. A later upgrade must update both the pin and this reviewed-version guard after a renewed license assessment. Cite the approved exception ID beside the workflow input and give the record a review/expiry date. -This document leaves that configuration unapplied pending the required record. The shared standards repository was not modified. +The workflow cites EX-0013 beside the input. Any renewal or change requires normal review of the central record and this caller; the package exclusion must not outlive the record's scope or expiry. This correction does not approve a release or live server cutover. diff --git a/docs/TODO.md b/docs/TODO.md index 5b5b1db..a9a915b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,8 +5,8 @@ - [ ] Require fresh hosted gates for the S3 correction; portable upstream keys, historical migration and production IAM remain separate qualification work. - [x] Verify the H2 2.4.240 candidate at `fb99faf`: 67 Java tests, zero Checkstyle violations, hosted build/CodeQL/security pass, and all 204 selected runtime dependencies verified. - [x] Qualify the exact CI artifact on an isolated closed-data copy: native frontend/authentication/query/denial/logout/retention checks, actual scheduled zero-removal tick, forced termination, read-only inspection before initialization, and successful cold restart; all 11 tables and retained state preserved. -- [x] Add the exact reviewed H2 version guard and license attribution/evidence; 17 Python guard tests and workflow lint pass. No active package license exception is enabled. -- [ ] Obtain approval for the prepared central H2 metadata exception record, then apply only its package-scoped workflow input and require fresh complete hosted gates. +- [x] Add the exact reviewed H2 version guard and license attribution/evidence; 17 Python guard tests and workflow lint pass. +- [x] Approve central H2 metadata record EX-0013 through standards PR #54 and apply only its package-scoped workflow input; fresh complete hosted gates remain required before release or cutover. - [x] Qualify registered JVM-hook shutdown on exact `00dd2dd`: native acceptance, Quartz/HTTP shutdown, exit 0 and independent read-only cold inspection before initialization pass. Windows reboot/service-manager behavior remains separate. - [ ] Perform authorized live paired-host cutover from a closed backup after all gates pass; preserve pairing and verify the real native records and connector heartbeat. -- 2.54.0 From 31d1bc1cabefea66f29c514ebe4bcb1ac0f8ef90 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 04:09:57 -0500 Subject: [PATCH 16/35] docs(printing): record verified local host deployment Record the approved H2 exception, passing complete CI and exact artifact comparison, verified recovery/cold-copy checks, and the state-preserving local native QQQ cutover. Keep remote S3 and physical-output limits explicit. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 2 +- docs/SESSION-STATE.md | 12 ++++++++---- docs/TODO.md | 8 ++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f2c30..08eda93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - Pin H2 2.4.240 to fix close-time compaction data loss; verify file-backed schema/settings across cold reopens and document closed-backup upgrade qualification. - Verify the exact frontend-inclusive runtime inventory in Maven, Trivy and OSV; fail on malformed or incomplete scanner evidence. - Include repository LICENSE and NOTICE under META-INF using standard Maven resources; preserve font license, provenance and diagnostic assets. -- Full local verification passed 72 tests with zero failures/errors/skips and Checkstyle violations, including incompatible S3 bindings, multiline prefix retargeting, retry accounting and inline document compatibility. Actual cross-platform refusal preserves objects and accounting with zero SDK requests. The H2-fixed artifact passed hosted build/security/CodeQL and isolated native cold-restart checks. Fresh S3 guard CI remains pending; this entry does not claim release or live storage migration. +- Full local and fresh hosted verification passed 72 tests with zero failures/errors/skips and Checkstyle violations, including incompatible S3 bindings, multiline prefix retargeting, retry accounting and inline document compatibility. Actual cross-platform refusal preserves objects and accounting with zero SDK requests. Required build/security/CodeQL/dependency gates pass with the approved scoped H2 record. The exact CI artifact passed isolated graceful/cold-restart checks and local paired-host cutover with native authentication, preserved records, automatic connector reconnection and 365-day retention. No live S3 migration or remote deployment is claimed. ## 0.1.3-rc.1 — 2026-10-30 diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 0a2fea7..6993a0c 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,10 +1,14 @@ -# Session state — 2026-10-30 +# Session state — 2026-10-31 ## Audit candidate 0.1.4-rc.1 +The authorized local paired-host cutover is complete. The exact approved CI package from `09e0995` runs the native QQQ frontend and device API on the existing loopback address. A native backup was independently restored and fully compared before the old H2 host stopped; its closed database was also preserved and checked. Existing pairing, one device, two ready printers, seven jobs and 21 events were retained. The Windows service reconnected automatically. Native login, bounded real-record queries, anonymous/cross-origin/authentication-table denials, logout and the enabled **365-day** retention policy passed. No new print was requested. This local host uses database delivery storage; real dedicated S3 provisioning and remote deployment are deferred by the user's local-first instruction. + +All required hosted checks pass at `09e0995`, including the approved narrow H2 dependency input. Exact CI JAR SHA256 is `db84552b84d0a2fa62766fe8fcf4ba7414ae57f798629f281abba951d0889422`; all 47 archive-entry payloads match the already qualified `45261d5` package, including 38 implementation classes and all runtime resources. All 204 dependency coordinates match. Fresh verification records 72 passing tests, zero Checkstyle violations, complete scanner evidence and zero Trivy HIGH/CRITICAL findings. Tagged publication and the Windows 0.1.9 installed qualification are still pending. + The latest S3 safeguard passes **72 Java tests, zero failures/errors/skips and zero Checkstyle violations**, with independent source review complete. It binds documents to the reviewed physical key layout and native separator using unambiguous ordered JSON. Historical reads, new retries and cleanup reject incompatible bindings before object IO; inline database content and prior retry receipts remain compatible. Old experimental S3 bindings are not silently adopted. -Actual isolated Windows-to-Linux qualification passed on local candidate JAR SHA256 `9163b5fb89c981b70d2bd416ca3920a17bcbda2fe61eaa7190f050cc27759495` with all 204 runtime libraries verified on both hosts. Read and retry returned 409; cleanup recorded an error with zero removals. A positive-control-checked native SDK observer recorded zero transmissions for those refusals. Content availability, tenant/device retained bytes, admission/orphan/job counts and the exact original object stayed unchanged. The earlier candidate reproduced false expiration of a Windows-key object from Linux; this correction prevents that loss of availability. Portable upstream keys, explicit historical migration and production IAM remain separate work. Fresh hosted checks for this correction are pending. +Actual isolated Windows-to-Linux qualification passed on local candidate JAR SHA256 `9163b5fb89c981b70d2bd416ca3920a17bcbda2fe61eaa7190f050cc27759495` with all 204 runtime libraries verified on both hosts. Read and retry returned 409; cleanup recorded an error with zero removals. A positive-control-checked native SDK observer recorded zero transmissions for those refusals. Content availability, tenant/device retained bytes, admission/orphan/job counts and the exact original object stayed unchanged. The earlier candidate reproduced false expiration of a Windows-key object from Linux; this correction prevents that loss of availability. Portable upstream keys, explicit historical migration and production IAM remain separate work. Fresh hosted checks for this correction and the scoped license input now pass. Packaged candidate `00dd2dd` passed hosted [build](https://github.com/QRun-IO/qbit-printing/actions/runs/41162272066), [CodeQL](https://github.com/QRun-IO/qbit-printing/actions/runs/41162272168) and [security](https://github.com/QRun-IO/qbit-printing/actions/runs/41162272058). The isolated frontend-profile gate passes **67 Java tests, zero failures/errors/skips and zero Checkstyle violations**. All **204** runtime dependencies match the selected Maven graph and scanner inventories, with exactly one H2 2.4.240 JAR. Refreshed Trivy reports zero HIGH/CRITICAL findings; OSV reports zero malicious findings. The exact CI package includes matching LICENSE/NOTICE resources; all 38 implementation classes and existing runtime assets match the earlier `fb99faf` candidate. @@ -14,9 +18,9 @@ Graceful shutdown is now separately verified on the exact packaged `00dd2dd` CI The prepared-copy failure was the upstream H2 2.3.232 close-time compaction defect (#4247/#4249). Replacing only H2 with **2.4.240** preserves the migrated schema using normal compaction. No warm-up, retry, delay or disabled-compaction production workaround is present. The public fresh-file cold-reopen test checks durability; it does not claim to reproduce every affected old-driver database. See [local runtime and backup guidance](LOCAL-HOST.md). -The [PR gate](https://github.com/QRun-IO/qbit-printing/actions/runs/41162272419) remains blocked **only by H2 license metadata classification**; dependency vulnerability review passed. Tagged H2 terms are unchanged and permit the unmodified MPL-2.0 option. The reviewed inventory guard now requires exactly H2 2.4.240; **17 Python guard tests** pass, with workflow lint and independent review complete. See [license evidence](H2-LICENSE-REVIEW.md). The guard and packaging follow-ups passed hosted build/security/CodeQL checks. The proposed central exception record and package-only workflow input remain unapplied pending approval; no active workflow exception, global license allowance or security bypass is enabled. Applying that input requires fresh complete hosted checks before release. +The earlier H2 license-metadata gate is resolved. [Standards PR #54](https://github.com/KofTwentyTwo/standards/pull/54) merged EX-0013 through normal required checks following explicit user approval. Its separately approved CodeQL repair retains Actions and JavaScript/TypeScript under the advanced security-extended workflow. Printing applies only the approved package input; its fresh [PR gates](https://github.com/QRun-IO/qbit-printing/actions/runs/41185576692) pass. The inventory guard still requires exactly unmodified H2 2.4.240, with 17 passing guard tests; all other license, vulnerability and branch protections remain enforced. See [license evidence and expiry](H2-LICENSE-REVIEW.md). -The audit also fixes native-process replay, tenant-scoped facility selectors, duplicate-output risk preservation, atomic queue/retained-byte/rate admission and bounded printer history. Dedicated QQQ document storage and scheduled retention protect active, failed and uncertain work. Stop older writers before schema/accounting migration and preserve a closed backup. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). Real PostgreSQL/S3, remote authentication, multi-node operation and sustained physical throughput remain unqualified. Live cutover, audit release and shared-standards publication have not occurred. +The audit also fixes native-process replay, tenant-scoped facility selectors, duplicate-output risk preservation, atomic queue/retained-byte/rate admission and bounded printer history. Dedicated QQQ document storage and scheduled retention protect active, failed and uncertain work. Stop older writers before schema/accounting migration and preserve verified recovery copies. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). Real PostgreSQL/S3, remote authentication, multi-node operation and sustained physical throughput remain unqualified. Local cutover and shared-standards publication are complete; tagged audit publication remains pending. Record Files' **21:54 America/Chicago checkpoint on 2026-10-30** is acknowledged. Its latest typed reader and configured reporting authority are candidate source with focused tests; the verified installed artifact is still pending, and the earlier snapshot is not the final contract. The teams agree on the consumer-owned observation boundary and are resolving identifier bounds and optional spooler-ID canonicalization. Printing still needs its durable accepted source/initiator/job/destination binding, tenant-scoped committed event receipt and legitimate current native reporting identity before an adapter can be enabled. No adapter is enabled, no browser session or fabricated authority is retained, and spool completion is not physical-output confirmation. Standalone byte submission remains independent. diff --git a/docs/TODO.md b/docs/TODO.md index a9a915b..35cfdaf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,13 +2,13 @@ - [x] Correct S3 layout and multiline-prefix binding ambiguity; full isolated gate passes 72 tests with zero Checkstyle violations and independent review complete. - [x] Reproduce the actual Windows-to-Linux false-expiration defect, then qualify the corrected runtime: zero SDK requests for refused reads/retries/cleanup and preserved objects, availability and accounting. -- [ ] Require fresh hosted gates for the S3 correction; portable upstream keys, historical migration and production IAM remain separate qualification work. +- [x] Pass fresh hosted gates for the S3 correction and approved scoped H2 input; verify exact CI artifacts, all 47 archive payloads and 204 runtime coordinates. Portable upstream keys, historical migration and production IAM remain separate qualification work. - [x] Verify the H2 2.4.240 candidate at `fb99faf`: 67 Java tests, zero Checkstyle violations, hosted build/CodeQL/security pass, and all 204 selected runtime dependencies verified. - [x] Qualify the exact CI artifact on an isolated closed-data copy: native frontend/authentication/query/denial/logout/retention checks, actual scheduled zero-removal tick, forced termination, read-only inspection before initialization, and successful cold restart; all 11 tables and retained state preserved. - [x] Add the exact reviewed H2 version guard and license attribution/evidence; 17 Python guard tests and workflow lint pass. -- [x] Approve central H2 metadata record EX-0013 through standards PR #54 and apply only its package-scoped workflow input; fresh complete hosted gates remain required before release or cutover. +- [x] Approve central H2 metadata record EX-0013 through standards PR #54, apply only its package-scoped workflow input and pass every fresh required check. - [x] Qualify registered JVM-hook shutdown on exact `00dd2dd`: native acceptance, Quartz/HTTP shutdown, exit 0 and independent read-only cold inspection before initialization pass. Windows reboot/service-manager behavior remains separate. -- [ ] Perform authorized live paired-host cutover from a closed backup after all gates pass; preserve pairing and verify the real native records and connector heartbeat. +- [x] Perform authorized live paired-host cutover after verified native recovery, closed backup and fresh required gates; preserve pairing, 1 device, 2 ready printers, 7 jobs and 21 events. Native authentication/query/denial/logout, automatic connector reconnection and 365-day retention checks pass. - [x] Finish audit regressions for native-process replay, strict options/inventory validation, retained duplicate risk and event field bounds; full gate passes 53 tests. - [x] Implement admin-configurable document retention (one-year default), bounded scheduled cleanup and existing QQQ object-storage integration, retaining history/receipts and protecting active or uncertain output. @@ -17,7 +17,7 @@ - [x] Historical audit PR #10 checkpoint aeac1a6 passed hosted checks; current H2/native qualification and remaining license gate are recorded above. - [x] Complete the independent baseline scan and current-source reviews of all five findings and remediations. - [x] Add authenticated native QQQ local host sharing paired data with device routes and the existing scheduler; 65 tests/zero Checkstyle pass and independent source review found no blocker. -- [ ] Complete live authenticated-host deployment after the isolated native acceptance above; keep remote/S3 qualification separate. +- [x] Complete live authenticated local-host deployment after isolated native and graceful/cold-copy acceptance; remote/S3 qualification remains separate under the user's local-first instruction. - [x] Versioned shared contract and HTTP happy-path test. - [x] Durable enrollment, device-scoped authentication, inventory and printer approval. -- 2.54.0 From c34883da07ca3d9bb8f2a08fa4260364efc29173 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 04:51:49 -0500 Subject: [PATCH 17/35] fix: use official Query mascot on diagnostic prints Preserve the official artwork aspect ratio in every PDF and thermal profile. Record provenance and the completed local test and installed-client acceptance. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 1 + NOTICE | 3 +- README.md | 2 +- docs/SESSION-STATE.md | 6 ++ docs/TODO.md | 2 + .../qbits/printing/PrintingTestDocuments.java | 12 +-- .../kingsrook/qbits/printing/images/README.md | 29 +++++++ .../qbits/printing/images/qqq-logo.png | Bin 43481 -> 0 bytes .../qbits/printing/images/query-mascot.png | Bin 0 -> 47207 bytes .../printing/PrintingTestDocumentsTest.java | 78 ++++++++++++++++++ 10 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/com/kingsrook/qbits/printing/images/README.md delete mode 100644 src/main/resources/com/kingsrook/qbits/printing/images/qqq-logo.png create mode 100644 src/main/resources/com/kingsrook/qbits/printing/images/query-mascot.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 08eda93..1eb60d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.1.4-rc.1 — unreleased +- Use the official Query dog mascot on every built-in PDF and ZPL diagnostic profile, preserving the original artwork and aspect ratio. The full local gate passes 73 tests with zero Checkstyle violations; PDF previews and reconstructed thermal graphics pass visual review. New physical output remains to be confirmed after deployment. - Add an authenticated loopback QQQ host that shares durable paired devices/jobs with native screens and retention scheduling, preserving the separate synthetic UI demo. - Make native enrollment and test-print execution replay-safe and retain known duplicate-output risk after later failures. - Strictly validate print options, inventory formats/status and event bounds before persistence; recheck current queue format support at claim time. diff --git a/NOTICE b/NOTICE index 4931277..c446af9 100644 --- a/NOTICE +++ b/NOTICE @@ -9,7 +9,8 @@ Built-in diagnostic documents bundle JetBrains Mono Bold v2.304. Copyright 2020 The JetBrains Mono Project Authors. The font is licensed under the SIL Open Font License, Version 1.1; see src/main/resources/com/kingsrook/qbits/printing/fonts/OFL.txt. -The QQQ logo is supplied by the QQQ framework, Copyright Kingsrook, LLC. +Query, the QQQ and QRun.IO dog mascot, is supplied by QRun.IO. +Artwork provenance: src/main/resources/com/kingsrook/qbits/printing/images/README.md. The local host runtime uses the unmodified H2 Database Engine, Copyright 2004-2025 H2 Group. H2 is dual licensed under MPL 2.0 or EPL 1.0; this diff --git a/README.md b/README.md index 5afd83c..848aaa1 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ QQQ's **Print jobs** table and records expose **Print again** and **Cancel queue ## Warehouse diagnostic pages -All built-in printer test actions share `PrintingTestDocuments`: QQQ branding, the existing QQQ logo, large warehouse-style identifiers and embedded JetBrains Mono Bold with a dotted zero and distinct 1/I/l. The font is included under SIL OFL 1.1; its license and provenance ship in the JAR. PDF embeds a font subset. ZPL renders the same glyphs into document-local, row-aligned graphics below Zebra's 99,999-byte per-field limit; it does not download or save a printer font. +All built-in printer test actions share `PrintingTestDocuments`: QQQ branding, the official [Query dog mascot](https://qrun.io/query), large warehouse-style identifiers and embedded JetBrains Mono Bold with a dotted zero and distinct 1/I/l. Query's original artwork and aspect ratio are preserved in PDF and thermal output; [asset provenance](src/main/resources/com/kingsrook/qbits/printing/images/README.md) ships with the resource. The font is included under SIL OFL 1.1; its license and provenance ship in the JAR. PDF embeds a font subset. ZPL renders the same glyphs into document-local, row-aligned graphics below Zebra's 99,999-byte per-field limit; it does not download or save a printer font. Native record and selected-table actions expose a possible-value stock/resolution selector. Letter and A4 are PDF-only. Label profiles are 4 × 6 inches at 203, 300 or 600 dpi; PDF label output is always 101.6 × 152.4 mm. Select matching stock and printer resolution. Bulk actions apply the chosen profile to every selected destination, so group compatible printers. The document does not reconfigure Windows stock, calibrate the printer or save device configuration; ZPL width/length are transient format settings. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 6993a0c..40e4936 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,11 @@ # Session state — 2026-10-31 +## Query mascot correction + +Built-in diagnostic pages and labels now use the official Query dog artwork from [qrun.io/query](https://qrun.io/query), with the exact original asset and provenance recorded alongside the resource. White padding preserves its aspect ratio within the existing layout; warehouse glyphs, media profiles, barcode and print routing are unchanged. All five PDF profiles and three thermal resolutions are covered by the new regression. The isolated full build passed **73 tests with no failures/errors/skips and zero Checkstyle violations**. Generated PDF previews and actual ZPL graphic-field reconstruction were reviewed. The tested resource is packaged and the previous logo is absent. + +This server-only correction is not yet deployed or released. The Windows client already requests these server-generated documents; no client icon change is needed. The installed corrective Windows 0.1.9 candidate is connected and passed all 16 UI checks with credentials/history preserved. Publication and fresh branded physical-output confirmation remain pending. + ## Audit candidate 0.1.4-rc.1 The authorized local paired-host cutover is complete. The exact approved CI package from `09e0995` runs the native QQQ frontend and device API on the existing loopback address. A native backup was independently restored and fully compared before the old H2 host stopped; its closed database was also preserved and checked. Existing pairing, one device, two ready printers, seven jobs and 21 events were retained. The Windows service reconnected automatically. Native login, bounded real-record queries, anonymous/cross-origin/authentication-table denials, logout and the enabled **365-day** retention policy passed. No new print was requested. This local host uses database delivery storage; real dedicated S3 provisioning and remote deployment are deferred by the user's local-first instruction. diff --git a/docs/TODO.md b/docs/TODO.md index 35cfdaf..b5dc2df 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,7 @@ # Follow-up +- [x] Replace the test-document logo with the exact official Query dog asset; preserve aspect ratio and verify all PDF/thermal profiles with 73 passing tests, zero Checkstyle violations and reviewed previews. +- [ ] Deploy and publish the Query correction after fresh required gates, then obtain physical-output confirmation for the updated diagnostic documents. - [x] Correct S3 layout and multiline-prefix binding ambiguity; full isolated gate passes 72 tests with zero Checkstyle violations and independent review complete. - [x] Reproduce the actual Windows-to-Linux false-expiration defect, then qualify the corrected runtime: zero SDK requests for refused reads/retries/cleanup and preserved objects, availability and accounting. - [x] Pass fresh hosted gates for the S3 correction and approved scoped H2 input; verify exact CI artifacts, all 47 archive payloads and 204 runtime coordinates. Portable upstream keys, historical migration and production IAM remain separate qualification work. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTestDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTestDocuments.java index 2c8f218..e915ac3 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingTestDocuments.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTestDocuments.java @@ -53,7 +53,7 @@ import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; public final class PrintingTestDocuments { static final String FONT_RESOURCE = "/com/kingsrook/qbits/printing/fonts/JetBrainsMono-Bold.ttf"; - private static final String LOGO_RESOURCE = "/com/kingsrook/qbits/printing/images/qqq-logo.png"; + private static final String LOGO_RESOURCE = "/com/kingsrook/qbits/printing/images/query-mascot.png"; static final String BARCODE_VALUE = "QQQ-TEST-123456"; @@ -267,7 +267,8 @@ public final class PrintingTestDocuments /*************************************************************************** - ** Composite transparent source artwork on white before monochrome printing. + ** Preserve Query's official artwork and aspect ratio inside square layout + ** boxes, compositing transparency on white before monochrome printing. ***************************************************************************/ private static BufferedImage logo() throws IOException { @@ -275,16 +276,17 @@ public final class PrintingTestDocuments { if(stream == null) { - throw new IOException("Bundled QQQ logo is missing"); + throw new IOException("Bundled Query mascot is missing"); } BufferedImage source = ImageIO.read(stream); - BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_BYTE_BINARY); + int size = Math.max(source.getWidth(), source.getHeight()); + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_BYTE_BINARY); Graphics2D graphics = image.createGraphics(); try { graphics.setColor(Color.WHITE); graphics.fillRect(0, 0, image.getWidth(), image.getHeight()); - graphics.drawImage(source, 0, 0, null); + graphics.drawImage(source, (size - source.getWidth()) / 2, (size - source.getHeight()) / 2, null); } finally { diff --git a/src/main/resources/com/kingsrook/qbits/printing/images/README.md b/src/main/resources/com/kingsrook/qbits/printing/images/README.md new file mode 100644 index 0000000..dd2cd63 --- /dev/null +++ b/src/main/resources/com/kingsrook/qbits/printing/images/README.md @@ -0,0 +1,29 @@ +# Query mascot used in built-in print diagnostics + +`query-mascot.png` is the official Query dog artwork, copied byte for byte from +QRun.IO's marketing source. It replaces the former interlocking-Q crown mark in +built-in test documents. Query is identified by the official +[mascot page](https://qrun.io/query), linked from +[dogfooding guidance](https://qrun.io/dogfooding). + +- Source repository: [QRun-IO/marketing-website](https://github.com/QRun-IO/marketing-website). +- Asset source revision: `5ff7c1564f4fe28a7ce75436cf7c4a6eeaa4bf3f`. +- Source file: [`assets/Puppy Mark/exports/PNG/qqq-puppy@4x.png`](https://github.com/QRun-IO/marketing-website/blob/5ff7c1564f4fe28a7ce75436cf7c4a6eeaa4bf3f/assets/Puppy%20Mark/exports/PNG/qqq-puppy%404x.png). +- Dimensions: 1326 x 1394 pixels, transparent PNG. +- SHA256: `385a053e15e0a9fc6f8a965257e3917db827594ab46ecf8cdddb847770a25dbb`. +- Verified2026-10-31 against the official page: the two SVG paths in its Query + illustration match `public/images/qqq-puppy.svg` in the same repository. + +The bundled PNG is unmodified. At document generation, transparent pixels are +composited onto white and the image is centered in a square white canvas. This +preserves the original aspect ratio inside existing PDF and thermal-label layout +boxes. The resulting document artwork is monochrome for warehouse printing; +there is no AI redraw, added outline, rotation or printer-persistent download. +Existing diagnostic text, font, media profiles, barcode data and quiet zones are +unchanged. The one shared generator supplies all native QQQ and device test-print +entrypoints, including Windows companion requests. + +This resource brands QQQ's built-in diagnostic documents only. It does not +replace a customer's submitted document artwork, Windows application/tray icons +or future customer-specific branding configuration. Query remains QRun.IO/QQQ +branding; this provenance is not a separate trademark permission grant. diff --git a/src/main/resources/com/kingsrook/qbits/printing/images/qqq-logo.png b/src/main/resources/com/kingsrook/qbits/printing/images/qqq-logo.png deleted file mode 100644 index da00c9174e0098d85883e59f75fce8cb68f584a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43481 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 HdGr4Ok6+YI diff --git a/src/main/resources/com/kingsrook/qbits/printing/images/query-mascot.png b/src/main/resources/com/kingsrook/qbits/printing/images/query-mascot.png new file mode 100644 index 0000000000000000000000000000000000000000..688b460ccf335d7c1d0011875231fbf27b042dfb GIT binary patch literal 47207 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 0) + { + double scale = profile.getDpi() / 72.0; + BufferedImage raster = PrintingTestDocuments.labelImage(profile.getDpi()); + BufferedImage mascot = raster.getSubimage((int) Math.floor(210 * scale), (int) Math.floor(30 * scale), (int) Math.ceil(54 * scale), (int) Math.ceil(54 * scale)); + assertEquals(expectedRatio, visibleAspectRatio(mascot), 0.03); + } + } + } + + + + /*************************************************************************** + ** Compare the actual visible artwork, ignoring transparent/white padding. + ***************************************************************************/ + private static double visibleAspectRatio(BufferedImage image) + { + int left = image.getWidth(); + int right = -1; + int top = image.getHeight(); + int bottom = -1; + for(int y = 0; y < image.getHeight(); y++) + { + for(int x = 0; x < image.getWidth(); x++) + { + int pixel = image.getRGB(x, y); + if((pixel >>> 24) >= 128 && (pixel & 0xffffff) < 0x808080) + { + left = Math.min(left, x); + right = Math.max(right, x); + top = Math.min(top, y); + bottom = Math.max(bottom, y); + } + } + } + assertTrue(right > left && bottom > top); + return ((right - left + 1.0) / (bottom - top + 1.0)); + } + + + /*************************************************************************** ** Every PDF profile has searchable diagnostic text and an embedded font. ***************************************************************************/ -- 2.54.0 From c3e37a548ef779d3f835c007597441aa221e457f Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 05:13:59 -0500 Subject: [PATCH 18/35] docs: update published printing release guidance Align installation examples and acceptance notes with the verified release. Keep physical-output and production deployment qualification limits explicit. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 7 ++++--- README.md | 10 ++++++---- docs/SESSION-STATE.md | 12 ++++++------ docs/TODO.md | 12 +++++++----- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb60d6..5bd7dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog -## 0.1.4-rc.1 — unreleased +## 0.1.4-rc.1 — 2026-10-31 -- Use the official Query dog mascot on every built-in PDF and ZPL diagnostic profile, preserving the original artwork and aspect ratio. The full local gate passes 73 tests with zero Checkstyle violations; PDF previews and reconstructed thermal graphics pass visual review. New physical output remains to be confirmed after deployment. +- Use the official Query dog mascot on every built-in PDF and ZPL diagnostic profile, preserving the original artwork and aspect ratio. Full local and hosted gates pass 73 tests with zero Checkstyle violations; PDF previews and reconstructed thermal graphics pass visual review. Local native deployment is accepted; physical output from the updated Query diagnostics remains unconfirmed. - Add an authenticated loopback QQQ host that shares durable paired devices/jobs with native screens and retention scheduling, preserving the separate synthetic UI demo. - Make native enrollment and test-print execution replay-safe and retain known duplicate-output risk after later failures. - Strictly validate print options, inventory formats/status and event bounds before persistence; recheck current queue format support at claim time. @@ -16,7 +16,8 @@ - Pin H2 2.4.240 to fix close-time compaction data loss; verify file-backed schema/settings across cold reopens and document closed-backup upgrade qualification. - Verify the exact frontend-inclusive runtime inventory in Maven, Trivy and OSV; fail on malformed or incomplete scanner evidence. - Include repository LICENSE and NOTICE under META-INF using standard Maven resources; preserve font license, provenance and diagnostic assets. -- Full local and fresh hosted verification passed 72 tests with zero failures/errors/skips and Checkstyle violations, including incompatible S3 bindings, multiline prefix retargeting, retry accounting and inline document compatibility. Actual cross-platform refusal preserves objects and accounting with zero SDK requests. Required build/security/CodeQL/dependency gates pass with the approved scoped H2 record. The exact CI artifact passed isolated graceful/cold-restart checks and local paired-host cutover with native authentication, preserved records, automatic connector reconnection and 365-day retention. No live S3 migration or remote deployment is claimed. +- The S3 safeguard checkpoint passed 72 tests with zero failures/errors/skips and Checkstyle violations, including incompatible S3 bindings, multiline prefix retargeting, retry accounting and inline document compatibility. Actual cross-platform refusal preserves objects and accounting with zero SDK requests. Required build/security/CodeQL/dependency gates pass with the approved scoped H2 record. Qualified artifacts passed isolated graceful/cold-restart checks and local paired-host acceptance with native authentication, preserved records, automatic connector reconnection and 365-day retention. No live S3 migration or remote deployment is claimed. +- Publish the JAR, POM and SHA256SUMS after [release run 41190650664](https://github.com/QRun-IO/qbit-printing/actions/runs/41190650664) passed all gates. Verify checksums and GitHub attestations for all three assets against the release tag and source; all packaged entry contents match the reviewed Query CI artifact. ## 0.1.3-rc.1 — 2026-10-30 diff --git a/README.md b/README.md index 848aaa1..716b77b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector initiates outbound HTTPS polling; the cloud needs no inbound route into the facility. -**Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. Audit source targets **0.1.4-rc.1**; the published baseline remains **0.1.3-rc.1** until the audit and release gates complete. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR. +**Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. The current published release is [**0.1.4-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.4-rc.1). The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with verified build attestations for all three assets. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR. 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. @@ -59,12 +59,14 @@ Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator The protocol-only `PrintingDemo` and synthetic, mock-authenticated `PrintingUiDemo` remain separate. Do not point the synthetic demo at real paired printer data. The local host retains database document storage until an actual dedicated S3 binding is configured in a deployment-owned application; it does not claim a remote cloud deployment. +Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The current release passes 73 tests and zero Checkstyle violations. Production S3/IAM, remote deployment and physical output from the new Query diagnostic artwork remain separate qualification work. + ## Add to a QQQ application For a source checkout, `mvnw.cmd install` (or `bash ./mvnw install`) installs the verified library into your local Maven repository. For a GitHub release, download the JAR, matching POM and `SHA256SUMS`, verify the checksums, then install those artifacts locally or through your organization's artifact repository. Example using the release files in the current directory: ```powershell -.\mvnw.cmd install:install-file '-Dfile=qbit-printing-0.1.3-rc.1.jar' '-DpomFile=qbit-printing-0.1.3-rc.1.pom' +.\mvnw.cmd install:install-file '-Dfile=qbit-printing-0.1.4-rc.1.jar' '-DpomFile=qbit-printing-0.1.4-rc.1.pom' ``` After the artifact is available in your configured repository, add: @@ -73,7 +75,7 @@ After the artifact is available in your configured repository, add: com.kingsrook.qbits qbit-printing - 0.1.3-rc.1 + 0.1.4-rc.1 ``` @@ -98,7 +100,7 @@ server.withAdditionalRouteProviders(List.of(new PrintingRoutes(printing, context `hostPrintingAdminGuard` is the host's own authenticated administrator policy, not a supplied library method. The service is bound to one tenant; use a separate host service instance for each tenant. Device token verification and job ownership are enforced on every device operation. Management service methods are intended for trusted application code; enforce user/facility authorization before calling them. -The storage/scheduler options above are new in the pending 0.1.4 source. Configure a dedicated private printing bucket for the customer/environment through existing QQQ S3 metadata; no bucket credentials belong in printing forms. Admin retention defaults to 365 days after successful completion. Scheduled cleanup handles bounded batches every five minutes and preserves active, failed and uncertain work plus all receipts/history. Without a configured storage binding, legacy database documents remain supported; without a host scheduler, automatic cleanup does not run. Review [storage configuration, bucket isolation and S3 versioning](docs/STORAGE-AND-RETENTION.md) before deployment. +The storage/scheduler options above are included in 0.1.4-rc.1. Configure a dedicated private printing bucket for the customer/environment through existing QQQ S3 metadata; no bucket credentials belong in printing forms. Admin retention defaults to 365 days after successful completion. Scheduled cleanup handles bounded batches every five minutes and preserves active, failed and uncertain work plus all receipts/history. Without a configured storage binding, legacy database documents remain supported; without a host scheduler, automatic cleanup does not run. Review [storage configuration, bucket isolation and S3 versioning](docs/STORAGE-AND-RETENTION.md) before deployment. Enrollment uses a named facility selector. Set `withFacilityPossibleValueSourceName("your_existing_facility_source")` to reuse an already registered host source and its record security. Without it, the standalone choice is **Default facility**. The backend validates the selection under the current user's permissions before issuing an enrollment code; typing or forging an unknown/foreign-tenant ID cannot bypass that check. Existing stored facility IDs are preserved; configure their original source to resolve their names. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 40e4936..a09a62c 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -4,15 +4,15 @@ Built-in diagnostic pages and labels now use the official Query dog artwork from [qrun.io/query](https://qrun.io/query), with the exact original asset and provenance recorded alongside the resource. White padding preserves its aspect ratio within the existing layout; warehouse glyphs, media profiles, barcode and print routing are unchanged. All five PDF profiles and three thermal resolutions are covered by the new regression. The isolated full build passed **73 tests with no failures/errors/skips and zero Checkstyle violations**. Generated PDF previews and actual ZPL graphic-field reconstruction were reviewed. The tested resource is packaged and the previous logo is absent. -This server-only correction is not yet deployed or released. The Windows client already requests these server-generated documents; no client icon change is needed. The installed corrective Windows 0.1.9 candidate is connected and passed all 16 UI checks with credentials/history preserved. Publication and fresh branded physical-output confirmation remain pending. +This server-only correction is published in [v0.1.4-rc.1](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.4-rc.1), and local native deployment is accepted. The Windows client already requests these server-generated documents; no client icon change is needed. Installed-client acceptance passed with credentials and history preserved. Physical output from the new Query diagnostics remains unconfirmed. -## Audit candidate 0.1.4-rc.1 +## Published audit release 0.1.4-rc.1 -The authorized local paired-host cutover is complete. The exact approved CI package from `09e0995` runs the native QQQ frontend and device API on the existing loopback address. A native backup was independently restored and fully compared before the old H2 host stopped; its closed database was also preserved and checked. Existing pairing, one device, two ready printers, seven jobs and 21 events were retained. The Windows service reconnected automatically. Native login, bounded real-record queries, anonymous/cross-origin/authentication-table denials, logout and the enabled **365-day** retention policy passed. No new print was requested. This local host uses database delivery storage; real dedicated S3 provisioning and remote deployment are deferred by the user's local-first instruction. +Local paired-host acceptance is complete following verified recovery backups. Native authentication, bounded record queries, anonymous/cross-origin/authentication-table denials, logout and the enabled **365-day** retention policy passed. Pairing and history were preserved, and the connector reconnected automatically. This acceptance used database document storage and did not request a new print. Dedicated production S3 provisioning, IAM and remote deployment remain unqualified. -All required hosted checks pass at `09e0995`, including the approved narrow H2 dependency input. Exact CI JAR SHA256 is `db84552b84d0a2fa62766fe8fcf4ba7414ae57f798629f281abba951d0889422`; all 47 archive-entry payloads match the already qualified `45261d5` package, including 38 implementation classes and all runtime resources. All 204 dependency coordinates match. Fresh verification records 72 passing tests, zero Checkstyle violations, complete scanner evidence and zero Trivy HIGH/CRITICAL findings. Tagged publication and the Windows 0.1.9 installed qualification are still pending. +All required checks passed for reviewed Query commit `c34883d`; protected merge `412ffbbc` preserves that exact tree. [Release run 41190650664](https://github.com/QRun-IO/qbit-printing/actions/runs/41190650664) passed every build, security and CodeQL gate before publishing assets for signed tag `v0.1.4-rc.1`. The current gate records **73 passing tests and zero Checkstyle violations**. All 204 runtime dependency coordinates are unchanged from the qualified audit runtime. The public JAR, POM and SHA256SUMS passed checksum and GitHub attestation verification, constrained to the exact source commit, tag and release workflow. All 48 JAR entry contents, including 38 implementation classes, match the reviewed Query CI package. Release JAR SHA256: `b31aa58588e2bad6b1ca63e5904906c0379ef75b6d2b550ad1680e668312065b`. -The latest S3 safeguard passes **72 Java tests, zero failures/errors/skips and zero Checkstyle violations**, with independent source review complete. It binds documents to the reviewed physical key layout and native separator using unambiguous ordered JSON. Historical reads, new retries and cleanup reject incompatible bindings before object IO; inline database content and prior retry receipts remain compatible. Old experimental S3 bindings are not silently adopted. +The preceding S3 safeguard checkpoint passed **72 Java tests, zero failures/errors/skips and zero Checkstyle violations**, with independent source review complete. It binds documents to the reviewed physical key layout and native separator using unambiguous ordered JSON. Historical reads, new retries and cleanup reject incompatible bindings before object IO; inline database content and prior retry receipts remain compatible. Old experimental S3 bindings are not silently adopted. Actual isolated Windows-to-Linux qualification passed on local candidate JAR SHA256 `9163b5fb89c981b70d2bd416ca3920a17bcbda2fe61eaa7190f050cc27759495` with all 204 runtime libraries verified on both hosts. Read and retry returned 409; cleanup recorded an error with zero removals. A positive-control-checked native SDK observer recorded zero transmissions for those refusals. Content availability, tenant/device retained bytes, admission/orphan/job counts and the exact original object stayed unchanged. The earlier candidate reproduced false expiration of a Windows-key object from Linux; this correction prevents that loss of availability. Portable upstream keys, explicit historical migration and production IAM remain separate work. Fresh hosted checks for this correction and the scoped license input now pass. @@ -26,7 +26,7 @@ The prepared-copy failure was the upstream H2 2.3.232 close-time compaction defe The earlier H2 license-metadata gate is resolved. [Standards PR #54](https://github.com/KofTwentyTwo/standards/pull/54) merged EX-0013 through normal required checks following explicit user approval. Its separately approved CodeQL repair retains Actions and JavaScript/TypeScript under the advanced security-extended workflow. Printing applies only the approved package input; its fresh [PR gates](https://github.com/QRun-IO/qbit-printing/actions/runs/41185576692) pass. The inventory guard still requires exactly unmodified H2 2.4.240, with 17 passing guard tests; all other license, vulnerability and branch protections remain enforced. See [license evidence and expiry](H2-LICENSE-REVIEW.md). -The audit also fixes native-process replay, tenant-scoped facility selectors, duplicate-output risk preservation, atomic queue/retained-byte/rate admission and bounded printer history. Dedicated QQQ document storage and scheduled retention protect active, failed and uncertain work. Stop older writers before schema/accounting migration and preserve verified recovery copies. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). Real PostgreSQL/S3, remote authentication, multi-node operation and sustained physical throughput remain unqualified. Local cutover and shared-standards publication are complete; tagged audit publication remains pending. +The audit also fixes native-process replay, tenant-scoped facility selectors, duplicate-output risk preservation, atomic queue/retained-byte/rate admission and bounded printer history. Dedicated QQQ document storage and scheduled retention protect active, failed and uncertain work. Stop older writers before schema/accounting migration and preserve verified recovery copies. See [audit evidence](AUDIT-2026-10-30.md), [operations](OPERATIONS.md) and [storage](STORAGE-AND-RETENTION.md). Real PostgreSQL/S3, remote authentication, multi-node operation and sustained physical throughput remain unqualified. Local acceptance, shared-standards approval and tagged audit publication are complete. Record Files' **21:54 America/Chicago checkpoint on 2026-10-30** is acknowledged. Its latest typed reader and configured reporting authority are candidate source with focused tests; the verified installed artifact is still pending, and the earlier snapshot is not the final contract. The teams agree on the consumer-owned observation boundary and are resolving identifier bounds and optional spooler-ID canonicalization. Printing still needs its durable accepted source/initiator/job/destination binding, tenant-scoped committed event receipt and legitimate current native reporting identity before an adapter can be enabled. No adapter is enabled, no browser session or fabricated authority is retained, and spool completion is not physical-output confirmation. Standalone byte submission remains independent. diff --git a/docs/TODO.md b/docs/TODO.md index b5dc2df..bd3e1cd 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,7 +1,8 @@ # Follow-up - [x] Replace the test-document logo with the exact official Query dog asset; preserve aspect ratio and verify all PDF/thermal profiles with 73 passing tests, zero Checkstyle violations and reviewed previews. -- [ ] Deploy and publish the Query correction after fresh required gates, then obtain physical-output confirmation for the updated diagnostic documents. +- [x] Publish v0.1.4-rc.1 after all required release gates, verify checksums and all three asset attestations, and complete local native deployment acceptance. +- [ ] Obtain physical-output confirmation for the updated Query diagnostic documents. - [x] Correct S3 layout and multiline-prefix binding ambiguity; full isolated gate passes 72 tests with zero Checkstyle violations and independent review complete. - [x] Reproduce the actual Windows-to-Linux false-expiration defect, then qualify the corrected runtime: zero SDK requests for refused reads/retries/cleanup and preserved objects, availability and accounting. - [x] Pass fresh hosted gates for the S3 correction and approved scoped H2 input; verify exact CI artifacts, all 47 archive payloads and 204 runtime coordinates. Portable upstream keys, historical migration and production IAM remain separate qualification work. @@ -10,12 +11,12 @@ - [x] Add the exact reviewed H2 version guard and license attribution/evidence; 17 Python guard tests and workflow lint pass. - [x] Approve central H2 metadata record EX-0013 through standards PR #54, apply only its package-scoped workflow input and pass every fresh required check. - [x] Qualify registered JVM-hook shutdown on exact `00dd2dd`: native acceptance, Quartz/HTTP shutdown, exit 0 and independent read-only cold inspection before initialization pass. Windows reboot/service-manager behavior remains separate. -- [x] Perform authorized live paired-host cutover after verified native recovery, closed backup and fresh required gates; preserve pairing, 1 device, 2 ready printers, 7 jobs and 21 events. Native authentication/query/denial/logout, automatic connector reconnection and 365-day retention checks pass. +- [x] Complete local paired-host acceptance after verified recovery backups and fresh required gates; preserve pairing and history. Native authentication/query/denial/logout, automatic connector reconnection and 365-day retention checks pass. - [x] Finish audit regressions for native-process replay, strict options/inventory validation, retained duplicate risk and event field bounds; full gate passes 53 tests. - [x] Implement admin-configurable document retention (one-year default), bounded scheduled cleanup and existing QQQ object-storage integration, retaining history/receipts and protecting active or uncertain output. - [ ] Implement the optional Record Files adapter against its verified frozen contract: durable accepted-source/actor binding, legitimate current reporting identity and committed observation receipt. Adapter remains disabled; standalone printing does not depend on it. -- [ ] Verify tagged audit release gates, published provenance and deployed-host qualification. +- [x] Verify v0.1.4-rc.1 release run 41190650664, published JAR/POM/SHA256SUMS provenance and local native deployment acceptance. - [x] Historical audit PR #10 checkpoint aeac1a6 passed hosted checks; current H2/native qualification and remaining license gate are recorded above. - [x] Complete the independent baseline scan and current-source reviews of all five findings and remediations. - [x] Add authenticated native QQQ local host sharing paired data with device routes and the existing scheduler; 65 tests/zero Checkstyle pass and independent source review found no blocker. @@ -30,7 +31,8 @@ - [x] Confirm physical built-in ZPL and PDF output on the connected Zebra; scanning and measured alignment remain separate checks below. - [x] Add separate synthetic native QQQ frontend preview host. - [x] Replace identifier text inputs with tenant-scoped, device-dependent selectors and add table/record actions. -- [ ] Qualify deployed QQQ host integration. +- [x] Qualify authenticated local QQQ host integration with preserved pairing/history and native retention scheduling. +- [ ] Qualify remote QQQ host deployment and production authentication/facility policy. - [x] Separate local printer availability from server approval and block withdrawn queues. - [x] Prepare public repository documentation, license, CI and GitHub prerelease artifacts. - [x] Verify remote CI/security and publish v0.1.0-rc.1 JAR/POM/checksum assets. @@ -70,4 +72,4 @@ The Windows gate, MSI rebuild, installed UI acceptance, release evidence and fre - [ ] Obtain explicit barcode scan and measured alignment confirmation; successful spooling is not physical acceptance. - [x] Complete independent review after the five baseline security findings are remediated; current details are in SESSION-STATE.md. -- [ ] Complete deployed-host and real S3 qualification; source tests are not deployment evidence. +- [ ] Complete remote deployment and production S3/IAM qualification; local native acceptance and isolated storage tests do not establish these. -- 2.54.0 From 3062fc8e842e0fbf9fb98d842be57b92021bbee9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 08:51:53 -0500 Subject: [PATCH 19/35] feat: add native printing device controls and session authorization Add record/selection actions, per-device printers/jobs/queue/history and observed connection/control status. Persist bounded device actions and immutable receipts, serialize queue clearing against claims, and cancel only owned Windows jobs. Register semantic printing extension 1.0.0 with standard QQQ sessions, secondary bound-device proof, current native table/process/record checks and explicit permission enforcement. Preserve legacy compatibility without primary fallback. Pin protocol cc3c4e50e8f6f4fbc93d351fb51edb4f5438bbd3 and prepare 0.2.0-rc.1. Validation: Java21 verify passes 106 tests with zero failures/errors/skips and zero Checkstyle. All204 runtime dependency hashes match frozen inputs. Native HTTP/OpenAPI, authority denial, FIFO eligibility, queue races, selection replay and offline command expiry covered; cross-runtime/provider/install acceptance remain separate gates. Independent source review findings are corrected. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 14 + README.md | 8 +- docs/OPERATIONS.md | 89 + docs/SESSION-STATE.md | 8 + docs/TODO.md | 7 +- pom.xml | 2 +- .../printing/PrintingAgentAuthorization.java | 132 ++ .../qbits/printing/PrintingApiSchemas.java | 185 ++ .../qbits/printing/PrintingCommandStatus.java | 139 ++ .../PrintingControlAuthorization.java | 186 ++ .../printing/PrintingDeviceControls.java | 541 +++++ .../printing/PrintingDeviceProcesses.java | 352 +++ .../qbits/printing/PrintingDeviceStatus.java | 179 ++ .../qbits/printing/PrintingFacilities.java | 9 +- .../qbits/printing/PrintingHome.java | 49 +- .../qbits/printing/PrintingJobProcesses.java | 1 + .../qbits/printing/PrintingProcesses.java | 1 + .../qbits/printing/PrintingQBitProducer.java | 87 +- .../qbits/printing/PrintingRoutes.java | 32 +- .../qbits/printing/PrintingService.java | 195 +- .../qbits/printing/PrintingTables.java | 1 + .../qbits/printing/PrintingUiApi.java | 295 +++ .../qbits/printing/PrintingUiApiSpec.java | 331 +++ .../resources/openapi/qqq-v1/printing.yaml | 2078 +++++++++++++++++ .../printing/PrintingCommandStatusTest.java | 218 ++ .../qbits/printing/PrintingContractTest.java | 48 + .../printing/PrintingDeviceControlsTest.java | 337 +++ .../printing/PrintingDeviceProcessesTest.java | 313 +++ .../qbits/printing/PrintingHomeTest.java | 42 + .../qbits/printing/PrintingProcessesTest.java | 4 +- .../printing/PrintingUiApiContractTest.java | 80 + .../qbits/printing/PrintingUiApiTest.java | 345 +++ .../resources/openapi/printing-1.0.0.yaml | 2078 +++++++++++++++++ 34 files changed, 8357 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingAgentAuthorization.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingCommandStatus.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingControlAuthorization.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingDeviceStatus.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java create mode 100644 src/main/resources/openapi/qqq-v1/printing.yaml create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingCommandStatusTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingDeviceControlsTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingDeviceProcessesTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java create mode 100644 src/test/resources/openapi/printing-1.0.0.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67972af..896cdd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: QRun-IO/qqq-print-protocol - ref: f4f977ca4aa6b749e6a52760eed5e53c99a7704d + ref: cc3c4e50e8f6f4fbc93d351fb51edb4f5438bbd3 path: qqq-print-protocol persist-credentials: false - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd7dcd..17490d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased — 0.2.0-rc.1 candidate + +- Add native device/table selection actions for pause, resume, disconnect, + reconnect, revocation, inventory refresh and queued-job clearing, plus exact + owned Windows-job cancellation and bounded command history. +- Show device presence, desired versus applied control state, related printers, + jobs and queue; include connection/control issues in the printing dashboard. +- Register semantic printing extension1.0.0 at `/qqq/v1/qbit/printing`, using + standard QQQ user sessions and secondary device proof with current native + record/process permissions. Legacy bearer transport remains explicit compatibility. +- Preserve journal/history and distinguish accepted commands from confirmed + execution. Shared-core/provider native authentication acceptance and installed + candidate qualification remain required before deployment claims. + ## 0.1.4-rc.1 — 2026-10-31 - Use the official Query dog mascot on every built-in PDF and ZPL diagnostic profile, preserving the original artwork and aspect ratio. Full local and hosted gates pass 73 tests with zero Checkstyle violations; PDF previews and reconstructed thermal graphics pass visual review. Local native deployment is accepted; physical output from the updated Query diagnostics remains unconfirmed. diff --git a/README.md b/README.md index 716b77b..816255f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https:// 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. +The device-controls branch adds native record/selection actions, per-device printers/jobs/queue/history, desired versus observed status and standard-session authentication at the discovered `/qqq/v1/qbit/printing` extension. Device credentials become secondary identity proof alongside the current QQQ user's permissions. These changes are under integration testing and are not included in the published release above. See the [authentication contract](https://github.com/QRun-IO/qqq-print-protocol/blob/main/docs/QQQ-AUTHENTICATION.md) when that coordinated contract is published; legacy `/printing/v1` remains explicit compatibility. + ```mermaid flowchart LR A[QQQ application / durable job] --> B[Printing QBit and JDBC queue] @@ -18,7 +20,7 @@ See the [screen walkthrough](docs/UI-WALKTHROUGH.md), [storage and retention](do ## Build and verify -Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. CI pins protocol commit `f4f977ca4aa6b749e6a52760eed5e53c99a7704d`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). +Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This feature branch pins protocol commit `cc3c4e50e8f6f4fbc93d351fb51edb4f5438bbd3`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). ```powershell .\mvnw.cmd verify @@ -43,7 +45,7 @@ For a directly managed Java process, classpath is `target/classes;` followed by ## Native QQQ UI preview -A separate local demo serves the real QQQ frontend with synthetic device/printer/job records. It binds only to `127.0.0.1:18082`, uses mock authentication and registers no device transport routes, so its synthetic jobs cannot be claimed by a physical connector. +A separate local demo serves the real QQQ frontend with synthetic device/printer/job records. It binds only to `127.0.0.1:18082` and uses mock authentication. Keep it isolated from real pairing data and physical connectors; it is a screen preview, not an authenticated deployment. ```powershell .\mvnw.cmd -Pui-demo package dependency:build-classpath '-Dmdep.outputFile=target/ui-classpath.txt' '-DincludeScope=runtime' @@ -59,7 +61,7 @@ Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator The protocol-only `PrintingDemo` and synthetic, mock-authenticated `PrintingUiDemo` remain separate. Do not point the synthetic demo at real paired printer data. The local host retains database document storage until an actual dedicated S3 binding is configured in a deployment-owned application; it does not claim a remote cloud deployment. -Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The current release passes 73 tests and zero Checkstyle violations. Production S3/IAM, remote deployment and physical output from the new Query diagnostic artwork remain separate qualification work. +Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The published release passed 73 tests and zero Checkstyle violations; the user confirmed all three Query diagnostic outputs good. Production S3/IAM and remote deployment remain separate qualification work. New device-controls/authentication source requires its own integration gates and release. ## Add to a QQQ application diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 4792a4e..9931ba5 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -46,6 +46,95 @@ HTTP 429 means capacity, the rolling-hour allowance, cumulative inventory histor Inventory snapshots remain limited to 200 entries. Rotating IDs cannot bypass cumulative history limits, and rejected snapshots leave approval, availability and job references intact. Existing IDs remain updateable when a legacy database already exceeds a limit. Management `GET /printing/v1/admin/devices/{deviceId}/printers` accepts `limit` (1–200, default 200) and opaque `before` from the preceding response's `nextCursor`. The response is `{ "printers": [...], "nextCursor": null | "..." }`, ordered by stored name and row identity. The existing Java `printers()` method returns the first bounded array; use `printersPage()` for additional history. Native QQQ tables retain their own backend paging. +## Device controls and durable action receipts + +The native client API is the registered `printing` extension, semantic version +`1.0.0`, under `/qqq/v1/qbit/printing`. Discover it through QQQ's version endpoint; +its generated OpenAPI fragment is `src/main/resources/openapi/qqq-v1/printing.yaml`. +The contract test checks this fragment against the registered endpoint specs and +the initial semantic compatibility baseline. Core QQQ session authentication and +credential scopes run before printing operations; the application does not issue +another kind of user login token. + +Every native operation requires the authenticated QQQ principal, `printing_admin`, +the configured tenant security key, and applicable native table, process and row +access. Device operations additionally require `X-QQQ-Device-Id` and +`X-QQQ-Device-Token` as secondary proof. Enrollment at `/devices/enroll` validates +the user's facility access before consuming the enrollment code and atomically +binds the device to the core user's stable identity. A legacy unbound device can +be bound only through explicit `/device/bind` with valid proof and native device +access. Ordinary requests never bind or transfer ownership implicitly. + +`GET /session` returns the current principal and tenant; valid bound device headers +add the device identity. Native administration can manage another user's device +when current permissions allow it, without impersonating that user. Queue clear +and inventory updates authorize affected existing rows before mutation and compare +the reviewed identity set under the device lock. Claims authorize the exact job +and printer before dispatch, ordered by creation time then identity. An inaccessible +printer fails closed rather than granting access through its parent device. + +Queue-clear review is bounded to 10,000 jobs. It performs a native Get for each job +and each distinct printer, with the device checked once. Get-specific host policies +remain effective. Large clears on remote databases have not been performance +qualified; this review cost is separate from normal claim throughput. + +Developer projections that request derived `device_status`, `control_status`, or +`command_status` must also select `id`. The normal native UI includes it. An +identity-free projection is rejected rather than returning a fabricated UNKNOWN +status; the frozen QQQ dependency has no pre-query projection hook for these fields. + +Authorization uses the native QQQ context established for each request. Host +authentication and session refresh policies determine when permission changes +reach that context; printing does not assert that an upstream cached session is +fresh. Cookie-authenticated unsafe calls must also satisfy the host's Origin/CSRF +policy. The local host requires its canonical same-origin authority. + +Device controls use the same tenant-scoped service facade from native management +and transport adapters. Adapters must authorize the current user, tenant, device, +and action before calling the facade; a device ID or an actor string is not an +authorization grant. The legacy `/printing/v1` device bearer transport remains a +compatibility interface, not a substitute for native QQQ user permissions. + +Pause blocks new claims. Disconnect also expresses that ordinary agent heartbeat, +inventory publication, and claiming should stop; the independent authenticated +control poll remains available for reconnect. Neither operation cancels in-flight +jobs. Resume preserves connection state; reconnect preserves pause. Revocation +invalidates the enrolled credential permanently, blocks control polling, and +requires separate local re-enrollment. It cannot be undone with resume/reconnect. +Desired state, the last observed revision, and the last poll are separate facts: +a successful server action does not prove an offline device applied it. + +Queue clear atomically changes only this device's QUEUED jobs to FAILED with the +existing cancellation reason. It preserves documents, accounting, and history; +CLAIMED, RECEIVED, SUBMITTING, and uncertain work remain untouched. Claim and clear +share the device lock across server instances. Spool cancellation accepts only an +owned job with a recorded spool identity. The agent must independently verify its +local connection-scoped ownership before asking the operating system to cancel; +the server never accepts an arbitrary queue name, spool ID, or executable. + +Every management action supplies a new `requestId` and a UTC `issuedAt`, retained +unchanged across retries. A retained matching request returns its existing record; +a changed body returns 409. Without an existing record, requests older than 15 +minutes return 410, and times over 60 seconds in the future return 400. Review a +new action explicitly when a form expires. Never silently refresh its time or +reuse its identity. Terminal records become eligible for bounded cleanup after +seven days; an unchanged old request still expires after its receipt is removed. + +Remote inventory refresh and owned spool cancellation require advertised agent +capabilities, expire after 15 minutes, and have a limit of 100 pending commands +per device. Polls deliver at most 20 commands. The retained action history is +limited to 1,000 records per device, with one additional reserved revocation; +eligible old terminal records are reclaimed before admission. Capacity returns +429 explicitly. History pages contain at most 200 records and use the preceding +`nextCursor`; an expired or foreign cursor cannot disclose another device. + +Agents durably journal remote command identity before any effect and return the +same receipt on duplicate delivery. Expiry stops delivery but does not prove that +an earlier attempt did not execute: a first late receipt may replace provisional +EXPIRED with its actual outcome. Once stored, a receipt is immutable; conflicting +replays return 409. `CANCEL_REQUESTED` means the operating system accepted a +cancellation request, not that paper was withdrawn or a physical print was undone. + ## Common failures | Symptom | Check | diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index a09a62c..0927492 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,13 @@ # Session state — 2026-10-31 +## Active device controls and standard QQQ authentication + +Implementation is in the isolated device-controls worktree. Native device/table actions support pause, resume, disconnect, reconnect, revoke, inventory refresh and queued-job clearing; job records support exact owned Windows-job cancellation. Related printers, jobs, pending queue and bounded command history are registered. Derived status separates connection presence, desired state and observed revision. Domain writes use current QQQ permissions; clearing a queue checks every affected job before the transactional mutation. + +The primary client transport is the discovered `/qqq/v1/qbit/printing` semantic extension with standard QQQ user sessions and secondary device proof. Authentik requires the shared advertised native PKCE session grant; no private bearer-only fallback substitutes for that authorization. The final isolated Java21 gate passed **106 tests**, zero failures/errors/skips and zero Checkstyle violations. All204 runtime dependencies match the frozen approved hashes. Native HTTP, proof/binding denials, explicit permission enforcement, FIFO eligibility, queue races, cancellation replay and offline command expiry are covered. Cross-runtime client acceptance and protected CI remain pending. No new feature release or local installation has occurred. + +The user confirmed all three current Query test prints good. Barcode catalog expansion was removed from printing scope and belongs to document/barcode generation. + ## Query mascot correction Built-in diagnostic pages and labels now use the official Query dog artwork from [qrun.io/query](https://qrun.io/query), with the exact original asset and provenance recorded alongside the resource. White padding preserves its aspect ratio within the existing layout; warehouse glyphs, media profiles, barcode and print routing are unchanged. All five PDF profiles and three thermal resolutions are covered by the new regression. The isolated full build passed **73 tests with no failures/errors/skips and zero Checkstyle violations**. Generated PDF previews and actual ZPL graphic-field reconstruction were reviewed. The tested resource is packaged and the previous logo is absent. diff --git a/docs/TODO.md b/docs/TODO.md index bd3e1cd..3cfb7c5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,8 +1,13 @@ # Follow-up +- [x] Complete native device controls, per-record/selection actions, queue/history and dashboard integration tests;106tests pass with zero Checkstyle violations. +- [ ] Complete real Windows Core client/native QQQ interoperability and installed candidate acceptance after protected CI. +- [ ] Complete standard QQQ discovery/session/device-binding security checks and Windows auth integration; qualify the shared Authentik native grant without a private authentication fork. +- [ ] Review protocol fixtures, publish immutable contract inputs and pass protected CI before packaging the new features. + - [x] Replace the test-document logo with the exact official Query dog asset; preserve aspect ratio and verify all PDF/thermal profiles with 73 passing tests, zero Checkstyle violations and reviewed previews. - [x] Publish v0.1.4-rc.1 after all required release gates, verify checksums and all three asset attestations, and complete local native deployment acceptance. -- [ ] Obtain physical-output confirmation for the updated Query diagnostic documents. +- [x] Obtain user confirmation for all three updated Query diagnostic documents (Letter PDF, Zebra ZPL and Zebra PDF). - [x] Correct S3 layout and multiline-prefix binding ambiguity; full isolated gate passes 72 tests with zero Checkstyle violations and independent review complete. - [x] Reproduce the actual Windows-to-Linux false-expiration defect, then qualify the corrected runtime: zero SDK requests for refused reads/retries/cleanup and preserved objects, availability and accounting. - [x] Pass fresh hosted gates for the S3 correction and approved scoped H2 input; verify exact CI artifacts, all 47 archive payloads and 204 runtime coordinates. Portable upstream keys, historical migration and production IAM remain separate qualification work. diff --git a/pom.xml b/pom.xml index 97838f3..a2b161c 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.kingsrookqbit-build-parent2.0.0 com.kingsrook.qbits qbit-printing - 0.1.4-rc.1 + 0.2.0-rc.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/PrintingAgentAuthorization.java b/src/main/java/com/kingsrook/qbits/printing/PrintingAgentAuthorization.java new file mode 100644 index 0000000..93005e4 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingAgentAuthorization.java @@ -0,0 +1,132 @@ +/* + * 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.util.List; +import java.util.Set; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONArray; +import org.json.JSONObject; + + +/** Secondary proof binds a device to the already authenticated native QQQ principal. */ +public final class PrintingAgentAuthorization +{ + public static final String DEVICE_HEADER = "X-QQQ-Device-Id"; + public static final String TOKEN_HEADER = "X-QQQ-Device-Token"; + + + /** No independent authentication module or permanent service user. */ + private PrintingAgentAuthorization() + { + } + + + /** Bind only on the explicit endpoint; all ordinary requests require the existing owner. */ + public static String device(String id, String token, boolean bind) throws Exception + { + String principal = PrintingControlAuthorization.principal(); + PrintingControlAuthorization.record(PrintingTables.DEVICE, id); + PrintingProcesses.currentService().authorizeAgent(id, token, principal, bind); + return (id); + } + + + /** Resolve a wire printer identity through native row security, not a raw SQL lookup. */ + static QRecord printer(String device, String printer) throws QException + { + List rows = PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.PRINTER) + .withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)) + .withCriteria(new QFilterCriteria("printer_id", QCriteriaOperator.EQUALS, printer)).withLimit(1))).getRecords(); + if(rows.isEmpty()) + { + throw new QPermissionDeniedException("Printer is not accessible"); + } + return (PrintingControlAuthorization.record(PrintingTables.PRINTER, rows.getFirst().getValueString("id"))); + } + + + /** Inventory absence updates existing rows too, so authorize the complete bounded inventory. */ + static Set inventory(String device) throws Exception + { + Set authorized = new java.util.HashSet<>(); + String cursor = null; + do + { + JSONObject page = PrintingProcesses.currentService().printersPage(device, 200, cursor); + JSONArray printers = page.getJSONArray("printers"); + for(int index = 0; index < printers.length(); index++) + { + authorized.add(printer(device, printers.getJSONObject(index).getString("printerId")).getValueString("id")); + if(authorized.size() > 10000) + { + throw new PrintingException(429, "Inventory exceeds the bounded native review limit"); + } + } + cursor = page.isNull("nextCursor") ? null : page.getString("nextCursor"); + } + while(cursor != null); + // Even an empty inventory must have native table READ access. + PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.PRINTER) + .withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)).withLimit(1))); + return (authorized); + } + + + /** Select with native row security, then dispatch only that exact authorized identity. */ + static JSONObject claim(String device) throws Exception + { + QQueryFilter formats = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); + for(String format : List.of("PDF", "ZPL")) + { + formats.withSubFilter(new QQueryFilter(new QFilterCriteria("format", QCriteriaOperator.EQUALS, format)) + .withCriteria(new QFilterCriteria(PrintingTables.PRINTER + ".formats", QCriteriaOperator.CONTAINS, "\"" + format + "\""))); + } + List rows = PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.JOB) + .withQueryJoin(new QueryJoin(PrintingTables.PRINTER)) + .withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)) + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, List.of("QUEUED", "CLAIMED"))) + .withCriteria(new QFilterCriteria(PrintingTables.PRINTER + ".enabled", QCriteriaOperator.EQUALS, 1)) + .withCriteria(new QFilterCriteria(PrintingTables.PRINTER + ".available", QCriteriaOperator.EQUALS, 1)) + .withSubFilter(formats) + .withOrderBy(new QFilterOrderBy("created_at", true)).withOrderBy(new QFilterOrderBy("id", true)).withLimit(20))).getRecords(); + for(QRecord row : rows) + { + String id = row.getValueString("id"); + PrintingControlAuthorization.job(device, id); + JSONObject result = PrintingProcesses.currentService().claimForJob(device, id); + if(!result.isNull("job")) + { + return (result); + } + } + return (new JSONObject().put("job", JSONObject.NULL)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java b/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java new file mode 100644 index 0000000..e84015b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java @@ -0,0 +1,185 @@ +/* + * 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.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema; +import com.kingsrook.qqq.openapi.model.Schema; +import com.kingsrook.qqq.openapi.model.Type; + + +/** Explicit typed native request/response contracts, namespaced independently from core schemas. */ +final class PrintingApiSchemas +{ + /** Static schema definitions. */ + private PrintingApiSchemas() + { + } + + + /** Shared wire components describe every field; no opaque JSON blob substitutes for a contract. */ + static Map components() + { + Map schemas = new LinkedHashMap<>(); + add(schemas, "Empty", fields()); + add(schemas, "Acknowledgment", fields("accepted", bool()), "accepted"); + add(schemas, "Session", fields("principalId", text(200), "tenantId", text(200), "deviceId", id()), "principalId", "tenantId"); + add(schemas, "EnrollmentRequest", fields("enrollmentCode", text(128), "deviceName", text(1024), "agentVersion", text(100)), "enrollmentCode", "deviceName", "agentVersion"); + add(schemas, "Enrollment", fields("protocolVersion", number(), "deviceId", id(), "deviceToken", text(128).withDescription("Private device proof, returned once; never log."), "pollIntervalSeconds", number()), "protocolVersion", "deviceId", "deviceToken", "pollIntervalSeconds"); + add(schemas, "Profile", fields("name", text(512).withNullable(true), "description", text(4096).withNullable(true), "location", text(512).withNullable(true))); + add(schemas, "HeartbeatRequest", fields("agentVersion", text(100), "profile", ref("Profile")), "agentVersion"); + add(schemas, "Heartbeat", fields("serverTime", time()), "serverTime"); + add(schemas, "Printer", fields("printerId", id(), "name", text(1024), "driverName", text(1024), "portName", text(1024), "status", enumeration("READY", "OFFLINE", "PAUSED", "ERROR", "UNKNOWN"), "formats", array(enumeration("PDF", "ZPL")), "displayName", text(512), "description", text(4096), "location", text(512)), "printerId", "name", "formats"); + add(schemas, "InventoryRequest", fields("printers", array(ref("Printer"))), "printers"); + add(schemas, "Options", fields("copies", number(), "scale", enumeration("ACTUAL_SIZE", "FIT"), "paperWidthMm", new Schema().withType(Type.NUMBER), "paperHeightMm", new Schema().withType(Type.NUMBER))); + add(schemas, "Job", fields("jobId", id(), "deviceId", id(), "printerId", id(), "title", text(1024), "format", enumeration("PDF", "ZPL"), "contentSha256", text(64), "contentLength", number(), "options", ref("Options"), "createdAt", time(), "status", enumeration("QUEUED", "CLAIMED", "RECEIVED", "SUBMITTING", "SPOOLED", "SPOOLER_COMPLETED", "FAILED", "OUTCOME_UNKNOWN"), "message", text(2048), "spoolerJobId", id()), "jobId", "deviceId", "printerId", "title", "format", "contentSha256", "contentLength", "options", "createdAt", "status"); + add(schemas, "Claim", fields("job", ref("Job").withNullable(true)), "job"); + add(schemas, "Jobs", fields("jobs", array(ref("Job"))), "jobs"); + add(schemas, "EventRequest", fields("eventId", id(), "status", enumeration("RECEIVED", "SUBMITTING", "SPOOLED", "SPOOLER_COMPLETED", "FAILED", "OUTCOME_UNKNOWN"), "message", text(2048), "spoolerJobId", id()), "eventId", "status"); + add(schemas, "TestRequest", fields("requestId", id(), "printerId", id(), "format", enumeration("PDF", "ZPL"), "testProfile", text(40)), "requestId", "printerId", "format"); + add(schemas, "RetryRequest", fields("requestId", id(), "confirmDuplicateRisk", bool()), "requestId"); + add(schemas, "DesiredState", fields("revision", number(), "dispatchPaused", bool(), "connectionState", enumeration("CONNECTED", "DISCONNECTED")), "revision", "dispatchPaused", "connectionState"); + add(schemas, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL")), "lastAppliedRevision", number()), "controlVersion", "agentVersion", "capabilities", "lastAppliedRevision"); + add(schemas, "Command", fields("commandId", id(), "requestId", id(), "action", enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL"), "createdAt", time(), "expiresAt", time(), "jobId", id()), "commandId", "requestId", "action", "createdAt", "expiresAt"); + add(schemas, "ControlPoll", fields("controlVersion", number(), "serverTime", time(), "pollIntervalSeconds", number(), "desiredState", ref("DesiredState"), "commands", array(ref("Command"))), "controlVersion", "serverTime", "pollIntervalSeconds", "desiredState", "commands"); + add(schemas, "ReceiptRequest", fields("receiptId", id(), "outcome", enumeration("INVENTORY_REFRESHED", "CANCEL_REQUESTED", "ALREADY_ABSENT", "OWNERSHIP_MISMATCH", "UNSUPPORTED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"), "completedAt", time(), "message", text(1024)), "receiptId", "outcome", "completedAt"); + Schema actions = enumeration("PAUSE_DISPATCH", "RESUME_DISPATCH", "DISCONNECT", "RECONNECT", "REVOKE", "REFRESH_INVENTORY", "CLEAR_QUEUE", "CANCEL_SPOOL"); + add(schemas, "ActionRequest", fields("requestId", id(), "issuedAt", time(), "action", actions, "jobId", id()), "requestId", "issuedAt", "action"); + add(schemas, "Action", fields("commandId", id(), "requestId", id(), "issuedAt", time(), "deviceId", id(), "action", actions, "status", enumeration("PENDING", "SUCCEEDED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"), "createdAt", time(), "expiresAt", time(), "jobId", id(), "resultCode", text(80), "cancelledJobCount", number(), "receipt", ref("ReceiptRequest")), "commandId", "requestId", "issuedAt", "deviceId", "action", "status", "createdAt", "expiresAt"); + add(schemas, "Actions", fields("commands", array(ref("Action")), "nextCursor", text(128).withNullable(true)), "commands", "nextCursor"); + return (schemas); + } + + + /** Every schema has the extension prefix required by the native registry. */ + static String name(String shortName) + { + return ("Printing" + shortName + "V1"); + } + + + /** Refer to one typed reusable component. */ + static Schema ref(String name) + { + return (new Schema().withRefToSchema(name(name))); + } + + + /** Bounded Unicode text. */ + static Schema text(int limit) + { + return (new Schema().withType(Type.STRING).withMaxLength(limit)); + } + + + /** SchemaBuilder customization retains explicit required fields unsupported by the base RC20 model. */ + private record Shape(Map properties, List required) implements ToSchema + { + @Override + public Schema toSchema() + { + return (new RequiredSchema(required).withType(Type.OBJECT).withProperties(properties)); + } + } + + + /** Required is a standard OpenAPI keyword missing from RC20's base Schema bean. */ + public static class RequiredSchema extends Schema + { + private final List required; + + /** Retain the explicit protocol field requirements. */ + public RequiredSchema(List required) + { + this.required = required; + } + + /** Published by the core Jackson schema serializer. */ + public List getRequired() + { + return (required); + } + } + + + /** Build typed object fields without map arity limits. */ + private static Map fields(Object... pairs) + { + Map fields = new LinkedHashMap<>(); + for(int index = 0; index < pairs.length; index += 2) + { + fields.put((String) pairs[index], (Schema) pairs[index + 1]); + } + return (fields); + } + + + /** Publish a customized typed schema. */ + private static void add(Map target, String name, Map properties, String... required) + { + target.put(name(name), new Shape(properties, List.of(required)).toSchema()); + } + + + /** Protocol identifier. */ + private static Schema id() + { + return (text(128)); + } + + + /** UTC wire timestamp. */ + private static Schema time() + { + return (text(40).withFormat("date-time").withDescription("RFC3339 UTC ending in uppercase Z.")); + } + + + /** Exact JSON integer, never a stringified numeric value. */ + private static Schema number() + { + return (new Schema().withType(Type.INTEGER)); + } + + + /** Required booleans must never be inferred from missing fields. */ + private static Schema bool() + { + return (new Schema().withType(Type.BOOLEAN)); + } + + + /** Closed action or status vocabulary. */ + private static Schema enumeration(String... values) + { + return (new Schema().withType(Type.STRING).withEnumValues(List.of(values))); + } + + + /** Homogeneous typed arrays. */ + private static Schema array(Schema items) + { + return (new Schema().withType(Type.ARRAY).withItems(items)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingCommandStatus.java b/src/main/java/com/kingsrook/qbits/printing/PrintingCommandStatus.java new file mode 100644 index 0000000..b853cc7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingCommandStatus.java @@ -0,0 +1,139 @@ +/* + * 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.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Effective command status never requires an offline device to poll again. + ** Recorded status and durable receipts remain unchanged by native reads. + *******************************************************************************/ +public class PrintingCommandStatus implements TableCustomizerInterface +{ + private static final Set DEPENDENCIES = Set.of("id", "status", "expires_at"); + private static final Set TERMINAL = Set.of("SUCCEEDED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"); + + + + /*************************************************************************** + ** Enrich only the requested virtual field, preserving the caller's projection. + ***************************************************************************/ + @Override + public List postQuery(QueryOrGetInputInterface input, List records) throws QException + { + Set selected = input instanceof QueryInput query ? query.getFieldNamesToInclude() : null; + if(selected != null && !selected.contains("command_status")) + { + return (records); + } + if(selected != null && !selected.contains("id")) + { + throw new QException("Select id with derived command status"); + } + Map dependencies = dependencies(records); + Instant now = Instant.now(); + for(QRecord record : records) + { + QRecord values = dependencies.getOrDefault(record.getValueString("id"), record); + record.setValue("command_status", effective(values, now)); + } + return (records); + } + + + + /*************************************************************************** + ** Dependency-only native queries preserve permissions and tenant locks. + ** They are bounded by the selected IDs and cannot recurse into enrichment. + ***************************************************************************/ + private static Map dependencies(List records) throws QException + { + ArrayList ids = new ArrayList<>(); + for(QRecord record : records) + { + if(!record.getValues().keySet().containsAll(DEPENDENCIES) && record.getValueString("id") != null) + { + ids.add(record.getValueString("id")); + } + } + Map result = new HashMap<>(); + for(int first = 0; first < ids.size(); first += 200) + { + List page = ids.subList(first, Math.min(first + 200, ids.size())); + QueryInput input = new QueryInput().withTableName(PrintingTables.COMMAND).withFieldNamesToInclude(DEPENDENCIES) + .withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, page).withLimit(page.size())); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + for(QRecord row : new QueryAction().execute(input).getRecords()) + { + result.put(row.getValueString("id"), row); + } + } + return (result); + } + + + + /*************************************************************************** + ** A terminal receipt wins; a pending command needs a valid expiry observation. + ***************************************************************************/ + static String effective(QRecord record, Instant now) + { + String status = record.getValueString("status"); + if(status != null && TERMINAL.contains(status)) + { + return (status); + } + if(!"PENDING".equals(status)) + { + return ("UNKNOWN"); + } + String expiresAt = record.getValueString("expires_at"); + if(expiresAt == null || expiresAt.isBlank()) + { + return ("UNKNOWN"); + } + try + { + return (Instant.parse(expiresAt).isBefore(now) ? "EXPIRED" : "PENDING"); + } + catch(DateTimeParseException exception) + { + return ("UNKNOWN"); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingControlAuthorization.java b/src/main/java/com/kingsrook/qbits/printing/PrintingControlAuthorization.java new file mode 100644 index 0000000..2398a5e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingControlAuthorization.java @@ -0,0 +1,186 @@ +/* + * 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.util.Set; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONObject; + + +/** Common current-user authorization for native UI and semantic API management. */ +public final class PrintingControlAuthorization +{ + /** Static authorization facade. */ + private PrintingControlAuthorization() + { + } + + + /** Core user references, never emails or request-body identity, determine attribution. */ + public static String principal() throws QException + { + if(QContext.getQSession() == null || QContext.getQSession().getUser() == null + || QContext.getQSession().getUser().getIdReference() == null || QContext.getQSession().getUser().getIdReference().isBlank()) + { + throw new QAuthenticationException("Sign in to continue"); + } + new PrintingAdminPermission().checkPermissionsThrowing(new RunBackendStepInput(), null); + PrintingProcesses.currentService(); + return (QContext.getQSession().getUser().getIdReference()); + } + + + /** Native GetAction applies table access, field protection, and current record security. */ + public static QRecord record(String table, String id) throws QException + { + principal(); + if(id == null || id.isBlank()) + { + throw new QPermissionDeniedException("Record is not accessible"); + } + GetInput input = new GetInput(); + input.setTableName(table); + input.setPrimaryKey(id); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + QRecord row = new GetAction().execute(input).getRecord(); + if(row == null) + { + throw new QPermissionDeniedException("Record is not accessible"); + } + return (row); + } + + + /** Operations without an existing row still require native table read authority. */ + public static void table(String table) throws QException + { + principal(); + query(new QueryInput().withTableName(table).withFilter(new QQueryFilter().withLimit(1))); + } + + + /** Explicit enforcement remains effective inside native process action scopes. */ + static QueryOutput query(QueryInput input) throws QException + { + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + return (new QueryAction().execute(input)); + } + + + /** A job operation must also authorize its device and exact printer record. */ + public static QRecord job(String deviceId, String jobId) throws QException + { + QRecord row = record(PrintingTables.JOB, jobId); + if(!deviceId.equals(row.getValueString("device_id"))) + { + throw new QPermissionDeniedException("Job is not assigned to this device"); + } + record(PrintingTables.DEVICE, deviceId); + QRecord printer = record(PrintingTables.PRINTER, row.getValueString("printer_row_id")); + if(!deviceId.equals(printer.getValueString("device_id"))) + { + throw new QPermissionDeniedException("Printer is not assigned to this device"); + } + return (row); + } + + + /** The native process permission remains the domain write authority; generic table writes stay disabled. */ + public static void process(String name) throws QException + { + principal(); + PermissionsHelper.checkProcessPermissionThrowing(new RunBackendStepInput().withProcessName(name), name); + } + + + /** Current users can manage authorized devices without impersonating the enrolled agent owner. */ + public static JSONObject deviceAction(String deviceId, JSONObject request) throws Exception + { + String actor = principal(); + record(PrintingTables.DEVICE, deviceId); + PrintingDeviceProcesses.Action selected = null; + for(PrintingDeviceProcesses.Action action : PrintingDeviceProcesses.Action.values()) + { + if(action.wire.equals(request.optString("action"))) + { + selected = action; + } + } + if(selected == null) + { + throw new PrintingException(400, "Invalid device action"); + } + process(selected.process); + PrintingService service = PrintingProcesses.currentService(); + String existing = service.existingDeviceAction(deviceId, request.optString("requestId")); + if(existing != null) + { + QRecord receipt = record(PrintingTables.COMMAND, existing); + if(receipt.getValueString("job_id") != null) + { + job(deviceId, receipt.getValueString("job_id")); + } + return (service.deviceAction(deviceId, request, actor)); + } + Set queue = null; + if("CLEAR_QUEUE".equals(selected.wire)) + { + queue = service.queuedJobIdsForControl(deviceId); + Set printers = new java.util.HashSet<>(); + for(String id : queue) + { + QRecord queued = record(PrintingTables.JOB, id); + if(!deviceId.equals(queued.getValueString("device_id"))) + { + throw new QPermissionDeniedException("Job is not assigned to this device"); + } + printers.add(queued.getValueString("printer_row_id")); + } + for(String printerId : printers) + { + QRecord printer = record(PrintingTables.PRINTER, printerId); + if(!deviceId.equals(printer.getValueString("device_id"))) + { + throw new QPermissionDeniedException("Printer is not assigned to this device"); + } + } + } + if("CANCEL_SPOOL".equals(selected.wire)) + { + job(deviceId, request.optString("jobId")); + } + return (service.deviceAction(deviceId, request, actor, queue)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java new file mode 100644 index 0000000..741b501 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java @@ -0,0 +1,541 @@ +/* + * 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 javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.json.JSONArray; +import org.json.JSONObject; +import static com.kingsrook.qbits.printing.PrintingService.query; +import static com.kingsrook.qbits.printing.PrintingService.update; + + +/** Durable device controls share the device lock with dispatch and submission. */ +final class PrintingDeviceControls +{ + private static final Set ACTIONS = Set.of("PAUSE_DISPATCH", "RESUME_DISPATCH", "DISCONNECT", "RECONNECT", "REVOKE", "REFRESH_INVENTORY", "CLEAR_QUEUE", "CANCEL_SPOOL"); + private static final Set REMOTE = Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL"); + private final DataSource source; + private final String tenant; + + + /** Bind to the same immutable tenant and pool as the queue. */ + PrintingDeviceControls(DataSource source, String tenant) + { + this.source = source; + this.tenant = tenant; + } + + + /** Add compatible default state and a bounded durable command ledger. */ + static void initialize(Connection connection) throws SQLException + { + for(String sql : new String[] { + "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS dispatch_paused INTEGER DEFAULT 0 NOT NULL", + "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS connection_state VARCHAR(20) DEFAULT 'CONNECTED' NOT NULL", + "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS control_revision BIGINT DEFAULT 0 NOT NULL", + "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS control_version INTEGER DEFAULT 0 NOT NULL", + "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS control_capabilities VARCHAR(200) DEFAULT '[]' NOT NULL", + "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS control_last_poll_at VARCHAR(40)", + "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS control_applied_revision BIGINT DEFAULT 0 NOT NULL", + "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS revoked_at VARCHAR(40)", + "CREATE TABLE IF NOT EXISTS printing_device_command (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), request_id VARCHAR(128) NOT NULL, action VARCHAR(40) NOT NULL, job_id VARCHAR(36), actor VARCHAR(200) NOT NULL, request_json VARCHAR(2000) NOT NULL, status VARCHAR(40) NOT NULL, created_at VARCHAR(40) NOT NULL, expires_at VARCHAR(40) NOT NULL, result_code VARCHAR(80), cancelled_job_count INTEGER, receipt_json VARCHAR(8000), UNIQUE(tenant_id,device_id,request_id))", + "CREATE INDEX IF NOT EXISTS printing_command_owner_idx ON printing_device_command(tenant_id,device_id,created_at,id)", + "CREATE INDEX IF NOT EXISTS printing_command_pending_idx ON printing_device_command(tenant_id,device_id,status,expires_at)" + }) + { + try(var statement = connection.prepareStatement(sql)) + { + statement.execute(); + } + } + } + + + /** Pure status projection never returns credential verifiers. */ + static JSONObject state(JSONObject row) + { + JSONObject result = new JSONObject().put("supported", row.getInt("control_version") == 1) + .put("capabilities", new JSONArray(row.getString("control_capabilities"))) + .put("lastAppliedRevision", row.getLong("control_applied_revision")) + .put("desiredState", new JSONObject().put("revision", row.getLong("control_revision")) + .put("dispatchPaused", row.getInt("dispatch_paused") == 1).put("connectionState", row.getString("connection_state"))); + if(row.has("control_last_poll_at")) + { + result.put("lastPollAt", row.getString("control_last_poll_at")); + } + return (result); + } + + + /** Strict action input and original receipt enforce replay before any side effect. */ + JSONObject action(String deviceId, JSONObject request, String actor) throws SQLException + { + return (action(deviceId, request, actor, null)); + } + + + /** A native authorized queue snapshot must still match after taking the device lock. */ + JSONObject action(String deviceId, JSONObject request, String actor, Set authorizedQueue) throws SQLException + { + fields(request, Set.of("requestId", "issuedAt", "action", "jobId")); + String requestId = text(request, "requestId", 128); + if(!requestId.matches("[A-Za-z0-9._-]+")) + { + throw new PrintingException(400, "Invalid requestId"); + } + String action = text(request, "action", 40); + if(!ACTIONS.contains(action) || request.has("jobId") != "CANCEL_SPOOL".equals(action)) + { + throw new PrintingException(400, "Invalid device action"); + } + String jobId = "CANCEL_SPOOL".equals(action) ? text(request, "jobId", 36) : null; + if(actor == null || actor.isBlank() || actor.length() > 200) + { + throw new PrintingException(400, "A bounded authenticated actor is required"); + } + try(Connection connection = source.getConnection()) + { + connection.setAutoCommit(false); + try + { + JSONObject device = lock(connection, deviceId, false); + List old = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenant, deviceId, requestId); + if(!old.isEmpty()) + { + if(!new JSONObject(old.getFirst().getString("request_json")).similar(request)) + { + throw new PrintingException(409, "Request identity was already used for a different action"); + } + connection.commit(); + return (record(old.getFirst())); + } + Instant issued; + try + { + issued = Instant.parse(utc(request, "issuedAt")); + } + catch(java.time.format.DateTimeParseException exception) + { + throw new PrintingException(400, "Invalid action time"); + } + Instant current = Instant.now(); + if(issued.isBefore(current.minusSeconds(900))) + { + throw new PrintingException(410, "Action request expired; review a new action explicitly"); + } + if(issued.isAfter(current.plusSeconds(60))) + { + throw new PrintingException(400, "Action time is too far in the future"); + } + if(device.getInt("enabled") != 1 || device.has("revoked_at")) + { + throw new PrintingException(409, "Revoked devices require a new local enrollment"); + } + if(!REMOTE.contains(action) && !Set.of("CLEAR_QUEUE", "REVOKE").contains(action) && device.getLong("control_revision") >= 9007199254740991L) + { + throw new PrintingException(409, "Control revision capacity reached"); + } + expire(connection, deviceId); + update(connection, "DELETE FROM printing_device_command WHERE id IN (SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status<>'PENDING' AND created_at= 1000 && !"REVOKE".equals(action)) + { + throw new PrintingException(429, "Device control receipt capacity reached; receipts are retained and revocation remains available"); + } + String id = UUID.randomUUID().toString(); + String now = current.toString(); + String expires = current.plusSeconds(900).toString(); + String result = null; + Integer cancelled = null; + if(REMOTE.contains(action)) + { + if(device.getInt("control_version") != 1 || !new JSONArray(device.getString("control_capabilities")).toList().contains(action)) + { + throw new PrintingException(409, "CONTROL_UNSUPPORTED"); + } + if(count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId) >= 100) + { + throw new PrintingException(429, "Too many pending device commands"); + } + if(jobId != null) + { + List jobs = query(connection, "SELECT id,status,spooler_job_id FROM printing_job WHERE tenant_id=? AND device_id=? AND id=?", tenant, deviceId, jobId); + if(jobs.isEmpty()) + { + throw new PrintingException(404, "Job not found"); + } + if(!Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(jobs.getFirst().getString("status")) || jobs.getFirst().optString("spooler_job_id").isBlank()) + { + throw new PrintingException(409, "Only an owned job with a recorded spool identity can be canceled"); + } + } + } + else if("CLEAR_QUEUE".equals(action)) + { + if(authorizedQueue != null) + { + Set currentQueue = new java.util.HashSet<>(); + for(JSONObject row : query(connection, "SELECT id FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' LIMIT 10001", tenant, deviceId)) + { + currentQueue.add(row.getString("id")); + } + if(!currentQueue.equals(authorizedQueue)) + { + throw new PrintingException(409, "The queue changed; review the current authorized jobs before retrying"); + } + } + cancelled = update(connection, "UPDATE printing_job SET status='FAILED',message=? WHERE tenant_id=? AND device_id=? AND status='QUEUED'", PrintingService.CANCELED_BEFORE_DISPATCH, tenant, deviceId); + result = "QUEUED_JOBS_CANCELLED"; + } + else if("REVOKE".equals(action)) + { + update(connection, "UPDATE printing_device SET enabled=0,token_hash=?,revoked_at=?,dispatch_paused=1,connection_state='DISCONNECTED',control_revision=? WHERE tenant_id=? AND id=?", UUID.randomUUID().toString(), now, Math.min(9007199254740991L, device.getLong("control_revision") + 1), tenant, deviceId); + update(connection, "UPDATE printing_device_command SET status='OUTCOME_UNKNOWN' WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId); + result = "CREDENTIAL_REVOKED"; + } + else + { + int paused = device.getInt("dispatch_paused"); + String connected = device.getString("connection_state"); + if("PAUSE_DISPATCH".equals(action)) + { + paused = 1; + } + if("RESUME_DISPATCH".equals(action)) + { + paused = 0; + } + if("DISCONNECT".equals(action)) + { + connected = "DISCONNECTED"; + } + if("RECONNECT".equals(action)) + { + connected = "CONNECTED"; + } + update(connection, "UPDATE printing_device SET dispatch_paused=?,connection_state=?,control_revision=control_revision+1 WHERE tenant_id=? AND id=?", paused, connected, tenant, deviceId); + result = "DESIRED_STATE_UPDATED"; + } + update(connection, "INSERT INTO printing_device_command (id,tenant_id,device_id,request_id,action,job_id,actor,request_json,status,created_at,expires_at,result_code,cancelled_job_count) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + id, tenant, deviceId, requestId, action, jobId, actor, request.toString(), REMOTE.contains(action) ? "PENDING" : "SUCCEEDED", now, expires, result, cancelled); + JSONObject response = record(command(connection, deviceId, id)); + connection.commit(); + return (response); + } + catch(Exception exception) + { + connection.rollback(); + throw exception; + } + } + } + + + /** Independent outbound control traffic remains available while disconnected. */ + JSONObject poll(String deviceId, JSONObject request) throws SQLException + { + fields(request, Set.of("controlVersion", "agentVersion", "capabilities", "lastAppliedRevision")); + if(integer(request, "controlVersion") != 1) + { + throw new PrintingException(400, "Unsupported control version"); + } + String version = text(request, "agentVersion", 100); + long applied = integer(request, "lastAppliedRevision"); + if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 2 + || capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length()) + { + throw new PrintingException(400, "Invalid control capabilities"); + } + try(Connection connection = source.getConnection()) + { + connection.setAutoCommit(false); + try + { + JSONObject device = lock(connection, deviceId, true); + if(applied > device.getLong("control_revision")) + { + throw new PrintingException(409, "Unknown control revision"); + } + String now = Instant.now().toString(); + update(connection, "UPDATE printing_device SET control_version=1,control_capabilities=?,control_last_poll_at=?,control_applied_revision=?,agent_version=?,last_seen_at=? WHERE tenant_id=? AND id=?", + capabilities.toString(), now, applied, version, now, tenant, deviceId); + expire(connection, deviceId); + JSONArray commands = new JSONArray(); + for(JSONObject row : query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status='PENDING' AND (action<>'REFRESH_INVENTORY' OR ?='CONNECTED') ORDER BY created_at,id LIMIT 20", tenant, deviceId, device.getString("connection_state"))) + { + JSONObject value = record(row); + JSONObject delivery = new JSONObject(); + for(String key : List.of("commandId", "requestId", "action", "createdAt", "expiresAt", "jobId")) + { + if(value.has(key)) + { + delivery.put(key, value.get(key)); + } + } + commands.put(delivery); + } + connection.commit(); + return (new JSONObject().put("controlVersion", 1).put("serverTime", now).put("pollIntervalSeconds", 5) + .put("desiredState", state(device).getJSONObject("desiredState")).put("commands", commands)); + } + catch(Exception exception) + { + connection.rollback(); + throw exception; + } + } + } + + + /** An immutable receipt records observation, never rewrites printing outcomes. */ + JSONObject receipt(String deviceId, String commandId, JSONObject receipt) throws SQLException + { + fields(receipt, Set.of("receiptId", "outcome", "completedAt", "message")); + if(!text(receipt, "receiptId", 128).matches("[A-Za-z0-9._-]+")) + { + throw new PrintingException(400, "Invalid receiptId"); + } + String outcome = text(receipt, "outcome", 40); + try + { + Instant.parse(utc(receipt, "completedAt")); + } + catch(java.time.format.DateTimeParseException exception) + { + throw new PrintingException(400, "Invalid receipt time"); + } + if(receipt.has("message") && (!(receipt.opt("message") instanceof String message) || message.length() > 1024 || message.indexOf('\0') >= 0)) + { + throw new PrintingException(400, "Invalid receipt message"); + } + if(receipt.toString().length() > 8000) + { + throw new PrintingException(400, "Receipt is too large"); + } + try(Connection connection = source.getConnection()) + { + connection.setAutoCommit(false); + try + { + lock(connection, deviceId, true); + JSONObject row = command(connection, deviceId, commandId); + String action = row.getString("action"); + if(!REMOTE.contains(action)) + { + throw new PrintingException(409, "This action has no device receipt"); + } + Set outcomes = "REFRESH_INVENTORY".equals(action) + ? Set.of("INVENTORY_REFRESHED", "UNSUPPORTED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN") + : Set.of("CANCEL_REQUESTED", "ALREADY_ABSENT", "OWNERSHIP_MISMATCH", "UNSUPPORTED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"); + if(!outcomes.contains(outcome)) + { + throw new PrintingException(400, "Invalid command outcome"); + } + if(row.has("receipt_json")) + { + if(!new JSONObject(row.getString("receipt_json")).similar(receipt)) + { + throw new PrintingException(409, "Command receipt is immutable"); + } + } + else + { + String status = switch(outcome) + { + case "INVENTORY_REFRESHED", "CANCEL_REQUESTED", "ALREADY_ABSENT" -> "SUCCEEDED"; + case "EXPIRED", "OUTCOME_UNKNOWN" -> outcome; + default -> "FAILED"; + }; + update(connection, "UPDATE printing_device_command SET status=?,receipt_json=? WHERE id=? AND tenant_id=? AND device_id=?", status, receipt.toString(), commandId, tenant, deviceId); + } + connection.commit(); + return (new JSONObject().put("accepted", true)); + } + catch(Exception exception) + { + connection.rollback(); + throw exception; + } + } + } + + + /** Management history is bounded and remains readable after revocation. */ + JSONObject page(String deviceId, int limit, String before) throws SQLException + { + if(limit < 1 || limit > 200) + { + throw new PrintingException(400, "Invalid command page limit"); + } + try(Connection connection = source.getConnection()) + { + List rows; + if(query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND id=?", tenant, deviceId).isEmpty()) + { + throw new PrintingException(404, "Device not found"); + } + if(before == null) + { + rows = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? ORDER BY created_at DESC,id DESC LIMIT ?", tenant, deviceId, limit + 1); + } + else + { + JSONObject cursor = command(connection, deviceId, before); + rows = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND (created_at limit ? rows.get(limit - 1).getString("id") : JSONObject.NULL)); + } + } + + + /** Look up one exact tenant/device command without exposing another owner. */ + JSONObject get(String deviceId, String commandId) throws SQLException + { + try(Connection connection = source.getConnection()) + { + return (record(command(connection, deviceId, commandId))); + } + } + + + /** Serialize a device before any state, claim, or command mutation. */ + private JSONObject lock(Connection connection, String deviceId, boolean active) throws SQLException + { + List rows = query(connection, "SELECT * FROM printing_device WHERE tenant_id=? AND id=? FOR UPDATE", tenant, deviceId); + if(rows.isEmpty() || (active && (rows.getFirst().getInt("enabled") != 1 || rows.getFirst().has("revoked_at")))) + { + throw new PrintingException(404, "Device not found"); + } + return (rows.getFirst()); + } + + + /** Expiry stops delivery, but a later first receipt can still report actual execution. */ + private void expire(Connection connection, String deviceId) throws SQLException + { + update(connection, "UPDATE printing_device_command SET status='EXPIRED' WHERE tenant_id=? AND device_id=? AND status='PENDING' AND expires_at rows = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND id=?", tenant, deviceId, id); + if(rows.isEmpty()) + { + throw new PrintingException(404, "Command not found"); + } + return (rows.getFirst()); + } + + + /** Stable wire projection excludes raw request and authentication state. */ + private static JSONObject record(JSONObject row) + { + JSONObject value = new JSONObject().put("commandId", row.getString("id")).put("requestId", row.getString("request_id")).put("issuedAt", new JSONObject(row.getString("request_json")).getString("issuedAt")) + .put("deviceId", row.getString("device_id")).put("action", row.getString("action")).put("status", row.getString("status")) + .put("createdAt", row.getString("created_at")).put("expiresAt", row.getString("expires_at")); + if("PENDING".equals(row.getString("status")) && Instant.parse(row.getString("expires_at")).isBefore(Instant.now())) + { + value.put("status", "EXPIRED"); + } + if(row.has("job_id")) + { + value.put("jobId", row.getString("job_id")); + } + if(row.has("result_code")) + { + value.put("resultCode", row.getString("result_code")); + } + if(row.has("cancelled_job_count")) + { + value.put("cancelledJobCount", row.getInt("cancelled_job_count")); + } + if(row.has("receipt_json")) + { + value.put("receipt", new JSONObject(row.getString("receipt_json"))); + } + return (value); + } + + + /** Numeric aggregates do not load a receipt history into application memory. */ + private static long count(Connection connection, String sql, Object... values) throws SQLException + { + return (query(connection, sql, values).getFirst().getLong("n")); + } + + + /** Reject ambiguous or oversized text before persistence. */ + private static String text(JSONObject object, String field, int maximum) + { + if(!(object.opt(field) instanceof String value) || value.isBlank() || value.length() > maximum || value.indexOf('\0') >= 0) + { + throw new PrintingException(400, "Invalid " + field); + } + return (value); + } + + + /** Protocol times have explicit UTC spelling rather than an implicit local zone. */ + private static String utc(JSONObject object, String field) + { + String value = text(object, field, 40); + if(!value.endsWith("Z")) + { + throw new PrintingException(400, "UTC timestamp required"); + } + return (value); + } + + /** Require an exact nonnegative integral JSON value without coercion. */ + private static long integer(JSONObject object, String field) + { + Object value = object.opt(field); + if(!(value instanceof Integer || value instanceof Long) || (((Number) value).longValue() < 0 || ((Number) value).longValue() > 9007199254740991L)) + { + throw new PrintingException(400, "Invalid " + field); + } + return (((Number) value).longValue()); + } + + + /** A closed vocabulary prevents arbitrary command payloads. */ + private static void fields(JSONObject object, Set allowed) + { + if(!allowed.containsAll(object.keySet())) + { + throw new PrintingException(400, "Unknown control field"); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java new file mode 100644 index 0000000..88b3686 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java @@ -0,0 +1,352 @@ +/* + * 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.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import org.json.JSONObject; + + +/******************************************************************************* + ** Native administration controls use the caller's QQQ session and selected rows. + ** Review is read-only; durable service requests execute only in the load step. + *******************************************************************************/ +public final class PrintingDeviceProcesses +{ + static final String REQUEST_ID = "printingControlRequestId"; + static final String ISSUED_AT = "printingControlIssuedAt"; + static final String CONFIRM = "confirmPrintingControl"; + + + + /*************************************************************************** + ** Each action is associated with both record and table-selection menus. + ***************************************************************************/ + public enum Action + { + PAUSE("printingPauseDevices", "Pause printing", "PAUSE_DISPATCH", false, false, + "New work will stop being dispatched. Jobs already received by Windows may still print."), + RESUME("printingResumeDevices", "Resume printing", "RESUME_DISPATCH", false, false, + "Dispatch will resume for connected, authorized devices. Administrative disconnect remains unchanged."), + DISCONNECT("printingDisconnectDevices", "Disconnect devices", "DISCONNECT", false, false, + "Normal printing traffic will stop. The outbound control channel remains available for reconnect."), + RECONNECT("printingReconnectDevices", "Reconnect devices", "RECONNECT", false, false, + "Connected agents will be asked to resume normal traffic. An offline computer cannot be reached immediately."), + REVOKE("printingRevokeDevices", "Revoke device access", "REVOKE", true, false, + "Access will be revoked permanently. The old credential cannot be restored; local re-enrollment is required."), + REFRESH("printingRefreshDevices", "Refresh printer inventory", "REFRESH_INVENTORY", false, false, + "A bounded refresh request will wait for a compatible agent. Acceptance does not mean it has executed."), + CLEAR("printingClearDeviceQueues", "Cancel queued jobs", "CLEAR_QUEUE", true, false, + "Only jobs still queued in QQQ will be canceled. History remains and Windows jobs are not purged."), + CANCEL_WINDOWS("printingCancelWindowsJobs", "Cancel Windows print job", "CANCEL_SPOOL", false, true, + "The agent will request cancellation only with proof that the spool entry belongs to this job. Printed output cannot be recalled."); + + final String process; + final String label; + final String wire; + final boolean confirmation; + final boolean job; + final String preview; + + + + /************************************************************************ + ** Keep labels separate from the closed wire action vocabulary. + ************************************************************************/ + Action(String process, String label, String wire, boolean confirmation, boolean job, String preview) + { + this.process = process; + this.label = label; + this.wire = wire; + this.confirmation = confirmation; + this.job = job; + this.preview = preview; + } + } + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private PrintingDeviceProcesses() + { + } + + + + /*************************************************************************** + ** Standard streamed review supports checked rows, records and query selection. + ***************************************************************************/ + public static void register(QInstance instance) + { + for(Action action : Action.values()) + { + String table = action.job ? PrintingTables.JOB : PrintingTables.DEVICE; + Map values = new HashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, action.preview); + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Transform.class, Load.class, values) + .withName(action.process).withLabel(action.label).withTableName(table).withMinInputRecords(1) + .withPermissionRules(PrintingProcesses.permissionRules()); + process.withStep(0, new QBackendStepMetaData().withName("prepareControl").withCode(new QCodeReference(Prepare.class))); + if(action.confirmation) + { + process.withStep(1, new QFrontendStepMetaData().withName("confirmControl").withLabel(action.label) + .withFormField(new QFieldMetaData(CONFIRM, QFieldType.BOOLEAN).withLabel("I understand and confirm this action").withDefaultValue(false)) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))); + } + instance.addProcess(process); + } + } + + + + /*************************************************************************** + ** Unknown names must never choose a more privileged default action. + ***************************************************************************/ + static Action action(RunBackendStepInput input) throws QException + { + for(Action action : Action.values()) + { + if(action.process.equals(input.getProcessName())) + { + return (action); + } + } + throw new QException("Unknown printing control action"); + } + + + + /*************************************************************************** + ** Use a real authenticated user identifier, never a client-supplied actor. + ***************************************************************************/ + static String actor(RunBackendStepInput input) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + PrintingProcesses.currentService(); + if(QContext.getQSession() == null || QContext.getQSession().getUser() == null + || QContext.getQSession().getUser().getIdReference() == null || QContext.getQSession().getUser().getIdReference().isBlank()) + { + throw new QException("Sign in with an authorized QQQ user before controlling a print device"); + } + return (QContext.getQSession().getUser().getIdReference()); + } + + + + /*************************************************************************** + ** Resolve current records through QQQ permissions and tenant row locks. + ***************************************************************************/ + static QRecord resolve(RunBackendStepInput input, String id) throws QException + { + Action action = action(input); + GetInput get = new GetInput(); + get.setTableName(action.job ? PrintingTables.JOB : PrintingTables.DEVICE); + get.setPrimaryKey(id); + get.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE); + QRecord record = new GetAction().execute(get).getRecord(); + if(record == null) + { + throw new QException("Choose an accessible print " + (action.job ? "job" : "device")); + } + if(action.job && "QUEUED".equals(record.getValueString("status"))) + { + throw new QException("Use Cancel queued jobs for work that has not been dispatched to Windows"); + } + return (record); + } + + + + /*************************************************************************** + ** Keep request identity and issue time stable throughout review and replay. + ***************************************************************************/ + public static class Prepare implements BackendStep + { + /************************************************************************ + ** No service mutation or command delivery occurs during preparation. + ************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + actor(input); + action(input); + String request = input.getValueString(REQUEST_ID); + String issued = input.getValueString(ISSUED_AT); + if((request == null || request.isBlank()) != (issued == null || issued.isBlank())) + { + throw new QException("Restart this action to obtain a complete control request identity"); + } + output.addValue(REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request); + output.addValue(ISSUED_AT, issued == null || issued.isBlank() ? Instant.now().toString() : issued); + } + } + + + + /*************************************************************************** + ** The native preview rechecks scope without changing device or queue state. + ***************************************************************************/ + public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private final ProcessSummaryLine ready = new ProcessSummaryLine(Status.OK, 0, null) + .withSingularFutureMessage("record will receive the selected control action") + .withPluralFutureMessage("records will receive the selected control action"); + + + + /************************************************************************ + ** Never trust extracted rows after permissions or records have changed. + ************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + actor(input); + for(QRecord selected : input.getRecords()) + { + QRecord current = resolve(input, selected.getValueString("id")); + ready.incrementCountAndAddPrimaryKey(current.getValue("id")); + output.getRecords().add(current); + } + } + + + + /************************************************************************ + ** Standard summary links preserve the native record/table workflow. + ************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + return (new ArrayList<>(List.of(ready))); + } + } + + + + /*************************************************************************** + ** Apply the same durable action service as the authenticated HTTP adapter. + ***************************************************************************/ + public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final ProcessSummaryLine applied = new ProcessSummaryLine(Status.OK, 0, null) + .withSingularPastMessage("server control action was applied; device observation is shown separately") + .withPluralPastMessage("server control actions were applied; device observations are shown separately"); + private final ProcessSummaryLine pending = new ProcessSummaryLine(Status.WARNING, 0, null) + .withSingularPastMessage("agent request is pending; no remote execution is confirmed") + .withPluralPastMessage("agent requests are pending; no remote execution is confirmed"); + private final ProcessSummaryLine unresolved = new ProcessSummaryLine(Status.WARNING, 0, null) + .withSingularPastMessage("request did not complete; inspect command history before trying another request") + .withPluralPastMessage("requests did not complete; inspect command history before trying another request"); + + + + /************************************************************************ + ** Recheck current scope and consent immediately before each mutation. + ************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + actor(input); + Action action = action(input); + if(action.confirmation && !Boolean.TRUE.equals(input.getValueBoolean(CONFIRM))) + { + throw new QException("Confirm the reviewed action before continuing"); + } + String requestId = input.getValueString(REQUEST_ID); + String issuedAt = input.getValueString(ISSUED_AT); + if(requestId == null || requestId.isBlank() || issuedAt == null || issuedAt.isBlank()) + { + throw new QException("Restart this action to obtain a stable control request identity"); + } + for(QRecord selected : input.getRecords()) + { + QRecord current = resolve(input, selected.getValueString("id")); + String deviceId = current.getValueString(action.job ? "device_id" : "id"); + JSONObject request = new JSONObject().put("requestId", action.job ? requestId + "-" + current.getValueString("id") : requestId) + .put("issuedAt", issuedAt).put("action", action.wire); + if(action.job) + { + request.put("jobId", current.getValueString("id")); + } + try + { + JSONObject result = PrintingControlAuthorization.deviceAction(deviceId, request); + String status = result.getString("status"); + ProcessSummaryLine summary = "PENDING".equals(status) ? pending : ("SUCCEEDED".equals(status) ? applied : unresolved); + summary.incrementCountAndAddPrimaryKey(current.getValue("id")); + output.getRecords().add(current); + } + catch(Exception exception) + { + throw new QException("Printing control could not be completed. Review command history before submitting a new action.", exception); + } + } + } + + + + /************************************************************************ + ** Pending requests are never presented as completed Windows operations. + ************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + return (new ArrayList<>(List.of(applied, pending, unresolved))); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceStatus.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceStatus.java new file mode 100644 index 0000000..f732e03 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceStatus.java @@ -0,0 +1,179 @@ +/* + * 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.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Presence and control application are observations, separate from desired state. + *******************************************************************************/ +public class PrintingDeviceStatus implements TableCustomizerInterface +{ + private static final Set DEPENDENCIES = Set.of("id", "enabled", "connection_state", "last_seen_at", "control_last_poll_at", "dispatch_paused", "control_version", "control_revision", "control_applied_revision"); + + + + /*************************************************************************** + ** Query/Get already applied native user permissions and tenant row security. + ***************************************************************************/ + @Override + public List postQuery(QueryOrGetInputInterface input, List records) throws QException + { + Set selected = input instanceof QueryInput query ? query.getFieldNamesToInclude() : null; + if(selected != null && !selected.contains("device_status") && !selected.contains("control_status")) + { + return (records); + } + if(selected != null && !selected.contains("id")) + { + throw new QException("Select id together with derived device status fields"); + } + Map dependencies = dependencies(records); + Instant now = Instant.now(); + for(QRecord record : records) + { + QRecord values = dependencies.getOrDefault(record.getValueString("id"), record); + if(selected == null || selected.contains("device_status")) + { + record.setValue("device_status", presence(values, now)); + } + if(selected == null || selected.contains("control_status")) + { + record.setValue("control_status", application(values)); + } + } + return (records); + } + + + + /*************************************************************************** + ** Visible-column projections still need authoritative inputs. Fetch bounded + ** batches through the same native permissions, never raw SQL or one query + ** per row. Dependency-only queries do not invoke this enrichment again. + ***************************************************************************/ + private static Map dependencies(List records) throws QException + { + ArrayList ids = new ArrayList<>(); + for(QRecord record : records) + { + if(!record.getValues().keySet().containsAll(DEPENDENCIES) && record.getValueString("id") != null) + { + ids.add(record.getValueString("id")); + } + } + Map result = new HashMap<>(); + for(int first = 0; first < ids.size(); first += 200) + { + List page = ids.subList(first, Math.min(first + 200, ids.size())); + QueryInput input = new QueryInput().withTableName(PrintingTables.DEVICE).withFieldNamesToInclude(DEPENDENCIES) + .withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, page).withLimit(page.size())); + input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE); + for(QRecord row : new QueryAction().execute(input).getRecords()) + { + result.put(row.getValueString("id"), row); + } + } + return (result); + } + + + + /*************************************************************************** + ** A recently seen control poll still proves liveness while printing is paused. + ***************************************************************************/ + static String presence(QRecord record, Instant now) + { + if(record.getValueInteger("enabled") != null && record.getValueInteger("enabled") == 0) + { + return ("REVOKED"); + } + if("DISCONNECTED".equals(record.getValueString("connection_state"))) + { + return ("DISCONNECTED"); + } + Instant heartbeat = timestamp(record.getValueString("last_seen_at")); + Instant control = timestamp(record.getValueString("control_last_poll_at")); + Instant seen = heartbeat == null || (control != null && control.isAfter(heartbeat)) ? control : heartbeat; + if(seen == null || seen.isAfter(now.plusSeconds(30))) + { + return ("UNKNOWN"); + } + if(seen.isBefore(now.minusSeconds(90))) + { + return ("OFFLINE"); + } + return (Integer.valueOf(1).equals(record.getValueInteger("dispatch_paused")) ? "PAUSED" : "ONLINE"); + } + + + + /*************************************************************************** + ** A server-side successful action is not proof that the agent applied it. + ***************************************************************************/ + static String application(QRecord record) + { + if(!Integer.valueOf(1).equals(record.getValueInteger("control_version"))) + { + return ("UNKNOWN"); + } + Long desired = record.getValueLong("control_revision"); + Long observed = record.getValueLong("control_applied_revision"); + return (desired != null && observed != null && desired.equals(observed) ? "APPLIED" : "PENDING"); + } + + + + /*************************************************************************** + ** Bad observations must remain unknown rather than imply a healthy device. + ***************************************************************************/ + private static Instant timestamp(String value) + { + if(value == null || value.isBlank()) + { + return (null); + } + try + { + return (Instant.parse(value)); + } + catch(DateTimeParseException exception) + { + return (null); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingFacilities.java b/src/main/java/com/kingsrook/qbits/printing/PrintingFacilities.java index cc21de1..59f64ec 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingFacilities.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingFacilities.java @@ -22,6 +22,7 @@ package com.kingsrook.qbits.printing; import java.util.List; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -95,12 +96,14 @@ final class PrintingFacilities throw new QException("Select an available facility"); } String source = QContext.getQInstance().getTable(PrintingTables.DEVICE).getField("facility_id").getPossibleValueSourceName(); - var options = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput() - .withPossibleValueSourceName(source).withIdList(List.of(selected))); + SearchPossibleValueSourceInput input = new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(source).withIdList(List.of(selected)); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + var options = new SearchPossibleValueSourceAction().execute(input); if(options.getResults() == null || options.getResults().stream().noneMatch(option -> selected.equals(String.valueOf(option.getId())))) { throw new QException("The selected facility is unavailable or is not accessible to this user"); } return (selected); } -} \ No newline at end of file +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java b/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java index ca4ac0e..6425b7a 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java @@ -88,6 +88,7 @@ public class PrintingHome extends AbstractWidgetRenderer add(instance, app, "printingHomePrinters", "Printers needing attention", WidgetType.STATISTICS, "systemHealth", 21, 3); add(instance, app, "printingHomeFailures", "Failed or uncertain jobs", WidgetType.STATISTICS, "systemHealth", 22, 3); add(instance, app, "printingHomePending", "Pending print jobs", WidgetType.STATISTICS, "systemHealth", 23, 3); + add(instance, app, "printingHomeControls", "Pending device commands", WidgetType.STATISTICS, "systemHealth", 24, 12); add(instance, app, "printingHomeWork", "Pending and recent print work", WidgetType.TABLE, "queue", 30, 12); } @@ -128,10 +129,12 @@ public class PrintingHome extends AbstractWidgetRenderer { case "printingHomeHeader" -> header(); case "printingHomeAttention" -> attention(now); - case "printingHomeOffline" -> statistic(DEVICE, offline(now), "Devices · now · target 0 without heartbeat for 90 seconds"); + case "printingHomeOffline" -> statistic(DEVICE, offline(now), "Enabled devices · now · disconnected by an administrator or unseen for 90 seconds"); case "printingHomePrinters" -> statistic(PRINTER, printerIssues(), "Enabled printers · now · target 0 needing attention"); case "printingHomeFailures" -> statistic(JOB, failures(now), "Jobs created in last 24 hours · target 0 failed or uncertain"); case "printingHomePending" -> statistic(JOB, statuses(PENDING), "Jobs · now · target queue drains after printing"); + case "printingHomeControls" -> statistic(PrintingTables.COMMAND, statuses(List.of("PENDING")) + .withCriteria("expires_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.toString()), "Device commands · now · awaiting an agent receipt; not confirmed applied"); case "printingHomeWork" -> work(now); default -> throw new QException("Unknown printing home widget"); }; @@ -167,13 +170,27 @@ public class PrintingHome extends AbstractWidgetRenderer private static TableData attention(Instant now) throws QException { List> rows = new ArrayList<>(); - for(QRecord job : query(JOB, failures(now).withOrderBy(new QFilterOrderBy("created_at", true)).withLimit(10))) + for(QRecord job : query(JOB, failures(now).withOrderBy(new QFilterOrderBy("created_at", false)).withLimit(10))) { rows.add(issue(JOB, job, job.getValueString("title"), job.getValueString("status"), "Check the local queue and physical output before retrying an uncertain job.", "created_at")); } for(QRecord device : query(DEVICE, offline(now).withOrderBy(new QFilterOrderBy("last_seen_at", true)).withLimit(10))) { - rows.add(issue(DEVICE, device, device.getValueString("name"), "No heartbeat for 90 seconds", "Open the Windows agent and check its service and outbound connection.", "last_seen_at")); + boolean disconnected = "DISCONNECTED".equals(device.getValueString("connection_state")); + rows.add(issue(DEVICE, device, device.getValueString("name"), disconnected ? "Administratively disconnected" : "No connection for 90 seconds", + disconnected ? "Open the device and use Reconnect; review its observed control revision." : "Open the Windows agent and check its service, sign-in and outbound connection.", "last_seen_at")); + } + for(QRecord device : query(DEVICE, new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1) + .withCriteria("dispatch_paused", QCriteriaOperator.EQUALS, 1).withOrderBy(new QFilterOrderBy("name")).withLimit(10))) + { + rows.add(issue(DEVICE, device, device.getValueString("name"), "Dispatch paused", "Open the device queue, then use Resume dispatch when it is ready.", "control_last_poll_at")); + } + for(QRecord command : query(PrintingTables.COMMAND, statuses(List.of("PENDING", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN")) + .withCriteria("created_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400).toString()) + .withOrderBy(new QFilterOrderBy("created_at", false)).withLimit(10))) + { + rows.add(issue(PrintingTables.COMMAND, command, command.getValueString("action"), command.getValueString("command_status"), + "Review command history and the device connection before submitting another request.", "created_at")); } for(QRecord printer : query(PRINTER, printerIssues().withOrderBy(new QFilterOrderBy("last_seen_at", true)).withLimit(10))) { @@ -225,7 +242,9 @@ public class PrintingHome extends AbstractWidgetRenderer ***************************************************************************/ private static List query(String table, QQueryFilter filter) throws QException { - return (new QueryAction().execute(new QueryInput().withTableName(table).withFilter(filter)).getRecords()); + QueryInput input = new QueryInput().withTableName(table).withFilter(filter); + input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE); + return (new QueryAction().execute(input).getRecords()); } @@ -235,17 +254,33 @@ public class PrintingHome extends AbstractWidgetRenderer ***************************************************************************/ private static int count(String table, QQueryFilter filter) throws QException { - return (new CountAction().execute(new CountInput(table).withFilter(filter)).getCount()); + CountInput input = new CountInput(table).withFilter(filter); + input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE); + return (new CountAction().execute(input).getCount()); } /*************************************************************************** - ** A heartbeat older than 90 seconds means disconnected, not printer failure. + ** Either authenticated connection proves liveness; requested disconnect stays visible. ***************************************************************************/ private static QQueryFilter offline(Instant now) { - return (new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withCriteria("last_seen_at", QCriteriaOperator.LESS_THAN, now.minusSeconds(90).toString())); + QQueryFilter unseen = new QQueryFilter().withSubFilter(stale("last_seen_at", now)).withSubFilter(stale("control_last_poll_at", now)); + QQueryFilter disconnected = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) + .withCriteria("connection_state", QCriteriaOperator.EQUALS, "DISCONNECTED").withSubFilter(unseen); + return (new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(disconnected)); + } + + + + /*************************************************************************** + ** Legacy devices may never have sent a control poll. + ***************************************************************************/ + private static QQueryFilter stale(String field, Instant now) + { + return (new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) + .withCriteria(field, QCriteriaOperator.IS_BLANK).withCriteria(field, QCriteriaOperator.LESS_THAN, now.minusSeconds(90).toString())); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java index 2ee8db0..eae78b9 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java @@ -124,6 +124,7 @@ public class PrintingJobProcesses static QRecord resolve(RunBackendStepInput input, String id) throws QException { GetInput get = new GetInput(); + get.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE); get.setTableName(PrintingTables.JOB); get.setPrimaryKey(id); QRecord job = new GetAction().execute(get).getRecord(); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java index 00be85d..418e54c 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java @@ -293,6 +293,7 @@ public class PrintingProcesses implements BackendStep static QRecord resolvePrinter(String id, String deviceId) throws QException { GetInput get = new GetInput(); + get.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE); get.setTableName(PrintingTables.PRINTER); get.setPrimaryKey(id); QRecord printer = new GetAction().execute(get).getRecord(); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java index d4b10b0..ca31f1f 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java @@ -22,11 +22,20 @@ package com.kingsrook.qbits.printing; import java.util.List; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; @@ -37,7 +46,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer; import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Association; import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; +import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; @@ -85,12 +96,33 @@ public class PrintingQBitProducer implements QBitProducer instance.addSecurityKeyType(new QSecurityKeyType().withName("printing_tenant")); QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at")); devices.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Enabled").withPossibleValueSourceName("printing_enabled")); + devices.withField(new QFieldMetaData("dispatch_paused", QFieldType.INTEGER).withLabel("Printing paused").withPossibleValueSourceName("printing_paused")); + devices.withField(new QFieldMetaData("connection_state", QFieldType.STRING).withLabel("Requested connection").withPossibleValueSourceName("printing_connectionState")); + devices.withField(new QFieldMetaData("control_revision", QFieldType.LONG).withLabel("Requested control revision")); + devices.withField(new QFieldMetaData("control_applied_revision", QFieldType.LONG).withLabel("Observed control revision")); + devices.withField(new QFieldMetaData("control_version", QFieldType.INTEGER).withLabel("Agent control version")); + devices.withField(new QFieldMetaData("control_capabilities", QFieldType.STRING).withLabel("Agent control capabilities")); + devices.withField(new QFieldMetaData("control_last_poll_at", QFieldType.STRING).withLabel("Last control connection")); + devices.withField(new QFieldMetaData("revoked_at", QFieldType.STRING).withLabel("Access revoked at")); + devices.withVirtualField(new QVirtualFieldMetaData("device_status", QFieldType.STRING).withLabel("Device status").withPossibleValueSourceName("printing_deviceStatus").withIsQuerySelectable(true)); + devices.withVirtualField(new QVirtualFieldMetaData("control_status", QFieldType.STRING).withLabel("Control application").withPossibleValueSourceName("printing_controlApplication").withIsQuerySelectable(true)); + devices.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PrintingDeviceStatus.class)); devices.getField("facility_id").withLabel("Facility").withPossibleValueSourceName(PrintingFacilities.register(instance, config)); QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at")); printers.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Approved for printing").withPossibleValueSourceName("printing_approved")); printers.withField(new QFieldMetaData("available", QFieldType.INTEGER).withLabel("Exposed by device").withPossibleValueSourceName("printing_available")); QTableMetaData jobs = table(PrintingTables.JOB, "Print jobs", List.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "options_json", "idempotency_key", "created_at", "status", "message", "spooler_job_id")); jobs.withField(new QFieldMetaData("content_length", QFieldType.INTEGER).withLabel("Document size (bytes)")); + QTableMetaData commands = table(PrintingTables.COMMAND, "Device control history", List.of("id", "tenant_id", "device_id", "request_id", "action", "job_id", "actor", "status", "created_at", "expires_at", "result_code")); + commands.withField(new QFieldMetaData("cancelled_job_count", QFieldType.INTEGER).withLabel("Queued jobs canceled")); + commands.getField("device_id").withLabel("Print device").withPossibleValueSourceName(PrintingTables.DEVICE); + commands.getField("job_id").withLabel("Print job").withPossibleValueSourceName(PrintingTables.JOB); + commands.getField("action").withPossibleValueSourceName("printing_controlAction"); + commands.getField("status").withLabel("Recorded status").withPossibleValueSourceName("printing_commandStatus"); + commands.withVirtualField(new QVirtualFieldMetaData("command_status", QFieldType.STRING).withLabel("Current status") + .withPossibleValueSourceName("printing_commandStatus").withIsQuerySelectable(true)); + commands.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PrintingCommandStatus.class)); + commands.withRecordLabelFormatAndFields("%s", "action"); jobs.getField("printer_row_id").withLabel("Printer").withPossibleValueSourceName(PrintingTables.PRINTER); jobs.getField("format").withPossibleValueSourceName("printing_format"); jobs.getField("status").withPossibleValueSourceName("printing_jobStatus"); @@ -108,6 +140,12 @@ public class PrintingQBitProducer implements QBitProducer binaryValues(instance, "printing_enabled", "Disabled", "Enabled"); binaryValues(instance, "printing_approved", "Not approved", "Approved"); binaryValues(instance, "printing_available", "Not exposed", "Exposed"); + binaryValues(instance, "printing_paused", "Running", "Paused"); + enumValues(instance, "printing_connectionState", List.of("CONNECTED", "DISCONNECTED")); + enumValues(instance, "printing_deviceStatus", List.of("ONLINE", "OFFLINE", "PAUSED", "DISCONNECTED", "REVOKED", "UNKNOWN")); + enumValues(instance, "printing_controlApplication", List.of("APPLIED", "PENDING", "UNKNOWN")); + enumValues(instance, "printing_commandStatus", List.of("PENDING", "SUCCEEDED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN", "UNKNOWN")); + enumValues(instance, "printing_controlAction", List.of("PAUSE_DISPATCH", "RESUME_DISPATCH", "DISCONNECT", "RECONNECT", "REVOKE", "REFRESH_INVENTORY", "CLEAR_QUEUE", "CANCEL_SPOOL")); enumValues(instance, "printing_format", List.of("PDF", "ZPL")); enumValues(instance, "printing_jobStatus", List.of("QUEUED", "CLAIMED", "RECEIVED", "SUBMITTING", "SPOOLED", "SPOOLER_COMPLETED", "FAILED", "OUTCOME_UNKNOWN")); enumValues(instance, "printing_printerStatus", List.of("READY", "OFFLINE", "PAUSED", "ERROR", "UNKNOWN")); @@ -119,15 +157,24 @@ public class PrintingQBitProducer implements QBitProducer printers.getField("device_id").withLabel("Print device"); instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.DEVICE)); instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.PRINTER)); - layout(devices, List.of("name", "location", "description", "facility_id", "enabled", "last_seen_at")); + instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.JOB)); + layout(devices, List.of("name", "device_status", "control_status", "location", "facility_id", "last_seen_at")); layout(printers, List.of("name", "location", "description", "device_id", "status", "enabled", "available", "last_seen_at")); layout(jobs, List.of("title", "device_id", "printer_row_id", "status", "created_at", "format")); + layout(commands, List.of("device_id", "action", "command_status", "created_at", "expires_at", "result_code", "cancelled_job_count")); instance.addTable(devices); instance.addTable(printers); instance.addTable(jobs); - QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs); + instance.addTable(commands); + related(instance, devices, printers, "device_id", "Printers"); + related(instance, devices, jobs, "device_id", "Jobs and queue"); + related(instance, devices, commands, "device_id", "Control history"); + related(instance, printers, jobs, "printer_row_id", "Jobs and queue"); + QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs).withChild(commands); app.withPermissionRules(PrintingProcesses.permissionRules()); PrintingProcesses.register(instance, app); + PrintingDeviceProcesses.register(instance); + PrintingUiApi.register(instance); PrintingRetentionProcesses.register(instance, config); PrintingHome.register(instance, app); instance.addApp(app); @@ -140,6 +187,31 @@ public class PrintingQBitProducer implements QBitProducer + /*************************************************************************** + ** Native related lists open the current device's work without raw-ID input. + ***************************************************************************/ + private static void related(QInstance instance, QTableMetaData parent, QTableMetaData child, String childField, String label) + { + QJoinMetaData join = new QJoinMetaData().withLeftTable(parent.getName()).withRightTable(child.getName()) + .withType(JoinType.ONE_TO_MANY).withJoinOn(new JoinOn("id", childField)).withInferredName(); + instance.addJoin(join); + parent.withAssociation(new Association().withName(child.getName()).withAssociatedTableName(child.getName()).withJoinName(join.getName())); + parent.withExposedJoin(new ExposedJoin().withJoinTable(child.getName()).withJoinPath(List.of(join.getName())).withLabel(label)); + instance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(join).withLabel(label).withCanAddChildRecord(false).withMaxRows(50).getWidgetMetaData()); + parent.withSection(new QFieldSection(join.getName(), label, new QIcon("list"), Tier.T2, null).withWidgetName(join.getName())); + if(PrintingTables.JOB.equals(child.getName())) + { + String queueWidget = parent.getName() + "_queue"; + var widget = ChildRecordListRenderer.widgetMetaDataBuilder(join).withLabel("Current print queue").withCanAddChildRecord(false).withMaxRows(50) + .withBaseFilter(new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, List.of("QUEUED", "CLAIMED", "RECEIVED", "SUBMITTING"))) + .withOrderBys(List.of(new QFilterOrderBy("created_at", true))).getWidgetMetaData().withName(queueWidget); + instance.addWidget(widget); + parent.withSection(new QFieldSection(queueWidget, "Current print queue", new QIcon("pending_actions"), Tier.T2, null).withWidgetName(queueWidget)); + } + } + + + /*************************************************************************** ** Read the build-produced JAR manifest; IDE/test class directories identify themselves honestly. ***************************************************************************/ @@ -203,10 +275,17 @@ public class PrintingQBitProducer implements QBitProducer { table.getField("tenant_id").withIsHidden(true); java.util.LinkedHashMap ordered = new java.util.LinkedHashMap<>(); - primary.forEach(name -> ordered.put(name, table.getField(name))); + primary.stream().filter(table.getFields()::containsKey).forEach(name -> ordered.put(name, table.getField(name))); ordered.putAll(table.getFields()); table.setFields(ordered); - table.withSection(new QFieldSection(table.getName() + "_summary", "Summary", new QIcon("print"), Tier.T1, primary)); + QFieldSection summary = new QFieldSection(table.getName() + "_summary", "Summary", new QIcon("print"), Tier.T1, + primary.stream().filter(table.getFields()::containsKey).toList()); + if(table.getVirtualFields() != null && primary.stream().anyMatch(table.getVirtualFields()::containsKey)) + { + summary.withAlternative(com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSectionAlternativeType.RECORD_VIEW, + section -> section.setFieldNames(primary)); + } + table.withSection(summary); List details = table.getFields().keySet().stream().filter(name -> !primary.contains(name) && !Boolean.TRUE.equals(table.getField(name).getIsHidden())).toList(); table.withSection(new QFieldSection(table.getName() + "_details", "Details", new QIcon("info"), Tier.T3, details)); table.withSupplementalMetaData(new DefaultColumns(primary.stream().filter(name -> !"description".equals(name)).toList())); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java index fb3dcd6..655e190 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java @@ -69,6 +69,25 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface @Override public void acceptJavalinConfig(JavalinConfig config) { + config.routes.get(BASE + "/version", context -> handle(context, () -> new JSONObject().put("protocolVersion", 1).put("capabilities", new org.json.JSONArray().put("device-control-v1")))); + config.routes.post(BASE + "/device/control/poll", context -> handle(context, () -> service.deviceControlPoll(device(context), controlBody(context)))); + config.routes.post(BASE + "/device/control/commands/{commandId}/receipt", context -> handle(context, () -> service.deviceControlReceipt(device(context), context.pathParam("commandId"), controlBody(context)))); + config.routes.post(BASE + "/admin/devices/{deviceId}/actions", context -> handle(context, () -> + { + admin(context); + return service.deviceAction(context.pathParam("deviceId"), controlBody(context), "http-administrator"); + })); + config.routes.get(BASE + "/admin/devices/{deviceId}/commands", context -> handle(context, () -> + { + admin(context); + String limit = context.queryParam("limit"); + return service.deviceCommandsPage(context.pathParam("deviceId"), limit == null ? 50 : Integer.parseInt(limit), context.queryParam("before")); + })); + config.routes.get(BASE + "/admin/devices/{deviceId}/commands/{commandId}", context -> handle(context, () -> + { + admin(context); + return service.getDeviceCommand(context.pathParam("deviceId"), context.pathParam("commandId")); + })); config.routes.post(BASE + "/enroll", context -> handle(context, () -> service.enroll(body(context)))); config.routes.put(BASE + "/device/printers", context -> handle(context, () -> { @@ -195,8 +214,19 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface /*************************************************************************** - ** Standard mutation acknowledgment. + ** Control messages have their own small transport bound. ***************************************************************************/ + private static JSONObject controlBody(Context context) + { + if(context.bodyAsBytes().length > 8192) + { + throw new PrintingException(413, "Control request is too large"); + } + return (new JSONObject(context.body())); + } + + + /** Standard mutation acknowledgment. */ private static JSONObject accepted() { return (new JSONObject().put("accepted", true)); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index 7da7652..6009811 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -178,6 +178,8 @@ public class PrintingService } } PrintingAdmission.initialize(connection, tenantId); + PrintingDeviceControls.initialize(connection); + update(connection, "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS bound_user_id VARCHAR(200)"); widenColumn(connection, "printing_device", "name", 1024); widenColumn(connection, "printing_enrollment", "device_name", 1024); widenColumn(connection, "printing_printer", "name", 1024); @@ -221,8 +223,81 @@ public class PrintingService /*************************************************************************** - ** The immutable tenant scope used by host authorization and native actions. + ** Apply one audited action after the adapter authorizes the current actor. ***************************************************************************/ + public JSONObject deviceAction(String deviceId, JSONObject request, String actor) throws SQLException + { + return (new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor)); + } + + + /** Native authorization supplies the exact reviewed queue identity set, never a predicate executed inside SQL locks. */ + JSONObject deviceAction(String deviceId, JSONObject request, String actor, Set authorizedQueue) throws SQLException + { + return (new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor, authorizedQueue)); + } + + + /** Resolve a retained action receipt without treating new queue contents as part of a replay. */ + String existingDeviceAction(String deviceId, String requestId) throws SQLException + { + try(Connection connection = dataSource.getConnection()) + { + List rows = query(connection, "SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenantId, deviceId, requestId); + return (rows.isEmpty() ? null : rows.getFirst().getString("id")); + } + } + + + /** Bounded identifiers permit native row authorization before bulk queue cancellation. */ + Set queuedJobIdsForControl(String deviceId) throws SQLException + { + try(Connection connection = dataSource.getConnection()) + { + requireDevice(connection, deviceId); + Set ids = new java.util.HashSet<>(); + for(JSONObject row : query(connection, "SELECT id FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' LIMIT 10001", tenantId, deviceId)) + { + ids.add(row.getString("id")); + } + if(ids.size() > 10000) + { + throw new PrintingException(429, "The queue exceeds the bounded native review limit"); + } + return (ids); + } + } + + + /** Independently poll desired state and pending commands using device authentication. */ + public JSONObject deviceControlPoll(String deviceId, JSONObject request) throws SQLException + { + return (new PrintingDeviceControls(dataSource, tenantId).poll(deviceId, request)); + } + + + /** Persist one immutable device observation without inferring physical output. */ + public JSONObject deviceControlReceipt(String deviceId, String commandId, JSONObject receipt) throws SQLException + { + return (new PrintingDeviceControls(dataSource, tenantId).receipt(deviceId, commandId, receipt)); + } + + + /** Tenant/device scoped history remains visible after credential revocation. */ + public JSONObject deviceCommandsPage(String deviceId, int limit, String before) throws SQLException + { + return (new PrintingDeviceControls(dataSource, tenantId).page(deviceId, limit, before)); + } + + + /** Read the exact durable receipt for a native or HTTP management action. */ + public JSONObject getDeviceCommand(String deviceId, String commandId) throws SQLException + { + return (new PrintingDeviceControls(dataSource, tenantId).get(deviceId, commandId)); + } + + + /** The immutable tenant scope used by host authorization and native actions. */ public String getTenantId() { return (tenantId); @@ -337,6 +412,32 @@ public class PrintingService ***************************************************************************/ public JSONObject enroll(JSONObject request) throws SQLException { + return (enrollForUser(request, null)); + } + + + /** Native enrollment checks facility visibility before consuming the immutable enrollment code. */ + String enrollmentFacility(String code) throws SQLException + { + try(Connection connection = dataSource.getConnection()) + { + List rows = query(connection, "SELECT facility_id FROM printing_enrollment WHERE tenant_id=? AND code_hash=? AND consumed=0 AND expires_at>?", tenantId, hash(required(code, "enrollmentCode")), Instant.now().toString()); + if(rows.isEmpty()) + { + throw new PrintingException(401, "Enrollment code is invalid or expired"); + } + return (rows.getFirst().getString("facility_id")); + } + } + + + /** Native enrollment binds the core-authenticated principal in the credential transaction. */ + public JSONObject enrollForUser(JSONObject request, String principalId) throws SQLException + { + if(principalId != null && (principalId.isBlank() || principalId.length() > 200)) + { + throw new PrintingException(403, "Invalid authenticated principal"); + } String codeHash = hash(required(request.optString("enrollmentCode"), "enrollmentCode")); try(Connection connection = dataSource.getConnection()) { @@ -352,7 +453,7 @@ public class PrintingService String id = UUID.randomUUID().toString(); String token = secret(); String computerName = required(request.optString("deviceName"), "deviceName"); - update(connection, "INSERT INTO printing_device (id, tenant_id, facility_id, name, agent_version, token_hash, last_seen_at, enabled, computer_name) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)", id, tenantId, code.getString("facility_id"), computerName, required(request.optString("agentVersion"), "agentVersion"), hash(token), Instant.now().toString(), computerName); + update(connection, "INSERT INTO printing_device (id, tenant_id, facility_id, name, agent_version, token_hash, last_seen_at, enabled, computer_name, bound_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)", id, tenantId, code.getString("facility_id"), computerName, required(request.optString("agentVersion"), "agentVersion"), hash(token), Instant.now().toString(), computerName, principalId); update(connection, "UPDATE printing_enrollment SET consumed=1 WHERE code_hash=?", codeHash); connection.commit(); return (new JSONObject().put("protocolVersion", 1).put("deviceId", id).put("deviceToken", token).put("pollIntervalSeconds", 5)); @@ -374,7 +475,7 @@ public class PrintingService { try(Connection connection = dataSource.getConnection()) { - List devices = query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND token_hash=? AND enabled=1", tenantId, hash(token == null ? "" : token)); + List devices = query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND token_hash=? AND enabled=1 AND revoked_at IS NULL", tenantId, hash(token == null ? "" : token)); if(devices.isEmpty()) { throw new PrintingException(401, "Invalid device credential"); @@ -384,6 +485,43 @@ public class PrintingService } + /** Secondary device proof never replaces the caller's native QQQ authentication and row permission. */ + public void authorizeAgent(String deviceId, String token, String principalId, boolean bind) throws SQLException + { + if(deviceId == null || token == null || token.length() > 128 || principalId == null || principalId.isBlank() || principalId.length() > 200) + { + throw new PrintingException(403, "User or device is not authorized"); + } + try(Connection connection = dataSource.getConnection()) + { + connection.setAutoCommit(false); + try + { + List rows = query(connection, "SELECT token_hash,bound_user_id FROM printing_device WHERE tenant_id=? AND id=? AND enabled=1 AND revoked_at IS NULL FOR UPDATE", tenantId, deviceId); + if(rows.isEmpty() || !MessageDigest.isEqual(rows.getFirst().getString("token_hash").getBytes(StandardCharsets.UTF_8), hash(token).getBytes(StandardCharsets.UTF_8))) + { + throw new PrintingException(403, "User or device is not authorized"); + } + String owner = rows.getFirst().optString("bound_user_id", null); + if(owner == null && bind) + { + update(connection, "UPDATE printing_device SET bound_user_id=? WHERE tenant_id=? AND id=? AND bound_user_id IS NULL", principalId, tenantId, deviceId); + } + else if(!principalId.equals(owner)) + { + throw new PrintingException(403, "User or device is not authorized"); + } + connection.commit(); + } + catch(Exception exception) + { + connection.rollback(); + throw exception; + } + } + } + + /*************************************************************************** ** Record reachability; queue/hardware health remains a separate observation. @@ -396,6 +534,8 @@ public class PrintingService connection.setAutoCommit(false); try { + requireDevice(connection, deviceId); + query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); requireDevice(connection, deviceId); update(connection, "UPDATE printing_device SET last_seen_at=?, agent_version=? WHERE id=?", now, required(request.optString("agentVersion"), "agentVersion"), deviceId); JSONObject profile = request.optJSONObject("profile"); @@ -424,6 +564,13 @@ public class PrintingService ** Replace observed inventory while retaining administrator approval. ***************************************************************************/ public void inventory(String deviceId, JSONArray printers) throws SQLException + { + inventory(deviceId, printers, null); + } + + + /** Native inventory changes exactly the existing row set authorized before opening this transaction. */ + void inventory(String deviceId, JSONArray printers, Set authorizedRows) throws SQLException { if(printers.length() > 200) { @@ -439,6 +586,19 @@ public class PrintingService PrintingAdmission.lock(connection, tenantId); requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); + requireDevice(connection, deviceId); + if(authorizedRows != null) + { + Set currentRows = new java.util.HashSet<>(); + for(JSONObject row : query(connection, "SELECT id FROM printing_printer WHERE tenant_id=? AND device_id=? LIMIT 10001", tenantId, deviceId)) + { + currentRows.add(row.getString("id")); + } + if(!currentRows.equals(authorizedRows)) + { + throw new PrintingException(409, "Inventory changed; authorize its current rows before retrying"); + } + } Set incoming = new java.util.HashSet<>(); for(int index = 0; index < printers.length(); index++) { @@ -578,6 +738,7 @@ public class PrintingService PrintingAdmission.lock(connection, tenantId); requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); + requireDevice(connection, deviceId); List previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key); if(!previous.isEmpty()) { @@ -625,6 +786,7 @@ public class PrintingService } requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); + requireDevice(connection, deviceId); printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1", deviceId, printerId); if(printers.isEmpty() || !new JSONArray(printers.getFirst().getString("formats")).toList().contains(format)) { @@ -807,6 +969,7 @@ public class PrintingService { requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); + requireDevice(connection, deviceId); JSONObject row = deviceJob(connection, deviceId, jobId); if("FAILED".equals(row.getString("status")) && CANCELED_BEFORE_DISPATCH.equals(row.optString("message"))) { @@ -835,6 +998,13 @@ public class PrintingService ** Claim one job; replay the same assignment until the device advances it. ***************************************************************************/ public JSONObject claim(String deviceId) throws SQLException + { + return (claimForJob(deviceId, null)); + } + + + /** Native adapters authorize one exact job and printer before this locked dispatch transition. */ + public JSONObject claimForJob(String deviceId, String authorizedJobId) throws SQLException { try(Connection connection = dataSource.getConnection()) { @@ -843,7 +1013,14 @@ public class PrintingService { requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); - List candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') AND ((j.format='PDF' AND p.formats LIKE '%\"PDF\"%') OR (j.format='ZPL' AND p.formats LIKE '%\"ZPL\"%')) ORDER BY j.created_at, j.id LIMIT 1", tenantId, deviceId); + requireDevice(connection, deviceId); + JSONObject control = query(connection, "SELECT dispatch_paused,connection_state FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst(); + if(control.getInt("dispatch_paused") == 1 || !"CONNECTED".equals(control.getString("connection_state"))) + { + connection.commit(); + return (new JSONObject().put("job", JSONObject.NULL)); + } + List candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') AND ((j.format='PDF' AND p.formats LIKE '%\"PDF\"%') OR (j.format='ZPL' AND p.formats LIKE '%\"ZPL\"%'))" + (authorizedJobId == null ? "" : " AND j.id=?") + " ORDER BY j.created_at, j.id LIMIT 1", authorizedJobId == null ? new Object[] { tenantId, deviceId } : new Object[] { tenantId, deviceId, authorizedJobId }); if(candidates.isEmpty()) { connection.commit(); @@ -914,6 +1091,8 @@ public class PrintingService connection.setAutoCommit(false); try { + requireDevice(connection, deviceId); + query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); requireDevice(connection, deviceId); deviceJob(connection, deviceId, jobId); query(connection, "SELECT id FROM printing_job WHERE id=? FOR UPDATE", jobId); @@ -964,7 +1143,7 @@ public class PrintingService { result.put(new JSONObject().put("deviceId", row.getString("id")).put("facilityId", row.getString("facility_id")).put("deviceName", row.getString("name")).put("computerName", row.getString("computer_name")) .put("profile", new JSONObject().put("name", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location"))) - .put("agentVersion", row.getString("agent_version")).put("lastSeenAt", row.getString("last_seen_at")).put("enabled", row.getInt("enabled") == 1)); + .put("agentVersion", row.getString("agent_version")).put("lastSeenAt", row.getString("last_seen_at")).put("enabled", row.getInt("enabled") == 1).put("control", PrintingDeviceControls.state(row))); } return (result); } @@ -1205,7 +1384,7 @@ public class PrintingService ***************************************************************************/ private void requireDevice(Connection connection, String deviceId) throws SQLException { - if(query(connection, "SELECT id FROM printing_device WHERE id=? AND tenant_id=? AND enabled=1", deviceId, tenantId).isEmpty()) + if(query(connection, "SELECT id FROM printing_device WHERE id=? AND tenant_id=? AND enabled=1 AND revoked_at IS NULL", deviceId, tenantId).isEmpty()) { throw new PrintingException(404, "Device not found"); } @@ -1490,7 +1669,7 @@ public class PrintingService /*************************************************************************** ** Run a bounded, parameterized query with lower-case column names. ***************************************************************************/ - private static List query(Connection connection, String sql, Object... values) throws SQLException + static List query(Connection connection, String sql, Object... values) throws SQLException { try(PreparedStatement statement = connection.prepareStatement(sql)) { @@ -1517,7 +1696,7 @@ public class PrintingService /*************************************************************************** ** Execute a parameterized mutation. ***************************************************************************/ - private static int update(Connection connection, String sql, Object... values) throws SQLException + static int update(Connection connection, String sql, Object... values) throws SQLException { try(PreparedStatement statement = connection.prepareStatement(sql)) { diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTables.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTables.java index 5e6d5a9..e6438cc 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingTables.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTables.java @@ -29,6 +29,7 @@ public final class PrintingTables public static final String DEVICE = "printing_device"; public static final String PRINTER = "printing_printer"; public static final String JOB = "printing_job"; + public static final String COMMAND = "printing_device_command"; diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java new file mode 100644 index 0000000..864c9cd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java @@ -0,0 +1,295 @@ +/* + * 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.util.LinkedHashMap; +import java.util.Map; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData; +import com.kingsrook.qqq.openapi.model.HttpMethod; +import com.kingsrook.qqq.openapi.model.Response; +import com.kingsrook.qqq.openapi.model.Schema; +import com.kingsrook.qqq.openapi.model.Type; +import io.javalin.http.Context; + + +/** Native QQQ printing extension; core session authentication stays authoritative. */ +public final class PrintingUiApi +{ + public static final String PREFIX = "/qbit/printing"; + + /** Static registry. */ + private PrintingUiApi() + { + } + + /** Producer registration gives discovery, OpenAPI, session setup and credential scopes. */ + public static void register(QInstance instance) + { + MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension()); + } + + /** Semantic extension version 1.0.0 includes device-control-v1. */ + public static MiddlewareSpecExtension extension() + { + return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("1.0.0") + .withDescription("Native current-user printing with secondary device proof and device-control-v1.") + .withSpec(Session.class) + .withSpec(Bind.class) + .withSpec(Enroll.class) + .withSpec(Heartbeat.class) + .withSpec(Inventory.class) + .withSpec(Claim.class) + .withSpec(Queued.class) + .withSpec(Content.class) + .withSpec(Event.class) + .withSpec(Test.class) + .withSpec(Retry.class) + .withSpec(Cancel.class) + .withSpec(Poll.class) + .withSpec(Receipt.class) + .withSpec(Action.class) + .withSpec(Commands.class) + .withSpec(Command.class)); + } + + + /** Session native endpoint. */ + public static class Session extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Session() + { + super("Session", HttpMethod.GET, "/session", null, "Session"); + } + + } + + + /** Bind native endpoint. */ + public static class Bind extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Bind() + { + super("Bind", HttpMethod.POST, "/device/bind", "Empty", "Acknowledgment"); + } + + } + + + /** Enroll native endpoint. */ + public static class Enroll extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Enroll() + { + super("Enroll", HttpMethod.POST, "/devices/enroll", "EnrollmentRequest", "Enrollment"); + } + + } + + + /** Heartbeat native endpoint. */ + public static class Heartbeat extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Heartbeat() + { + super("Heartbeat", HttpMethod.POST, "/device/heartbeat", "HeartbeatRequest", "Heartbeat"); + } + + } + + + /** Inventory native endpoint. */ + public static class Inventory extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Inventory() + { + super("Inventory", HttpMethod.PUT, "/device/printers", "InventoryRequest", "Acknowledgment"); + } + + } + + + /** Claim native endpoint. */ + public static class Claim extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Claim() + { + super("Claim", HttpMethod.POST, "/device/jobs/claim", "Empty", "Claim"); + } + + } + + + /** Queued native endpoint. */ + public static class Queued extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Queued() + { + super("Queued", HttpMethod.GET, "/device/jobs/queued", null, "Jobs"); + } + + } + + + /** Content native endpoint. */ + public static class Content extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Content() + { + super("Content", HttpMethod.GET, "/device/jobs/{jobId}/content", null, "Empty"); + } + + /** Binary document bytes use an explicit octet-stream OpenAPI response. */ + @Override + public Map defineResponses() + { + Map responses = new LinkedHashMap<>(super.defineResponses()); + responses.put(200, new Response().withDescription("Verified retained document bytes") + .withContent(Map.of("application/octet-stream", new com.kingsrook.qqq.openapi.model.Content().withSchema(new Schema().withType(Type.STRING).withFormat("binary"))))); + return (responses); + } + + /** Do not JSON-serialize document contents or include them in logs. */ + @Override + public void handleOutput(Context context, JsonOutput output) + { + context.header("Cache-Control", "no-store").contentType("application/octet-stream").result((byte[]) output.getResult()); + } + } + + + /** Event native endpoint. */ + public static class Event extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Event() + { + super("Event", HttpMethod.POST, "/device/jobs/{jobId}/events", "EventRequest", "Acknowledgment"); + } + + } + + + /** Test native endpoint. */ + public static class Test extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Test() + { + super("Test", HttpMethod.POST, "/device/test-print", "TestRequest", "Job"); + } + + } + + + /** Retry native endpoint. */ + public static class Retry extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Retry() + { + super("Retry", HttpMethod.POST, "/device/jobs/{jobId}/retry", "RetryRequest", "Job"); + } + + } + + + /** Cancel native endpoint. */ + public static class Cancel extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Cancel() + { + super("Cancel", HttpMethod.POST, "/device/jobs/{jobId}/cancel", "Empty", "Acknowledgment"); + } + + } + + + /** Poll native endpoint. */ + public static class Poll extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Poll() + { + super("Poll", HttpMethod.POST, "/device/control/poll", "ControlPollRequest", "ControlPoll"); + } + + } + + + /** Receipt native endpoint. */ + public static class Receipt extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Receipt() + { + super("Receipt", HttpMethod.POST, "/device/control/commands/{commandId}/receipt", "ReceiptRequest", "Acknowledgment"); + } + + } + + + /** Action native endpoint. */ + public static class Action extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Action() + { + super("Action", HttpMethod.POST, "/admin/devices/{deviceId}/actions", "ActionRequest", "Action"); + } + + } + + + /** Commands native endpoint. */ + public static class Commands extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Commands() + { + super("Commands", HttpMethod.GET, "/admin/devices/{deviceId}/commands", null, "Actions"); + } + + } + + + /** Command native endpoint. */ + public static class Command extends PrintingUiApiSpec + { + /** Public no-argument constructor required by the core extension registry. */ + public Command() + { + super("Command", HttpMethod.GET, "/admin/devices/{deviceId}/commands/{commandId}", null, "Action"); + } + + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java new file mode 100644 index 0000000..f155c3a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java @@ -0,0 +1,331 @@ +/* + * 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.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.middleware.javalin.QJavalinUtils; +import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation; +import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec; +import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1; +import com.kingsrook.qqq.openapi.model.HttpMethod; +import com.kingsrook.qqq.openapi.model.In; +import com.kingsrook.qqq.openapi.model.Parameter; +import com.kingsrook.qqq.openapi.model.Schema; +import com.kingsrook.qqq.openapi.model.Type; +import io.javalin.http.Context; +import io.javalin.http.HttpStatus; +import org.json.JSONArray; +import org.json.JSONObject; + + +/** Native secured QQQ specs: session setup and credential scope execute before request construction. */ +public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec +{ + private final String operation; + private final String path; + private final HttpMethod method; + private final String requestSchema; + private final String responseSchema; + + + /** Immutable operation metadata; request credentials never live on shared spec instances. */ + protected PrintingUiApiSpec(String operation, HttpMethod method, String path, String requestSchema, String responseSchema) + { + this.operation = operation; + this.method = method; + this.path = path; + this.requestSchema = requestSchema; + this.responseSchema = responseSchema; + } + + + /** All operations are discoverable under the registered extension namespace. */ + @Override + public BasicOperation defineBasicOperation() + { + return (new BasicOperation().withHttpMethod(method).withPath(PrintingUiApi.PREFIX + path) + .withOperationId("printing" + operation).withShortSummary("Printing " + operation) + .withLongDescription("Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication.")); + } + + + /** Typed request bodies use the same wire vocabulary as the native client. */ + @Override + protected Schema defineRequestBodySchema() + { + return (requestSchema == null ? null : PrintingApiSchemas.ref(requestSchema)); + } + + + /** Typed response body. */ + @Override + protected String getResponseSchemaName() + { + return (PrintingApiSchemas.name(responseSchema)); + } + + + /** Complete namespaced component definitions are merged into the core OpenAPI document. */ + @Override + public Map defineComponentSchemas() + { + return (PrintingApiSchemas.components()); + } + + + /** Document secondary proof independently from core session authentication. */ + @Override + public List defineRequestParameters() + { + List parameters = new ArrayList<>(); + if(path.startsWith("/device/") || "Session".equals(operation)) + { + boolean required = !"Session".equals(operation); + parameters.add(new Parameter().withName(PrintingAgentAuthorization.DEVICE_HEADER).withIn(In.HEADER).withRequired(required).withSchema(PrintingApiSchemas.text(36))); + parameters.add(new Parameter().withName(PrintingAgentAuthorization.TOKEN_HEADER).withIn(In.HEADER).withRequired(required).withSchema(PrintingApiSchemas.text(128)).withDescription("Private secondary device proof; never log.")); + } + for(String name : List.of("deviceId", "jobId", "commandId")) + { + if(path.contains("{" + name + "}")) + { + parameters.add(new Parameter().withName(name).withIn(In.PATH).withRequired(true).withSchema(PrintingApiSchemas.text(128))); + } + } + if("Commands".equals(operation)) + { + parameters.add(new Parameter().withName("limit").withIn(In.QUERY).withSchema(new Schema().withType(Type.INTEGER)).withDescription("1 through 200; default 50.")); + parameters.add(new Parameter().withName("before").withIn(In.QUERY).withSchema(PrintingApiSchemas.text(128))); + } + return (parameters); + } + + + /** Authenticate before processing, retain only the verified public device ID in the request input. */ + @Override + public JsonInput buildInput(Context context) throws Exception + { + context.header("Cache-Control", "no-store"); + PrintingControlAuthorization.principal(); + if(context.bodyAsBytes().length > ("Inventory".equals(operation) ? 2097152 : 32768)) + { + throw new PrintingException(413, "Printing request exceeds its size limit"); + } + JsonInput input = super.buildInput(context); + if("Empty".equals(requestSchema) && !input.getBody().isEmpty()) + { + throw new QBadRequestException("This operation requires an empty JSON object"); + } + String id = context.header(PrintingAgentAuthorization.DEVICE_HEADER); + String token = context.header(PrintingAgentAuthorization.TOKEN_HEADER); + if(path.startsWith("/device/") || ("Session".equals(operation) && (id != null || token != null))) + { + input.getPathParams().put("_verifiedDevice", PrintingAgentAuthorization.device(id, token, "Bind".equals(operation))); + } + return (input); + } + + + /** Domain mutations follow native user/table/row/process checks, never generic table-write bypass flags. */ + @Override + protected Object handle(JsonInput input) throws Exception + { + PrintingService service = PrintingProcesses.currentService(); + String principal = PrintingControlAuthorization.principal(); + String device = input.getPathParam("_verifiedDevice"); + JSONObject body = new JSONObject(input.getBody()); + String job = input.getPathParam("jobId"); + Object result; + switch(operation) + { + case "Session" -> + { + result = new JSONObject().put("principalId", principal).put("tenantId", service.getTenantId()).put("deviceId", device); + } + case "Bind" -> result = accepted(); + case "Enroll" -> + { + PrintingControlAuthorization.process("printingEnrollDevice"); + PrintingControlAuthorization.table(PrintingTables.DEVICE); + String facility = service.enrollmentFacility(body.optString("enrollmentCode")); + try + { + PrintingFacilities.validate(facility); + } + catch(QException denied) + { + throw new QPermissionDeniedException("Enrollment facility is not accessible"); + } + result = service.enrollForUser(body, principal); + } + case "Heartbeat" -> result = service.heartbeat(device, body); + case "Inventory" -> + { + service.inventory(device, body.getJSONArray("printers"), PrintingAgentAuthorization.inventory(device)); + result = accepted(); + } + case "Claim" -> result = PrintingAgentAuthorization.claim(device); + case "Queued" -> + { + PrintingControlAuthorization.table(PrintingTables.JOB); + JSONArray jobs = service.queuedJobs(device); + for(int index = 0; index < jobs.length(); index++) + { + PrintingControlAuthorization.job(device, jobs.getJSONObject(index).getString("jobId")); + } + result = new JSONObject().put("jobs", jobs); + } + case "Content" -> + { + PrintingControlAuthorization.job(device, job); + return (service.content(device, job)); + } + case "Event" -> + { + PrintingControlAuthorization.job(device, job); + service.event(device, job, body); + result = accepted(); + } + case "Test" -> + { + PrintingAgentAuthorization.printer(device, body.optString("printerId")); + PrintingControlAuthorization.process("PDF".equals(body.optString("format")) ? "printingTestPdf" : "printingTestZpl"); + result = service.testPrint(device, body); + } + case "Retry" -> + { + PrintingControlAuthorization.job(device, job); + PrintingControlAuthorization.process(PrintingJobProcesses.RETRY); + result = service.retry(device, job, body); + } + case "Cancel" -> + { + PrintingControlAuthorization.job(device, job); + PrintingControlAuthorization.process(PrintingJobProcesses.CANCEL); + result = service.cancel(device, job); + } + case "Poll" -> + { + PrintingControlAuthorization.table(PrintingTables.COMMAND); + JSONObject poll = service.deviceControlPoll(device, body); + JSONArray commands = poll.getJSONArray("commands"); + for(int index = 0; index < commands.length(); index++) + { + command(device, commands.getJSONObject(index).getString("commandId")); + } + result = poll; + } + case "Receipt" -> + { + command(device, input.getPathParam("commandId")); + result = service.deviceControlReceipt(device, input.getPathParam("commandId"), body); + } + case "Action" -> result = PrintingControlAuthorization.deviceAction(input.getPathParam("deviceId"), body); + case "Commands" -> + { + device = input.getPathParam("deviceId"); + PrintingControlAuthorization.record(PrintingTables.DEVICE, device); + PrintingControlAuthorization.table(PrintingTables.COMMAND); + Integer limit = input.getQueryParamInteger("limit"); + JSONObject page = service.deviceCommandsPage(device, limit == null ? 50 : limit, input.getQueryParam("before")); + JSONArray commands = page.getJSONArray("commands"); + for(int index = 0; index < commands.length(); index++) + { + command(device, commands.getJSONObject(index).getString("commandId")); + } + result = page; + } + case "Command" -> + { + device = input.getPathParam("deviceId"); + PrintingControlAuthorization.record(PrintingTables.DEVICE, device); + command(device, input.getPathParam("commandId")); + result = service.getDeviceCommand(device, input.getPathParam("commandId")); + } + default -> throw new PrintingException(400, "Unknown printing operation"); + } + return (result instanceof JSONObject json ? json.toMap() : result); + } + + + /** Receipt and history access apply child-record security independently of device access. */ + private static void command(String device, String id) throws Exception + { + QRecord command = PrintingControlAuthorization.record(PrintingTables.COMMAND, id); + if(!device.equals(command.getValueString("device_id"))) + { + throw new QPermissionDeniedException("Command is not assigned to this device"); + } + if(command.getValueString("job_id") != null) + { + PrintingControlAuthorization.job(device, command.getValueString("job_id")); + } + } + + + /** Mutation success does not assert a physical print outcome. */ + private static JSONObject accepted() + { + return (new JSONObject().put("accepted", true)); + } + + + /** Use the core error envelope and suppress backend/credential diagnostics. */ + @Override + protected void handleException(Context context, Exception exception) + { + Throwable cause = exception; + while(cause.getCause() != null && !(cause instanceof PrintingException) && !(cause instanceof QAuthenticationException) + && !(cause instanceof QPermissionDeniedException) && !(cause instanceof QBadRequestException)) + { + cause = cause.getCause(); + } + int status = cause instanceof PrintingException printing ? printing.getStatus() + : cause instanceof QAuthenticationException ? 401 : cause instanceof QPermissionDeniedException ? 403 + : cause instanceof QBadRequestException || cause instanceof org.json.JSONException ? 400 : 500; + String message = status == 500 ? "The printing operation could not be completed" + : status == 401 ? "Sign in to continue" : status == 403 ? "User or device is not authorized" : cause.getMessage(); + context.header("Cache-Control", "no-store").contentType("application/json"); + QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.getCode(status), message); + } + + + /** Standard native authentication, authorization, conflict and capacity responses. */ + @Override + public List defineAdditionalBasicResponses() + { + return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.UNAUTHORIZED, "Native QQQ sign-in required", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.FORBIDDEN, "Permission, device proof or user binding denied", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.NOT_FOUND, "Record not found", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.CONFLICT, "State or request conflict", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.GONE, "Request or content expired", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Bounded capacity reached", BasicErrorResponseV1.class.getSimpleName()))); + } +} diff --git a/src/main/resources/openapi/qqq-v1/printing.yaml b/src/main/resources/openapi/qqq-v1/printing.yaml new file mode 100644 index 0000000..88032c2 --- /dev/null +++ b/src/main/resources/openapi/qqq-v1/printing.yaml @@ -0,0 +1,2078 @@ +components: + schemas: + BasicErrorResponseV1: + description: "The error body of every UI API operation (core and extensions)." + properties: + error: + description: "The error message (the same as message; kept for clients of\ + \ UI API 1.0 to 1.5)." + type: "string" + code: + description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\ + \ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\ + \ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\ + \ Extensions may add codes; treat an unknown code by the HTTP status.\ + \ Since 1.6.0." + type: "string" + message: + description: "A message to show the user. Since 1.6.0." + type: "string" + fieldErrors: + description: "Errors about particular request fields, when known. Since\ + \ 1.6.0." + items: + properties: + field: + description: "The field (or parameter) name." + type: "string" + message: + description: "The error about that field." + type: "string" + code: + description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\ + \ PATTERN." + type: "string" + type: "object" + type: "array" + traceId: + description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\ + \ response header); quote it when reporting a problem. Since 1.6.0." + type: "string" + type: "object" + PrintingAcknowledgmentV1: + properties: + accepted: + type: "boolean" + required: + - "accepted" + type: "object" + PrintingActionRequestV1: + properties: + requestId: + maxLength: 128 + type: "string" + issuedAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + action: + enum: + - "PAUSE_DISPATCH" + - "RESUME_DISPATCH" + - "DISCONNECT" + - "RECONNECT" + - "REVOKE" + - "REFRESH_INVENTORY" + - "CLEAR_QUEUE" + - "CANCEL_SPOOL" + type: "string" + jobId: + maxLength: 128 + type: "string" + required: + - "requestId" + - "issuedAt" + - "action" + type: "object" + PrintingActionV1: + properties: + commandId: + maxLength: 128 + type: "string" + requestId: + maxLength: 128 + type: "string" + issuedAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + deviceId: + maxLength: 128 + type: "string" + action: + enum: + - "PAUSE_DISPATCH" + - "RESUME_DISPATCH" + - "DISCONNECT" + - "RECONNECT" + - "REVOKE" + - "REFRESH_INVENTORY" + - "CLEAR_QUEUE" + - "CANCEL_SPOOL" + type: "string" + status: + enum: + - "PENDING" + - "SUCCEEDED" + - "FAILED" + - "EXPIRED" + - "OUTCOME_UNKNOWN" + type: "string" + createdAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + expiresAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + jobId: + maxLength: 128 + type: "string" + resultCode: + maxLength: 80 + type: "string" + cancelledJobCount: + type: "integer" + receipt: + $ref: "#/components/schemas/PrintingReceiptRequestV1" + required: + - "commandId" + - "requestId" + - "issuedAt" + - "deviceId" + - "action" + - "status" + - "createdAt" + - "expiresAt" + type: "object" + PrintingActionsV1: + properties: + commands: + items: + $ref: "#/components/schemas/PrintingActionV1" + type: "array" + nextCursor: + maxLength: 128 + nullable: true + type: "string" + required: + - "commands" + - "nextCursor" + type: "object" + PrintingClaimV1: + properties: + job: + $ref: "#/components/schemas/PrintingJobV1" + nullable: true + required: + - "job" + type: "object" + PrintingCommandV1: + properties: + commandId: + maxLength: 128 + type: "string" + requestId: + maxLength: 128 + type: "string" + action: + enum: + - "REFRESH_INVENTORY" + - "CANCEL_SPOOL" + type: "string" + createdAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + expiresAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + jobId: + maxLength: 128 + type: "string" + required: + - "commandId" + - "requestId" + - "action" + - "createdAt" + - "expiresAt" + type: "object" + PrintingControlPollRequestV1: + properties: + controlVersion: + type: "integer" + agentVersion: + maxLength: 100 + type: "string" + capabilities: + items: + enum: + - "REFRESH_INVENTORY" + - "CANCEL_SPOOL" + type: "string" + type: "array" + lastAppliedRevision: + type: "integer" + required: + - "controlVersion" + - "agentVersion" + - "capabilities" + - "lastAppliedRevision" + type: "object" + PrintingControlPollV1: + properties: + controlVersion: + type: "integer" + serverTime: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + pollIntervalSeconds: + type: "integer" + desiredState: + $ref: "#/components/schemas/PrintingDesiredStateV1" + commands: + items: + $ref: "#/components/schemas/PrintingCommandV1" + type: "array" + required: + - "controlVersion" + - "serverTime" + - "pollIntervalSeconds" + - "desiredState" + - "commands" + type: "object" + PrintingDesiredStateV1: + properties: + revision: + type: "integer" + dispatchPaused: + type: "boolean" + connectionState: + enum: + - "CONNECTED" + - "DISCONNECTED" + type: "string" + required: + - "revision" + - "dispatchPaused" + - "connectionState" + type: "object" + PrintingEmptyV1: + type: "object" + PrintingEnrollmentRequestV1: + properties: + enrollmentCode: + maxLength: 128 + type: "string" + deviceName: + maxLength: 1024 + type: "string" + agentVersion: + maxLength: 100 + type: "string" + required: + - "enrollmentCode" + - "deviceName" + - "agentVersion" + type: "object" + PrintingEnrollmentV1: + properties: + protocolVersion: + type: "integer" + deviceId: + maxLength: 128 + type: "string" + deviceToken: + description: "Private device proof, returned once; never log." + maxLength: 128 + type: "string" + pollIntervalSeconds: + type: "integer" + required: + - "protocolVersion" + - "deviceId" + - "deviceToken" + - "pollIntervalSeconds" + type: "object" + PrintingEventRequestV1: + properties: + eventId: + maxLength: 128 + type: "string" + status: + enum: + - "RECEIVED" + - "SUBMITTING" + - "SPOOLED" + - "SPOOLER_COMPLETED" + - "FAILED" + - "OUTCOME_UNKNOWN" + type: "string" + message: + maxLength: 2048 + type: "string" + spoolerJobId: + maxLength: 128 + type: "string" + required: + - "eventId" + - "status" + type: "object" + PrintingHeartbeatRequestV1: + properties: + agentVersion: + maxLength: 100 + type: "string" + profile: + $ref: "#/components/schemas/PrintingProfileV1" + required: + - "agentVersion" + type: "object" + PrintingHeartbeatV1: + properties: + serverTime: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + required: + - "serverTime" + type: "object" + PrintingInventoryRequestV1: + properties: + printers: + items: + $ref: "#/components/schemas/PrintingPrinterV1" + type: "array" + required: + - "printers" + type: "object" + PrintingJobV1: + properties: + jobId: + maxLength: 128 + type: "string" + deviceId: + maxLength: 128 + type: "string" + printerId: + maxLength: 128 + type: "string" + title: + maxLength: 1024 + type: "string" + format: + enum: + - "PDF" + - "ZPL" + type: "string" + contentSha256: + maxLength: 64 + type: "string" + contentLength: + type: "integer" + options: + $ref: "#/components/schemas/PrintingOptionsV1" + createdAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + status: + enum: + - "QUEUED" + - "CLAIMED" + - "RECEIVED" + - "SUBMITTING" + - "SPOOLED" + - "SPOOLER_COMPLETED" + - "FAILED" + - "OUTCOME_UNKNOWN" + type: "string" + message: + maxLength: 2048 + type: "string" + spoolerJobId: + maxLength: 128 + type: "string" + required: + - "jobId" + - "deviceId" + - "printerId" + - "title" + - "format" + - "contentSha256" + - "contentLength" + - "options" + - "createdAt" + - "status" + type: "object" + PrintingJobsV1: + properties: + jobs: + items: + $ref: "#/components/schemas/PrintingJobV1" + type: "array" + required: + - "jobs" + type: "object" + PrintingOptionsV1: + properties: + copies: + type: "integer" + scale: + enum: + - "ACTUAL_SIZE" + - "FIT" + type: "string" + paperWidthMm: + type: "number" + paperHeightMm: + type: "number" + type: "object" + PrintingPrinterV1: + properties: + printerId: + maxLength: 128 + type: "string" + name: + maxLength: 1024 + type: "string" + driverName: + maxLength: 1024 + type: "string" + portName: + maxLength: 1024 + type: "string" + status: + enum: + - "READY" + - "OFFLINE" + - "PAUSED" + - "ERROR" + - "UNKNOWN" + type: "string" + formats: + items: + enum: + - "PDF" + - "ZPL" + type: "string" + type: "array" + displayName: + maxLength: 512 + type: "string" + description: + maxLength: 4096 + type: "string" + location: + maxLength: 512 + type: "string" + required: + - "printerId" + - "name" + - "formats" + type: "object" + PrintingProfileV1: + properties: + name: + maxLength: 512 + nullable: true + type: "string" + description: + maxLength: 4096 + nullable: true + type: "string" + location: + maxLength: 512 + nullable: true + type: "string" + type: "object" + PrintingReceiptRequestV1: + properties: + receiptId: + maxLength: 128 + type: "string" + outcome: + enum: + - "INVENTORY_REFRESHED" + - "CANCEL_REQUESTED" + - "ALREADY_ABSENT" + - "OWNERSHIP_MISMATCH" + - "UNSUPPORTED" + - "FAILED" + - "EXPIRED" + - "OUTCOME_UNKNOWN" + type: "string" + completedAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + message: + maxLength: 1024 + type: "string" + required: + - "receiptId" + - "outcome" + - "completedAt" + type: "object" + PrintingRetryRequestV1: + properties: + requestId: + maxLength: 128 + type: "string" + confirmDuplicateRisk: + type: "boolean" + required: + - "requestId" + type: "object" + PrintingSessionV1: + properties: + principalId: + maxLength: 200 + type: "string" + tenantId: + maxLength: 200 + type: "string" + deviceId: + maxLength: 128 + type: "string" + required: + - "principalId" + - "tenantId" + type: "object" + PrintingTestRequestV1: + properties: + requestId: + maxLength: 128 + type: "string" + printerId: + maxLength: 128 + type: "string" + format: + enum: + - "PDF" + - "ZPL" + type: "string" + testProfile: + maxLength: 40 + type: "string" + required: + - "requestId" + - "printerId" + - "format" + type: "object" + securitySchemes: + sessionUuidCookie: + description: "The session cookie set by POST /qqq/v1/manageSession (web clients)." + in: "cookie" + name: "sessionUUID" + type: "apiKey" + bearerAuth: + bearerFormat: "JWT or session UUID" + description: "`Authorization: Bearer `, where the token is either\n-\ + \ an OAuth2 access token (a signed JWT) issued by the app's identity provider\ + \ to the app's client,\n or to its native client (`values.nativeClientId`\ + \ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\ + \ grant or auth-code + PKCE. It is verified against the\n provider's keys\ + \ (issuer, audience, expiry, signature) and gives the same user and permissions\n\ + \ as signing in on the web. When it expires (401), refresh it at the provider\ + \ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\ + \ which the server keeps refreshed." + scheme: "bearer" + type: "http" + oauth2: + description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\ + \ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\ + \ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\ + \ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\ + \ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\ + \ grant (`device_authorization_endpoint`, RFC 8628), then send the access\ + \ token\nas `Authorization: Bearer`." + flows: + authorizationCode: + authorizationUrl: "/authorize" + scopes: + email: "Email address" + offline_access: "A refresh token, to renew the access token without signing\ + \ in again" + openid: "OpenID Connect sign-in" + profile: "Name, username and groups (permissions come from the groups)" + tokenUrl: "/token" + type: "oauth2" +info: + contact: + email: "contact@kingsrook.com" + description: "Native current-user printing with secondary device proof and device-control-v1." + title: "QQQ Middleware API extension: printing" + version: "1.0.0" +openapi: "3.0.3" +paths: + /qqq/v1/qbit/printing/session: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingSession" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: false + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: false + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingSessionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Session" + tags: + - "printing" + /qqq/v1/qbit/printing/device/bind: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingBind" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEmptyV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Bind" + tags: + - "printing" + /qqq/v1/qbit/printing/devices/enroll: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingEnroll" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEnrollmentRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEnrollmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Enroll" + tags: + - "printing" + /qqq/v1/qbit/printing/device/heartbeat: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingHeartbeat" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingHeartbeatRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingHeartbeatV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Heartbeat" + tags: + - "printing" + /qqq/v1/qbit/printing/device/printers: + put: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingInventory" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingInventoryRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Inventory" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/claim: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingClaim" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEmptyV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingClaimV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Claim" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/queued: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingQueued" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingJobsV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Queued" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/{jobId}/content: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingContent" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "jobId" + required: true + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/octet-stream: + schema: + format: "binary" + type: "string" + description: "Verified retained document bytes" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Content" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/{jobId}/events: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingEvent" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "jobId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEventRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Event" + tags: + - "printing" + /qqq/v1/qbit/printing/device/test-print: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingTest" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingTestRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingJobV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Test" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/{jobId}/retry: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingRetry" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "jobId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingRetryRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingJobV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Retry" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/{jobId}/cancel: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingCancel" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "jobId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEmptyV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Cancel" + tags: + - "printing" + /qqq/v1/qbit/printing/device/control/poll: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingPoll" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingControlPollRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingControlPollV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Poll" + tags: + - "printing" + /qqq/v1/qbit/printing/device/control/commands/{commandId}/receipt: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingReceipt" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "commandId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingReceiptRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Receipt" + tags: + - "printing" + /qqq/v1/qbit/printing/admin/devices/{deviceId}/actions: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingAction" + parameters: + - in: "path" + name: "deviceId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingActionRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Action" + tags: + - "printing" + /qqq/v1/qbit/printing/admin/devices/{deviceId}/commands: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingCommands" + parameters: + - in: "path" + name: "deviceId" + required: true + schema: + maxLength: 128 + type: "string" + - description: "1 through 200; default 50." + in: "query" + name: "limit" + schema: + type: "integer" + - in: "query" + name: "before" + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingActionsV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Commands" + tags: + - "printing" + /qqq/v1/qbit/printing/admin/devices/{deviceId}/commands/{commandId}: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingCommand" + parameters: + - in: "path" + name: "deviceId" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "commandId" + required: true + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Command" + tags: + - "printing" +servers: +- description: "The QQQ server that serves this document (paths include /qqq/v1)." + url: "/" +tags: +- description: "Operations of the printing extension." + name: "printing" diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingCommandStatusTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingCommandStatusTest.java new file mode 100644 index 0000000..cd923b6 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingCommandStatusTest.java @@ -0,0 +1,218 @@ +/* + * 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.sql.Connection; +import java.sql.PreparedStatement; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +/******************************************************************************* + ** Native reads derive expiry without an agent poll, mutation or tenant bypass. + *******************************************************************************/ +class PrintingCommandStatusTest +{ + /*************************************************************************** + ** Keep each synthetic fixture's authority local to that test. + ***************************************************************************/ + @AfterEach + void clearContext() + { + QContext.clear(); + } + + + + /*************************************************************************** + ** An offline stale row displays expired even though stored state stays pending. + ***************************************************************************/ + @Test + void fullGetAndProjectedQueryDeriveExpiryWithoutChangingRecordedState() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + DriverDataSource source = source(fixture); + String id = seed(source, "tenant-a", fixture.device(), Instant.now().minusSeconds(60).toString()); + QRecord record = get(id); + assertEquals("EXPIRED", record.getValueString("command_status")); + assertEquals("PENDING", record.getValueString("status")); + + QueryInput query = new QueryInput().withTableName(PrintingTables.COMMAND) + .withFieldNamesToInclude(Set.of("id", "action", "command_status")) + .withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id)); + query.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + QRecord projected = new QueryAction().execute(query).getRecords().getFirst(); + assertEquals("EXPIRED", projected.getValueString("command_status")); + assertFalse(projected.getValues().containsKey("status")); + assertFalse(projected.getValues().containsKey("expires_at")); + try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT status FROM printing_device_command WHERE id=?")) + { + statement.setString(1, id); + try(var result = statement.executeQuery()) + { + result.next(); + assertEquals("PENDING", result.getString(1)); + } + } + } + + + + /*************************************************************************** + ** Hydration uses the same current native permissions and tenant row security. + ***************************************************************************/ + @Test + void selectedProjectionCannotExposeAnotherTenantsCommands() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + DriverDataSource source = source(fixture); + String own = seed(source, "tenant-a", fixture.device(), Instant.now().minusSeconds(60).toString()); + String foreign = seed(source, "tenant-b", fixture.foreignDevice(), Instant.now().minusSeconds(60).toString()); + QueryInput query = new QueryInput().withTableName(PrintingTables.COMMAND) + .withFieldNamesToInclude(Set.of("id", "command_status")) + .withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, List.of(own, foreign))); + query.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + List rows = new QueryAction().execute(query).getRecords(); + assertEquals(List.of(own), rows.stream().map(row -> row.getValueString("id")).toList()); + assertEquals("EXPIRED", rows.getFirst().getValueString("command_status")); + assertNull(get(foreign)); + QContext.getQSession().setPermissions(Set.of()); + assertThrows(QException.class, () -> new QueryAction().execute(query)); + } + + + + /*************************************************************************** + ** Arbitrary projections need a primary key to hydrate an authoritative value. + ***************************************************************************/ + @Test + void derivedStatusProjectionRequiresId() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + seed(source(fixture), "tenant-a", fixture.device(), Instant.now().minusSeconds(60).toString()); + QueryInput query = new QueryInput().withTableName(PrintingTables.COMMAND).withFieldNamesToInclude(Set.of("action", "command_status")); + query.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + assertThrows(QException.class, () -> new QueryAction().execute(query)); + } + + + + /*************************************************************************** + ** Expiry cannot erase a receipt; missing or malformed pending expiry is unknown. + ***************************************************************************/ + @Test + void effectiveStatusKeepsTerminalReceiptsAndRejectsMalformedPendingExpiry() + { + Instant now = Instant.parse("2026-10-31T12:00:00Z"); + QRecord row = new QRecord().withValue("status", "PENDING").withValue("expires_at", now.toString()); + assertEquals("PENDING", PrintingCommandStatus.effective(row, now)); + row.setValue("expires_at", now.minusSeconds(1).toString()); + assertEquals("EXPIRED", PrintingCommandStatus.effective(row, now)); + row.setValue("expires_at", "not-a-timestamp"); + assertEquals("UNKNOWN", PrintingCommandStatus.effective(row, now)); + row.setValue("expires_at", null); + assertEquals("UNKNOWN", PrintingCommandStatus.effective(row, now)); + for(String status : List.of("SUCCEEDED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN")) + { + row.setValue("status", status); + assertEquals(status, PrintingCommandStatus.effective(row, now)); + } + row.setValue("status", "unrecognized"); + assertEquals("UNKNOWN", PrintingCommandStatus.effective(row, now)); + } + + + + /*************************************************************************** + ** Reuse the existing isolated native fixture without exposing new production APIs. + ***************************************************************************/ + private static DriverDataSource source(PrintingProcessesTest.Fixture fixture) + { + RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(fixture.instance().getTable(PrintingTables.COMMAND).getBackendName()); + return (new DriverDataSource(backend.getJdbcUrl(), "sa", "")); + } + + + + /*************************************************************************** + ** Direct fixture inserts represent a device that never polls after acceptance. + ***************************************************************************/ + private static String seed(DriverDataSource source, String tenant, String device, String expiresAt) throws Exception + { + String id = UUID.randomUUID().toString(); + String issued = Instant.now().minusSeconds(960).toString(); + JSONObject request = new JSONObject().put("requestId", id).put("issuedAt", issued).put("action", "REFRESH_INVENTORY"); + try(Connection connection = source.getConnection(); + PreparedStatement insert = connection.prepareStatement("INSERT INTO printing_device_command (id,tenant_id,device_id,request_id,action,actor,request_json,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,'PENDING',?,?)"); + PreparedStatement offline = connection.prepareStatement("UPDATE printing_device SET last_seen_at=?,control_last_poll_at=NULL WHERE id=?")) + { + insert.setString(1, id); + insert.setString(2, tenant); + insert.setString(3, device); + insert.setString(4, id); + insert.setString(5, "REFRESH_INVENTORY"); + insert.setString(6, "operator"); + insert.setString(7, request.toString()); + insert.setString(8, issued); + insert.setString(9, expiresAt); + insert.executeUpdate(); + offline.setString(1, issued); + offline.setString(2, device); + offline.executeUpdate(); + } + return (id); + } + + + + /*************************************************************************** + ** Native get also runs the post-query customizer and ordinary record security. + ***************************************************************************/ + private static QRecord get(String id) throws QException + { + GetInput input = new GetInput(); + input.setTableName(PrintingTables.COMMAND); + input.setPrimaryKey(id); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + return (new GetAction().execute(input).getRecord()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java index 65fb5b3..52fa428 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java @@ -126,6 +126,54 @@ class PrintingContractTest + /** Legacy compatibility transport preserves the same shared control contract and authorization boundary. */ + @Test + void sharedControlFixturesRequireScopedAuthorizationAndPreserveReplay() throws Exception + { + PrintingService service = new PrintingService(new DriverDataSource("jdbc:h2:mem:control-contract-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""), "contract-tenant"); + service.initialize(); + PrintingRoutes routes = new PrintingRoutes(service, context -> ADMIN.equals(PrintingRoutes.bearer(context))); + Javalin app = Javalin.create(routes::acceptJavalinConfig).start("127.0.0.1", 0); + try(HttpClient client = HttpClient.newHttpClient()) + { + String base = "http://127.0.0.1:" + app.port() + PrintingRoutes.BASE; + JSONObject enrolled = service.enroll(fixture("enroll.request.json").put("enrollmentCode", service.createEnrollment("facility", "device").getString("enrollmentCode"))); + String token = enrolled.getString("deviceToken"); + String device = enrolled.getString("deviceId"); + String actions = "/admin/devices/" + device + "/actions"; + String history = "/admin/devices/" + device + "/commands"; + assertEquals("device-control-v1", json(send(client, base, "GET", "/version", null, null), 200).getJSONArray("capabilities").getString(0)); + JSONObject poll = fixture("control-poll.request.json").put("lastAppliedRevision", 0); + json(send(client, base, "POST", "/device/control/poll", null, poll), 401); + json(send(client, base, "POST", "/device/control/poll", token, poll), 200); + JSONObject refresh = fixture("action-refresh-inventory.request.json").put("issuedAt", java.time.Instant.now().toString()); + json(send(client, base, "POST", actions, token, refresh), 403); + JSONObject command = json(send(client, base, "POST", actions, ADMIN, refresh), 200); + assertEquals(command.toString(), json(send(client, base, "POST", actions, ADMIN, refresh), 200).toString()); + String commandId = command.getString("commandId"); + assertEquals(commandId, json(send(client, base, "POST", "/device/control/poll", token, poll), 200).getJSONArray("commands").getJSONObject(0).getString("commandId")); + JSONObject receipt = fixture("control-inventory-refreshed.receipt.json"); + String receiptPath = "/device/control/commands/" + commandId + "/receipt"; + json(send(client, base, "POST", receiptPath, token, receipt), 200); + json(send(client, base, "POST", receiptPath, token, receipt), 200); + json(send(client, base, "POST", receiptPath, token, new JSONObject(receipt.toString()).put("message", "changed")), 409); + json(send(client, base, "POST", receiptPath, token, new JSONObject(receipt.toString()).put("message", "x".repeat(9000))), 413); + json(send(client, base, "GET", history, token, null), 403); + assertEquals("SUCCEEDED", json(send(client, base, "GET", history + "/" + commandId, ADMIN, null), 200).getString("status")); + json(send(client, base, "GET", history + "?limit=201", ADMIN, null), 400); + JSONObject revoke = fixture("action-revoke.request.json").put("issuedAt", java.time.Instant.now().toString()); + json(send(client, base, "POST", actions, ADMIN, revoke), 200); + json(send(client, base, "POST", "/device/control/poll", token, poll), 401); + json(send(client, base, "POST", "/device/jobs/claim", token, new JSONObject()), 401); + assertEquals(2, json(send(client, base, "GET", history, ADMIN, null), 200).getJSONArray("commands").length()); + } + finally + { + app.stop(); + } + } + + /*************************************************************************** ** Claim waits for its bounded JSON body before committing the response. ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceControlsTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceControlsTest.java new file mode 100644 index 0000000..0839c5b --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceControlsTest.java @@ -0,0 +1,337 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import org.h2.jdbcx.JdbcConnectionPool; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** Real durable controls, replay boundaries, tenant isolation and competing dispatch. */ +class PrintingDeviceControlsTest +{ + /** Regression: pauseDisconnectAndRevokeHaveDistinctDurableSemantics. */ + @Test + void pauseDisconnectAndRevokeHaveDistinctDurableSemantics() throws Exception + { + Fixture f = new Fixture(); + String job = f.submit("first"); + JSONObject pause = action("PAUSE_DISPATCH"); + JSONObject receipt = f.service.deviceAction(f.device, pause, "admin"); + assertTrue(f.service.claim(f.device).isNull("job")); + assertEquals(receipt.toString(), f.service.deviceAction(f.device, pause, "admin").toString()); + f.act("RESUME_DISPATCH"); + f.act("DISCONNECT"); + assertTrue(f.service.claim(f.device).isNull("job")); + JSONObject state = f.service.deviceControlPoll(f.device, poll()).getJSONObject("desiredState"); + assertEquals("DISCONNECTED", state.getString("connectionState")); + assertFalse(state.getBoolean("dispatchPaused")); + f.act("RECONNECT"); + assertEquals(job, f.service.claim(f.device).getJSONObject("job").getString("jobId")); + f.act("DISCONNECT"); + f.service.event(f.device, job, new JSONObject().put("eventId", "received").put("status", "RECEIVED")); + assertEquals("RECEIVED", f.service.getJob(job).getString("status")); + f.act("REVOKE"); + assertEquals(401, assertThrows(PrintingException.class, () -> f.service.authenticate(f.token)).getStatus()); + assertThrows(PrintingException.class, () -> f.service.claim(f.device)); + assertThrows(PrintingException.class, () -> f.service.deviceControlPoll(f.device, poll())); + assertEquals(409, assertThrows(PrintingException.class, () -> f.act("RESUME_DISPATCH")).getStatus()); + assertEquals("RECEIVED", f.service.getJob(job).getString("status")); + assertFalse(f.service.deviceCommandsPage(f.device, 50, null).getJSONArray("commands").isEmpty()); + } + + + /** Regression: clearQueueReplayNeverCancelsNewJobsOrInflightWork. */ + @Test + void clearQueueReplayNeverCancelsNewJobsOrInflightWork() throws Exception + { + Fixture f = new Fixture(); + String active = f.submit("active"); + f.service.claim(f.device); + f.service.event(f.device, active, new JSONObject().put("eventId", "submitting").put("status", "SUBMITTING")); + String queued = f.submit("queued"); + JSONObject request = action("CLEAR_QUEUE"); + JSONObject first = f.service.deviceAction(f.device, request, "admin"); + assertEquals(1, first.getInt("cancelledJobCount")); + assertEquals("SUBMITTING", f.service.getJob(active).getString("status")); + assertEquals("FAILED", f.service.getJob(queued).getString("status")); + try(Connection c = f.source.getConnection()) + { + assertEquals(2, PrintingService.query(c, "SELECT id FROM printing_job WHERE tenant_id='tenant' AND content_available=1").size()); + assertEquals(12, PrintingService.query(c, "SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant' AND device_id='' ").getFirst().getLong("retained_bytes")); + assertEquals(0, PrintingService.query(c, "SELECT id FROM printing_document_orphan WHERE tenant_id='tenant'").size()); + } + String later = f.submit("later"); + assertEquals(first.toString(), f.service.deviceAction(f.device, request, "admin").toString()); + assertEquals("QUEUED", f.service.getJob(later).getString("status")); + assertEquals(409, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, new JSONObject(request.toString()).put("action", "PAUSE_DISPATCH"), "admin")).getStatus()); + } + + + /** Regression: remoteCommandsRequireCapabilitiesAndReceiptsAreImmutable. */ + @Test + void remoteCommandsRequireCapabilitiesAndReceiptsAreImmutable() throws Exception + { + Fixture f = new Fixture(); + assertEquals(409, assertThrows(PrintingException.class, () -> f.act("REFRESH_INVENTORY")).getStatus()); + f.service.deviceControlPoll(f.device, poll()); + JSONObject command = f.act("REFRESH_INVENTORY"); + assertEquals("PENDING", command.getString("status")); + f.act("DISCONNECT"); + assertTrue(f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").isEmpty()); + f.act("RECONNECT"); + assertEquals(command.getString("commandId"), f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").getJSONObject(0).getString("commandId")); + JSONObject receipt = receipt("INVENTORY_REFRESHED"); + f.service.deviceControlReceipt(f.device, command.getString("commandId"), receipt); + f.service.deviceControlReceipt(f.device, command.getString("commandId"), receipt); + assertEquals(409, assertThrows(PrintingException.class, () -> f.service.deviceControlReceipt(f.device, command.getString("commandId"), new JSONObject(receipt.toString()).put("outcome", "FAILED"))).getStatus()); + assertEquals("SUCCEEDED", f.service.getDeviceCommand(f.device, command.getString("commandId")).getString("status")); + assertTrue(f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").isEmpty()); + } + + + /** Regression: cancelSpoolRequiresOwnedReceiptAndNeverReclassifiesJob. */ + @Test + void cancelSpoolRequiresOwnedReceiptAndNeverReclassifiesJob() throws Exception + { + Fixture f = new Fixture(); + f.service.deviceControlPoll(f.device, poll()); + String job = f.submit("spool"); + assertEquals(409, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, action("CANCEL_SPOOL").put("jobId", job), "admin")).getStatus()); + f.service.claim(f.device); + f.service.event(f.device, job, new JSONObject().put("eventId", "spooled").put("status", "SPOOLED").put("spoolerJobId", "123")); + JSONObject command = f.service.deviceAction(f.device, action("CANCEL_SPOOL").put("jobId", job), "admin"); + f.service.deviceControlReceipt(f.device, command.getString("commandId"), receipt("OWNERSHIP_MISMATCH")); + assertEquals("FAILED", f.service.getDeviceCommand(f.device, command.getString("commandId")).getString("status")); + assertEquals("SPOOLED", f.service.getJob(job).getString("status")); + assertEquals(400, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, action("CANCEL_SPOOL").put("jobId", job).put("spoolerJobId", "999"), "admin")).getStatus()); + } + + + /** Regression: tenantDeviceCursorAndReceiptBoundariesAreEnforced. */ + @Test + void tenantDeviceCursorAndReceiptBoundariesAreEnforced() throws Exception + { + Fixture f = new Fixture(); + String second = f.enroll().getString("deviceId"); + JSONObject first = f.act("PAUSE_DISPATCH"); + String id = first.getString("commandId"); + assertEquals(404, assertThrows(PrintingException.class, () -> f.service.getDeviceCommand(second, id)).getStatus()); + assertEquals(404, assertThrows(PrintingException.class, () -> f.service.deviceCommandsPage(second, 1, id)).getStatus()); + assertEquals(404, assertThrows(PrintingException.class, () -> f.service.deviceControlReceipt(second, id, receipt("FAILED"))).getStatus()); + PrintingService other = new PrintingService(f.source, "other-tenant"); + assertEquals(404, assertThrows(PrintingException.class, () -> other.deviceAction(f.device, action("REVOKE"), "other-admin")).getStatus()); + f.act("RESUME_DISPATCH"); + JSONObject page = f.service.deviceCommandsPage(f.device, 1, null); + assertEquals(1, page.getJSONArray("commands").length()); + assertEquals(id, f.service.deviceCommandsPage(f.device, 1, page.getString("nextCursor")).getJSONArray("commands").getJSONObject(0).getString("commandId")); + } + + + /** Regression: expiredCommandsAcceptLateActualOutcomeAndStaleActionsNeverExecute. */ + @Test + void expiredCommandsAcceptLateActualOutcomeAndStaleActionsNeverExecute() throws Exception + { + Fixture f = new Fixture(); + f.service.deviceControlPoll(f.device, poll()); + JSONObject command = f.act("REFRESH_INVENTORY"); + try(Connection c = f.source.getConnection()) + { + PrintingService.update(c, "UPDATE printing_device_command SET expires_at=? WHERE id=?", Instant.now().minusSeconds(60).toString(), command.getString("commandId")); + } + assertTrue(f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").isEmpty()); + assertEquals("EXPIRED", f.service.getDeviceCommand(f.device, command.getString("commandId")).getString("status")); + f.service.deviceControlReceipt(f.device, command.getString("commandId"), receipt("INVENTORY_REFRESHED")); + assertEquals("SUCCEEDED", f.service.getDeviceCommand(f.device, command.getString("commandId")).getString("status")); + JSONObject old = action("CLEAR_QUEUE").put("issuedAt", Instant.now().minusSeconds(901).toString()); + assertEquals(410, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, old, "admin")).getStatus()); + JSONObject saved = action("PAUSE_DISPATCH"); + f.service.deviceAction(f.device, saved, "admin"); + saved.put("issuedAt", Instant.now().minusSeconds(8 * 86400).toString()); + try(Connection c = f.source.getConnection()) + { + PrintingService.update(c, "UPDATE printing_device_command SET created_at=?,expires_at=?,request_json=? WHERE request_id=?", saved.getString("issuedAt"), Instant.parse(saved.getString("issuedAt")).plusSeconds(900).toString(), saved.toString(), saved.getString("requestId")); + } + assertEquals("SUCCEEDED", f.service.deviceAction(f.device, saved, "admin").getString("status")); + f.act("RESUME_DISPATCH"); + assertEquals(410, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, saved, "admin")).getStatus()); + assertFalse(f.service.deviceControlPoll(f.device, poll()).getJSONObject("desiredState").getBoolean("dispatchPaused")); + assertEquals(400, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, action("PAUSE_DISPATCH").put("issuedAt", Instant.now().plusSeconds(120).toString()), "admin")).getStatus()); + } + + + /** Regression: concurrentClearAndClaimCannotBothOwnTheSameQueuedJob. */ + @Test + void concurrentClearAndClaimCannotBothOwnTheSameQueuedJob() throws Exception + { + Fixture f = new Fixture(); + PrintingService second = new PrintingService(f.source, "tenant"); + String job = f.submit("race"); + CountDownLatch start = new CountDownLatch(1); + try(var executor = Executors.newFixedThreadPool(2)) + { + var clear = executor.submit(() -> + { + start.await(); + return f.act("CLEAR_QUEUE"); + }); + var claim = executor.submit(() -> + { + start.await(); + return second.claim(f.device); + }); + start.countDown(); + int canceled = clear.get().getInt("cancelledJobCount"); + boolean claimed = !claim.get().isNull("job"); + assertEquals(canceled == 0, claimed); + assertEquals(claimed ? "CLAIMED" : "FAILED", f.service.getJob(job).getString("status")); + } + } + + + /** Regression: pendingAndHistoryBudgetsRetainEmergencyRevocation. */ + @Test + void pendingAndHistoryBudgetsRetainEmergencyRevocation() throws Exception + { + Fixture f = new Fixture(); + f.service.deviceControlPoll(f.device, poll()); + for(int i = 0; i < 100; i++) + { + f.act("REFRESH_INVENTORY"); + } + assertEquals(429, assertThrows(PrintingException.class, () -> f.act("REFRESH_INVENTORY")).getStatus()); + assertEquals(20, f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").length()); + for(int i = 100; i < 1000; i++) + { + f.act("PAUSE_DISPATCH"); + } + assertEquals(429, assertThrows(PrintingException.class, () -> f.act("RESUME_DISPATCH")).getStatus()); + assertEquals("CREDENTIAL_REVOKED", f.act("REVOKE").getString("resultCode")); + assertEquals(401, assertThrows(PrintingException.class, () -> f.service.authenticate(f.token)).getStatus()); + } + + + /** All control paths work with a single real pooled connection and preserve escaped receipt data. */ + @Test + void singleConnectionPreservesStateAndBoundedUnicodeReceipts() throws Exception + { + JdbcConnectionPool pool = JdbcConnectionPool.create("jdbc:h2:mem:controls-pool-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + pool.setMaxConnections(1); + pool.setLoginTimeout(2); + try + { + PrintingService first = new PrintingService(pool, "tenant"); + first.initialize(); + String device = first.enroll(first.createEnrollment("facility", "device").put("deviceName", "device").put("agentVersion", "test")).getString("deviceId"); + first.deviceControlPoll(device, poll()); + JSONObject command = first.deviceAction(device, action("REFRESH_INVENTORY"), "actor"); + JSONObject result = receipt("INVENTORY_REFRESHED").put("message", "\u0001".repeat(1024)); + first.deviceControlReceipt(device, command.getString("commandId"), result); + PrintingService reopened = new PrintingService(pool, "tenant"); + assertTrue(reopened.getDeviceCommand(device, command.getString("commandId")).getJSONObject("receipt").similar(result)); + JSONObject paused = action("PAUSE_DISPATCH"); + first.deviceAction(device, paused, "actor"); + assertTrue(reopened.deviceControlPoll(device, poll()).getJSONObject("desiredState").getBoolean("dispatchPaused")); + assertEquals(2, reopened.deviceCommandsPage(device, 10, null).getJSONArray("commands").length()); + assertEquals("SUCCEEDED", reopened.deviceAction(device, paused, "actor").getString("status")); + } + finally + { + pool.dispose(); + } + } + + + /** Explicit fresh action identity, retained unchanged by callers when retrying. */ + private static JSONObject action(String action) + { + return (new JSONObject().put("requestId", UUID.randomUUID().toString()).put("issuedAt", Instant.now().toString()).put("action", action)); + } + + + /** Advertise only the two implemented remote operations. */ + private static JSONObject poll() + { + return (new JSONObject().put("controlVersion", 1).put("agentVersion", "test").put("lastAppliedRevision", 0) + .put("capabilities", new JSONArray(List.of("REFRESH_INVENTORY", "CANCEL_SPOOL")))); + } + + + /** A synthetic command observation carries no arbitrary executable data. */ + private static JSONObject receipt(String outcome) + { + return (new JSONObject().put("receiptId", UUID.randomUUID().toString()).put("outcome", outcome).put("completedAt", Instant.now().toString())); + } + + + /** Real isolated database and approved synthetic printer for each test. */ + private static final class Fixture + { + private final DriverDataSource source = new DriverDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + private final PrintingService service = new PrintingService(source, "tenant"); + private final String device; + private final String token; + + /** Initialize once; new service instances must observe the same durable state. */ + private Fixture() throws Exception + { + service.initialize(); + JSONObject enrolled = enroll(); + device = enrolled.getString("deviceId"); + token = enrolled.getString("deviceToken"); + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "zebra").put("name", "Zebra").put("formats", new JSONArray(List.of("ZPL"))))); + service.enablePrinter(device, "zebra", true); + } + + /** Pair without sharing another device's credential. */ + private JSONObject enroll() throws Exception + { + return (service.enroll(service.createEnrollment("warehouse", "station").put("deviceName", "station").put("agentVersion", "test"))); + } + + /** Use normal admission and retained document accounting. */ + private String submit(String key) throws Exception + { + return (service.submit(new JSONObject().put("deviceId", device).put("printerId", "zebra").put("title", "Synthetic") + .put("format", "ZPL").put("contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(StandardCharsets.UTF_8))).put("idempotencyKey", key)).getString("jobId")); + } + + /** Apply through the same facade used by authenticated native and HTTP adapters. */ + private JSONObject act(String action) throws Exception + { + return (service.deviceAction(device, action(action), "admin")); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceProcessesTest.java new file mode 100644 index 0000000..09815b5 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceProcessesTest.java @@ -0,0 +1,313 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Native control actions preserve identity, live permissions and row security. + ** Fixtures use in-memory SQL and never contact Windows or physical printers. + *******************************************************************************/ +class PrintingDeviceProcessesTest +{ + /*************************************************************************** + ** Prevent one test's authority from leaking into another test. + ***************************************************************************/ + @AfterEach + void clearContext() + { + QContext.clear(); + } + + + + /*************************************************************************** + ** Every device control and late-job action is available on native selections. + ***************************************************************************/ + @Test + void metadataIncludesControlsHistoryAndDeviceQueue() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + for(var action : PrintingDeviceProcesses.Action.values()) + { + var process = fixture.instance().getProcess(action.process); + assertNotNull(process); + assertEquals(action.job ? PrintingTables.JOB : PrintingTables.DEVICE, process.getTableName()); + assertNotNull(process.getStep("prepareControl")); + } + assertNotNull(fixture.instance().getWidget("printing_device_queue")); + assertNotNull(fixture.instance().getWidget("printing_printer_queue")); + assertTrue(fixture.instance().getTables().keySet().stream().allMatch(name -> name.startsWith("printing_"))); + assertFalse(fixture.instance().getTable(PrintingTables.COMMAND).getFields().containsKey("request_json")); + assertFalse(fixture.instance().getTable(PrintingTables.COMMAND).getFields().containsKey("receipt_json")); + assertEquals(PrintingTables.DEVICE, fixture.instance().getTable(PrintingTables.COMMAND).getField("device_id").getPossibleValueSourceName()); + } + + + + /*************************************************************************** + ** Preparation/review cannot change a device, and revoked permissions block load. + ***************************************************************************/ + @Test + void previewDoesNotMutateAndPermissionRemovalBlocksExecution() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + RunBackendStepInput input = prepared(PrintingDeviceProcesses.Action.PAUSE, fixture.device()); + RunBackendStepOutput review = new RunBackendStepOutput(); + new PrintingDeviceProcesses.Transform().runOnePage(input, review); + assertEquals(1, review.getRecords().size()); + assertEquals(0, fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands").length()); + QContext.getQSession().setPermissions(Set.of()); + assertThrows(QException.class, () -> new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput())); + } + + + + /*************************************************************************** + ** Untrusted extracted values cannot select a foreign device or spoof the actor. + ***************************************************************************/ + @Test + void loadRechecksRowsAndRecordsTheAuthenticatedActor() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + RunBackendStepInput forged = prepared(PrintingDeviceProcesses.Action.PAUSE, fixture.foreignDevice()); + assertThrows(QException.class, () -> new PrintingDeviceProcesses.Load().runOnePage(forged, new RunBackendStepOutput())); + RunBackendStepInput input = prepared(PrintingDeviceProcesses.Action.PAUSE, fixture.device()); + input.addValue("actor", "forged administrator"); + new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()); + assertEquals(1, get(PrintingTables.DEVICE, fixture.device()).getValueInteger("dispatch_paused")); + var command = fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands").getJSONObject(0); + assertEquals("operator", get(PrintingTables.COMMAND, command.getString("commandId")).getValueString("actor")); + new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()); + assertEquals(1, fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands").length()); + } + + + + /*************************************************************************** + ** Selected late jobs produce valid, stable wire request IDs and one command. + ***************************************************************************/ + @Test + void nativeWindowsCancellationReplaysTheSameOwnedJobCommand() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + fixture.service().deviceControlPoll(fixture.device(), new JSONObject().put("controlVersion", 1).put("agentVersion", "test") + .put("capabilities", new org.json.JSONArray(List.of("CANCEL_SPOOL"))).put("lastAppliedRevision", 0)); + String job = submit(fixture.service(), fixture.device()); + fixture.service().claim(fixture.device()); + fixture.service().event(fixture.device(), job, new JSONObject().put("eventId", "test-spooled").put("status", "SPOOLED").put("spoolerJobId", "42")); + RunBackendStepInput input = prepared(PrintingDeviceProcesses.Action.CANCEL_WINDOWS, job); + input.addValue(PrintingDeviceProcesses.CONFIRM, true); + new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()); + new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()); + var commands = fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands"); + assertEquals(1, commands.length()); + assertEquals(job, commands.getJSONObject(0).getString("jobId")); + assertEquals("CANCEL_SPOOL", commands.getJSONObject(0).getString("action")); + assertEquals("PENDING", commands.getJSONObject(0).getString("status")); + assertEquals("SPOOLED", fixture.service().getJob(job).getString("status")); + } + + + + /*************************************************************************** + ** Replaying a native clear action never cancels work added after its receipt. + ***************************************************************************/ + @Test + void nativeQueueClearReplayPreservesLaterWorkAndRequiresConsent() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + String first = submit(fixture.service(), fixture.device()); + RunBackendStepInput input = prepared(PrintingDeviceProcesses.Action.CLEAR, fixture.device()); + assertThrows(QException.class, () -> new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput())); + assertEquals("QUEUED", fixture.service().getJob(first).getString("status")); + input.addValue(PrintingDeviceProcesses.CONFIRM, true); + new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()); + String later = submit(fixture.service(), fixture.device()); + new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()); + assertEquals("FAILED", fixture.service().getJob(first).getString("status")); + assertEquals("QUEUED", fixture.service().getJob(later).getString("status")); + assertEquals(1, fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands").length()); + } + + + + /*************************************************************************** + ** Short projections must use current scoped dependencies, without returning them. + ***************************************************************************/ + @Test + void deviceStatusProjectionReflectsPauseWithoutLeakingExtraColumns() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + new PrintingDeviceProcesses.Load().runOnePage(prepared(PrintingDeviceProcesses.Action.PAUSE, fixture.device()), new RunBackendStepOutput()); + QueryInput query = new QueryInput().withTableName(PrintingTables.DEVICE) + .withFieldNamesToInclude(Set.of("id", "name", "device_status", "control_status")) + .withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, fixture.device())); + QRecord row = new QueryAction().execute(query).getRecords().getFirst(); + assertEquals("PAUSED", row.getValueString("device_status")); + assertEquals("UNKNOWN", row.getValueString("control_status")); + assertFalse(row.getValues().containsKey("dispatch_paused")); + assertFalse(row.getValues().containsKey("control_revision")); + query.setFieldNamesToInclude(Set.of("name", "device_status")); + assertThrows(QException.class, () -> new QueryAction().execute(query)); + } + + + + /*************************************************************************** + ** The record's queue widget is scoped to that device and excludes old outcomes. + ***************************************************************************/ + @Test + void deviceQueueWidgetShowsOnlyItsPendingWork() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + String queued = submit(fixture.service(), fixture.device()); + String canceled = submit(fixture.service(), fixture.device()); + fixture.service().cancel(fixture.device(), canceled); + submit(fixture.service(), fixture.otherDevice()); + RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(fixture.instance().getWidget("printing_device_queue")); + input.setQueryParams(Map.of("id", fixture.device(), "joinName", input.getWidgetMetaData().getDefaultValues().get("joinName").toString())); + var data = (ChildRecordListData) new ChildRecordListRenderer().render(input).getWidgetData(); + assertEquals(List.of(queued), data.getQueryOutput().getRecords().stream().map(row -> row.getValueString("id")).toList()); + input.setQueryParams(Map.of("id", fixture.foreignDevice(), "joinName", input.getWidgetMetaData().getDefaultValues().get("joinName").toString())); + assertThrows(QException.class, () -> new ChildRecordListRenderer().render(input)); + } + + + + /*************************************************************************** + ** State and observation differ; malformed/future timestamps never imply online. + ***************************************************************************/ + @Test + void statusDistinguishesPresenceDesiredStateAndObservedRevision() + { + Instant now = Instant.parse("2026-10-31T12:00:00Z"); + QRecord row = new QRecord().withValue("enabled", 1).withValue("last_seen_at", now.minusSeconds(120).toString()) + .withValue("control_last_poll_at", now.minusSeconds(2).toString()).withValue("control_version", 1) + .withValue("control_revision", 3L).withValue("control_applied_revision", 2L); + assertEquals("ONLINE", PrintingDeviceStatus.presence(row, now)); + assertEquals("PENDING", PrintingDeviceStatus.application(row)); + row.setValue("control_applied_revision", 3L); + assertEquals("APPLIED", PrintingDeviceStatus.application(row)); + row.setValue("dispatch_paused", 1); + assertEquals("PAUSED", PrintingDeviceStatus.presence(row, now)); + row.setValue("connection_state", "DISCONNECTED"); + assertEquals("DISCONNECTED", PrintingDeviceStatus.presence(row, now)); + row.setValue("enabled", 0); + assertEquals("REVOKED", PrintingDeviceStatus.presence(row, now)); + row.setValue("enabled", 1); + row.setValue("connection_state", "CONNECTED"); + row.setValue("control_last_poll_at", "malformed"); + assertEquals("OFFLINE", PrintingDeviceStatus.presence(row, now)); + row.setValue("last_seen_at", now.plusSeconds(120).toString()); + assertEquals("UNKNOWN", PrintingDeviceStatus.presence(row, now)); + } + + + + /*************************************************************************** + ** Persist both identity fields; an old review cannot acquire a fresh timestamp. + ***************************************************************************/ + @Test + void preparationPreservesIdentityAndRejectsIncompleteOrExpiredRequests() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + RunBackendStepInput input = prepared(PrintingDeviceProcesses.Action.PAUSE, fixture.device()); + RunBackendStepOutput again = new RunBackendStepOutput(); + new PrintingDeviceProcesses.Prepare().run(input, again); + assertEquals(input.getValueString(PrintingDeviceProcesses.REQUEST_ID), again.getValueString(PrintingDeviceProcesses.REQUEST_ID)); + assertEquals(input.getValueString(PrintingDeviceProcesses.ISSUED_AT), again.getValueString(PrintingDeviceProcesses.ISSUED_AT)); + input.addValue(PrintingDeviceProcesses.ISSUED_AT, Instant.now().minusSeconds(901).toString()); + assertThrows(QException.class, () -> new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput())); + input.addValue(PrintingDeviceProcesses.ISSUED_AT, null); + assertThrows(QException.class, () -> new PrintingDeviceProcesses.Prepare().run(input, new RunBackendStepOutput())); + } + + + + /*************************************************************************** + ** Synthetic jobs never leave the in-memory server fixture. + ***************************************************************************/ + private static String submit(PrintingService service, String device) throws Exception + { + return (service.submit(new JSONObject().put("deviceId", device).put("printerId", "laser").put("title", "Control fixture") + .put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-1.4 fixture".getBytes(StandardCharsets.UTF_8))) + .put("idempotencyKey", UUID.randomUUID().toString())).getString("jobId")); + } + + + + /*************************************************************************** + ** Real native request preparation keeps tests independent of UUID generation. + ***************************************************************************/ + private static RunBackendStepInput prepared(PrintingDeviceProcesses.Action action, String id) throws QException + { + RunBackendStepInput input = new RunBackendStepInput().withProcessName(action.process).withRecords(List.of(new QRecord().withValue("id", id))); + RunBackendStepOutput output = new RunBackendStepOutput(); + new PrintingDeviceProcesses.Prepare().run(input, output); + input.addValue(PrintingDeviceProcesses.REQUEST_ID, output.getValueString(PrintingDeviceProcesses.REQUEST_ID)); + input.addValue(PrintingDeviceProcesses.ISSUED_AT, output.getValueString(PrintingDeviceProcesses.ISSUED_AT)); + return (input); + } + + + + /*************************************************************************** + ** Metadata reads are subject to normal native permission/record checks. + ***************************************************************************/ + private static QRecord get(String table, String id) throws QException + { + GetInput input = new GetInput(); + input.setTableName(table); + input.setPrimaryKey(id); + return (new GetAction().execute(input).getRecord()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingHomeTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingHomeTest.java index 6b7447d..e3eae75 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingHomeTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingHomeTest.java @@ -98,6 +98,48 @@ class PrintingHomeTest + /*************************************************************************** + ** Control polls prove liveness; explicit administrative intent stays visible. + ***************************************************************************/ + @Test + void controlConnectionAndPendingCommandsRemainVisible() throws Exception + { + DriverDataSource source = source(); + QInstance instance = instance(source); + seed(source, "tenant-a"); + QContext.init(instance, viewer("tenant-a", true)); + try + { + PrintingService service = new PrintingService(source, "tenant-a"); + String device = service.devices().getJSONObject(0).getString("deviceId"); + service.deviceControlPoll(device, new JSONObject().put("controlVersion", 1).put("agentVersion", "test") + .put("capabilities", new JSONArray(List.of("REFRESH_INVENTORY"))).put("lastAppliedRevision", 0)); + assertEquals(0, ((StatisticsData) render(instance, "printingHomeOffline")).getCount()); + service.deviceAction(device, new JSONObject().put("requestId", UUID.randomUUID().toString()).put("issuedAt", java.time.Instant.now().toString()) + .put("action", "REFRESH_INVENTORY"), "viewer"); + assertEquals(1, ((StatisticsData) render(instance, "printingHomeControls")).getCount()); + assertTrue(JsonUtils.toJson(render(instance, "printingHomeAttention")).contains("REFRESH_INVENTORY")); + try(Connection connection = source.getConnection(); PreparedStatement expired = connection.prepareStatement("UPDATE printing_device_command SET expires_at=? WHERE device_id=?")) + { + expired.setString(1, java.time.Instant.now().minusSeconds(1).toString()); + expired.setString(2, device); + expired.executeUpdate(); + } + assertEquals(0, ((StatisticsData) render(instance, "printingHomeControls")).getCount()); + assertTrue(JsonUtils.toJson(render(instance, "printingHomeAttention")).contains("EXPIRED")); + service.deviceAction(device, new JSONObject().put("requestId", UUID.randomUUID().toString()).put("issuedAt", java.time.Instant.now().toString()) + .put("action", "DISCONNECT"), "viewer"); + assertEquals(1, ((StatisticsData) render(instance, "printingHomeOffline")).getCount()); + assertTrue(JsonUtils.toJson(render(instance, "printingHomeAttention")).contains("Administratively disconnected")); + } + finally + { + QContext.clear(); + } + } + + + /*************************************************************************** ** Empty state guides setup; an unprivileged or unscoped caller gets no data. ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java index cc49aee..d51e349 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java @@ -466,7 +466,7 @@ class PrintingProcessesTest /*************************************************************************** ** One database, two tenants and multiple devices exercise real query scoping. ***************************************************************************/ - private static Fixture fixture() throws Exception + static Fixture fixture() throws Exception { return (fixture(null)); } @@ -545,7 +545,7 @@ class PrintingProcessesTest /*************************************************************************** ** Per-test handles, without exposed device tokens. ***************************************************************************/ - private record Fixture(QInstance instance, PrintingService service, String device, String otherDevice, String printerId, String otherPrinterId, String foreignPrinterId, String foreignDevice) + record Fixture(QInstance instance, PrintingService service, String device, String otherDevice, String printerId, String otherPrinterId, String foreignPrinterId, String foreignDevice) { } } diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java new file mode 100644 index 0000000..a27ef28 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java @@ -0,0 +1,80 @@ +/* + * 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.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData; +import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1; +import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** Published native extension contract and semantic compatibility baseline. */ +class PrintingUiApiContractTest +{ + /** Regeneration is explicit; ordinary builds detect accidental contract drift. */ + @Test + void publishedContractMatchesRegisteredSpecs() throws Exception + { + var extension = PrintingUiApi.extension(); + var version = new MiddlewareVersionV1(); + String generated = UiApiContract.generateExtensionYaml(version, extension); + Path contract = Path.of("src/main/resources/openapi/qqq-v1/printing.yaml"); + Path baseline = Path.of("src/test/resources/openapi/printing-1.0.0.yaml"); + if(Boolean.getBoolean("printing.openapi.update")) + { + Files.createDirectories(contract.getParent()); + Files.writeString(contract, generated); + } + if(Boolean.getBoolean("printing.openapi.initializeBaseline") && !Files.exists(baseline)) + { + Files.createDirectories(baseline.getParent()); + Files.writeString(baseline, generated); + } + assertEquals(List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(baseline), 1, + "mvnw.cmd -Dtest=PrintingUiApiContractTest -Dprinting.openapi.update=true test")); + var model = version.generateExtensionOpenAPIModel("qqq", extension); + assertEquals(17, model.getPaths().size()); + assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/"))); + assertEquals("1.0.0", extension.getSemanticVersion()); + assertTrue(generated.contains("X-QQQ-Device-Token")); + assertTrue(generated.contains("application/octet-stream")); + assertTrue(generated.contains("principalId")); + } + + + /** Registration replaces this extension without duplicating its native routes. */ + @Test + void registrationIsStable() + { + QInstance instance = new QInstance(); + PrintingUiApi.register(instance); + PrintingUiApi.register(instance); + assertEquals(Set.of("printing"), MiddlewareSpecExtensionsMetaData.of(instance).getExtensions().keySet()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java new file mode 100644 index 0000000..5f979c1 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java @@ -0,0 +1,345 @@ +/* + * 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.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig; +import io.javalin.Javalin; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** Native session, proof, principal, tenant and child-record boundaries over real QQQ/H2. */ +class PrintingUiApiTest +{ + @TempDir + Path directory; + + + /** Each test owns its native context. */ + @AfterEach + void clearContext() + { + QContext.clear(); + } + + + /** Device proof cannot authorize native routes without a current authenticated QQQ user. */ + @Test + void actualTableAuthHttpRequiresExplicitBindingAndPreservesBinaryContent() throws Exception + { + String jdbc = "jdbc:h2:file:" + directory.resolve("native-http").toString().replace('\\', '/'); + DriverDataSource source = new DriverDataSource(jdbc, "sa", ""); + String password = "synthetic-native-api-password-1234"; + PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", password)); + PrintingService service = new PrintingService(source, "demo"); + JSONObject enrolled = service.enroll(service.createEnrollment("default", "Synthetic device").put("deviceName", "Synthetic device").put("agentVersion", "test")); + String device = enrolled.getString("deviceId"); + String proof = enrolled.getString("deviceToken"); + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue").put("formats", new JSONArray(List.of("ZPL"))))); + service.enablePrinter(device, "queue", true); + byte[] content = "^XA^FO10,10^FDsynthetic^FS^XZ".getBytes(StandardCharsets.UTF_8); + String job = service.submit(new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Synthetic") + .put("format", "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(content)).put("idempotencyKey", "http")).getString("jobId"); + AtomicReference javalin = new AtomicReference<>(); + QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server -> + { + host.configure(server, false); + server.withJavalinConfigurationCustomizer(javalin::set); + })); + String base = "http://127.0.0.1:" + javalin.get().port(); + String prefix = "/qqq/v1" + PrintingUiApi.PREFIX; + try(HttpClient client = HttpClient.newHttpClient()) + { + assertEquals(401, send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), null, device, proof).statusCode()); + HttpRequest bearerOnly = HttpRequest.newBuilder(URI.create(base + prefix + "/device/jobs/claim")).header("Authorization", "Bearer " + proof) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build(); + assertEquals(401, client.send(bearerOnly, HttpResponse.BodyHandlers.discarding()).statusCode()); + assertEquals("QUEUED", service.getJob(job).getString("status")); + HttpRequest login = HttpRequest.newBuilder(URI.create(base + "/qqq/v1/manageSession")).header("Origin", base) + .header("Authorization", "Basic " + Base64.getEncoder().encodeToString(("operator:" + password).getBytes(StandardCharsets.UTF_8))) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build(); + HttpResponse authenticated = client.send(login, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, authenticated.statusCode()); + String cookie = authenticated.headers().allValues("set-cookie").stream().filter(value -> value.startsWith("sessionUUID=")).findFirst().orElseThrow().split(";", 2)[0]; + JSONObject session = json(send(client, base, prefix + "/session", "GET", null, cookie, null, null), 200); + assertEquals("operator", session.getString("principalId")); + assertEquals("demo", session.getString("tenantId")); + assertFalse(session.has("deviceId")); + assertEquals(403, send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), cookie, device, proof).statusCode()); + assertEquals(403, send(client, base, prefix + "/device/bind", "POST", new JSONObject(), cookie, device, "wrong").statusCode()); + json(send(client, base, prefix + "/device/bind", "POST", new JSONObject(), cookie, device, proof), 200); + assertEquals(403, send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), cookie, null, null).statusCode()); + JSONObject others = service.enrollForUser(service.createEnrollment("default", "Other owner").put("deviceName", "Other owner").put("agentVersion", "test"), "another-principal"); + assertEquals(403, send(client, base, prefix + "/device/heartbeat", "POST", new JSONObject().put("agentVersion", "test"), cookie, others.getString("deviceId"), others.getString("deviceToken")).statusCode()); + assertEquals(device, json(send(client, base, prefix + "/session", "GET", null, cookie, device, proof), 200).getString("deviceId")); + assertEquals(job, json(send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), cookie, device, proof), 200).getJSONObject("job").getString("jobId")); + HttpResponse downloaded = send(client, base, prefix + "/device/jobs/" + job + "/content", "GET", null, cookie, device, proof); + assertEquals(200, downloaded.statusCode()); + assertArrayEquals(content, downloaded.body()); + JSONObject openApi = json(send(client, base, "/qqq/v1/openapi.json", "GET", null, null, null, null), 200); + assertTrue(openApi.getJSONObject("paths").has(prefix + "/devices/enroll")); + assertTrue(openApi.toString().contains("application/octet-stream")); + JSONObject action = action("REVOKE"); + json(send(client, base, prefix + "/admin/devices/" + device + "/actions", "POST", action, cookie, null, null), 200); + assertEquals(403, send(client, base, prefix + "/device/heartbeat", "POST", new JSONObject().put("agentVersion", "test"), cookie, device, proof).statusCode()); + JSONObject nativeEnrollment = service.createEnrollment("default", "Native enrollment").put("deviceName", "Native enrollment").put("agentVersion", "test"); + JSONObject fresh = json(send(client, base, prefix + "/devices/enroll", "POST", nativeEnrollment, cookie, null, null), 200); + service.authorizeAgent(fresh.getString("deviceId"), fresh.getString("deviceToken"), "operator", false); + JSONObject unavailable = service.createEnrollment("inaccessible-facility", "Denied enrollment").put("deviceName", "Denied enrollment").put("agentVersion", "test"); + assertEquals(403, send(client, base, prefix + "/devices/enroll", "POST", unavailable, cookie, null, null).statusCode()); + assertEquals("inaccessible-facility", service.enrollmentFacility(unavailable.getString("enrollmentCode"))); + } + finally + { + launcher.stop(); + } + } + + + /** Native context permissions and principal ownership are re-evaluated on each adapter invocation. */ + @Test + void currentPrincipalTenantPermissionAndRevocationAreAllRequired() throws Exception + { + var f = PrintingProcessesTest.fixture(); + JSONObject enrolled = f.service().enroll(f.service().createEnrollment("default", "Bound").put("deviceName", "Bound").put("agentVersion", "test")); + String device = enrolled.getString("deviceId"); + String proof = enrolled.getString("deviceToken"); + assertThrows(PrintingException.class, () -> PrintingAgentAuthorization.device(device, proof, false)); + PrintingAgentAuthorization.device(device, proof, true); + QContext.getQSession().setUser(new QUser().withIdReference("different-user")); + assertThrows(PrintingException.class, () -> PrintingAgentAuthorization.device(device, proof, false)); + assertThrows(PrintingException.class, () -> PrintingAgentAuthorization.device(device, proof, true)); + QContext.getQSession().setUser(new QUser().withIdReference("operator")); + QContext.getQSession().setPermissions(Set.of()); + assertThrows(QException.class, () -> PrintingAgentAuthorization.device(device, proof, false)); + QContext.setQSession(new QSession().withUser(new QUser().withIdReference("operator")).withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-b")); + assertThrows(QException.class, () -> PrintingAgentAuthorization.device(device, proof, false)); + QContext.setQSession(new QSession().withUser(new QUser().withIdReference("operator")).withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-a")); + assertThrows(QException.class, () -> PrintingControlAuthorization.record(PrintingTables.DEVICE, f.foreignDevice())); + f.service().deviceAction(device, action("REVOKE"), "administrator"); + assertThrows(PrintingException.class, () -> PrintingAgentAuthorization.device(device, proof, false)); + } + + + /** An accessible device never grants hidden child jobs or lets claim choose a different job. */ + @Test + void claimAndClearHonorJobRowsAndQueueSnapshotRace() throws Exception + { + var f = PrintingProcessesTest.fixture(); + String hidden = submit(f.service(), f.device(), "hidden"); + String visible = submit(f.service(), f.device(), "visible"); + f.instance().addSecurityKeyType(new QSecurityKeyType().withName("job_scope")); + f.instance().getTable(PrintingTables.JOB).withRecordSecurityLock(new RecordSecurityLock().withFieldName("title").withSecurityKeyType("job_scope")); + QContext.getQSession().withSecurityKeyValue("job_scope", "visible"); + assertThrows(QException.class, () -> PrintingControlAuthorization.deviceAction(f.device(), action("CLEAR_QUEUE"))); + assertEquals("QUEUED", f.service().getJob(hidden).getString("status")); + assertEquals(visible, PrintingAgentAuthorization.claim(f.device()).getJSONObject("job").getString("jobId")); + assertEquals("QUEUED", f.service().getJob(hidden).getString("status")); + Set expected = f.service().queuedJobIdsForControl(f.device()); + String newcomer = submit(f.service(), f.device(), "newcomer"); + assertEquals(409, assertThrows(PrintingException.class, () -> f.service().deviceAction(f.device(), action("CLEAR_QUEUE"), "operator", expected)).getStatus()); + assertEquals("QUEUED", f.service().getJob(newcomer).getString("status")); + } + + + /** Shared-printer queue reviews retain Get-only policy with one printer lookup per request. */ + @Test + void clearDeduplicatesPrinterReviewWithoutSkippingGetPolicy() throws Exception + { + var f = PrintingProcessesTest.fixture(); + String first = submit(f.service(), f.device(), "first"); + String second = submit(f.service(), f.device(), "second"); + f.instance().getTable(PrintingTables.PRINTER).getPermissionRules().withCustomPermissionChecker(new QCodeReference(PrinterGetPolicy.class)); + PrinterGetPolicy.calls.set(0); + PrinterGetPolicy.denied = true; + assertThrows(QException.class, () -> PrintingControlAuthorization.deviceAction(f.device(), action("CLEAR_QUEUE"))); + assertEquals("QUEUED", f.service().getJob(first).getString("status")); + assertEquals("QUEUED", f.service().getJob(second).getString("status")); + PrinterGetPolicy.calls.set(0); + PrinterGetPolicy.denied = false; + assertEquals(2, PrintingControlAuthorization.deviceAction(f.device(), action("CLEAR_QUEUE")).getInt("cancelledJobCount")); + assertEquals(1, PrinterGetPolicy.calls.get()); + } + + + /** A host can distinguish Get authority from query visibility; this must remain effective. */ + public static class PrinterGetPolicy extends PrintingReadPermission + { + static final AtomicInteger calls = new AtomicInteger(); + static boolean denied; + + /** Apply the deliberately Get-only test policy. */ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException + { + super.checkPermissionsThrowing(input, metadata); + if(input instanceof GetInput) + { + calls.incrementAndGet(); + if(denied) + { + throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("Synthetic Get-only policy"); + } + } + } + } + + + /** Native selection preserves the oldest eligible job, including stable ID ordering on ties. */ + @Test + void nativeClaimUsesDurableCreationOrder() throws Exception + { + var f = PrintingProcessesTest.fixture(); + String newer = submit(f.service(), f.device(), "newer"); + String older = submit(f.service(), f.device(), "older"); + var backend = (com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) f.instance().getBackend(f.instance().getTable(PrintingTables.JOB).getBackendName()); + try(var connection = new DriverDataSource(backend.getJdbcUrl(), "sa", "").getConnection()) + { + PrintingService.update(connection, "UPDATE printing_job SET created_at=? WHERE id=?", "2026-01-02T00:00:00Z", newer); + PrintingService.update(connection, "UPDATE printing_job SET created_at=? WHERE id=?", "2026-01-01T00:00:00Z", older); + } + assertEquals(older, PrintingAgentAuthorization.claim(f.device()).getJSONObject("job").getString("jobId")); + f.service().event(f.device(), older, new JSONObject().put("eventId", "received-old").put("status", "RECEIVED")); + assertEquals(newer, PrintingAgentAuthorization.claim(f.device()).getJSONObject("job").getString("jobId")); + } + + + /** Printer eligibility is applied before the bounded candidate limit, not after it. */ + @Test + void olderDisabledPrinterJobsCannotStarveEligibleClaim() throws Exception + { + var f = PrintingProcessesTest.fixture(); + for(int index = 0; index < 20; index++) + { + submit(f.service(), f.device(), "disabled-" + index); + } + f.service().inventory(f.device(), new JSONArray() + .put(new JSONObject().put("printerId", "laser").put("name", "Disabled").put("formats", new JSONArray(List.of("PDF")))) + .put(new JSONObject().put("printerId", "ready").put("name", "Ready").put("formats", new JSONArray(List.of("PDF"))))); + f.service().enablePrinter(f.device(), "laser", false); + f.service().enablePrinter(f.device(), "ready", true); + String ready = f.service().submit(new JSONObject().put("deviceId", f.device()).put("printerId", "ready").put("title", "eligible") + .put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-synthetic".getBytes(StandardCharsets.UTF_8))) + .put("idempotencyKey", "eligible")).getString("jobId"); + assertEquals(ready, PrintingAgentAuthorization.claim(f.device()).getJSONObject("job").getString("jobId")); + assertEquals(20, f.service().queuedJobs(f.device()).length()); + } + + + /** Inventory omission cannot update hidden rows or a concurrent unreviewed row. */ + @Test + void inventoryRequiresAllExistingRowsAndExactLockedSnapshot() throws Exception + { + var f = PrintingProcessesTest.fixture(); + Set before = PrintingAgentAuthorization.inventory(f.device()); + JSONArray snapshot = new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("formats", new JSONArray(List.of("PDF")))) + .put(new JSONObject().put("printerId", "hidden").put("name", "Hidden printer").put("formats", new JSONArray(List.of("PDF")))); + f.service().inventory(f.device(), snapshot); + assertEquals(409, assertThrows(PrintingException.class, () -> f.service().inventory(f.device(), new JSONArray(), before)).getStatus()); + assertTrue(f.service().printers(f.device()).getJSONObject(0).getBoolean("available")); + f.instance().addSecurityKeyType(new QSecurityKeyType().withName("printer_scope")); + f.instance().getTable(PrintingTables.PRINTER).withRecordSecurityLock(new RecordSecurityLock().withFieldName("name").withSecurityKeyType("printer_scope")); + QContext.getQSession().withSecurityKeyValue("printer_scope", "Packing laser"); + assertThrows(QException.class, () -> PrintingAgentAuthorization.inventory(f.device())); + assertEquals(2, f.service().printers(f.device()).length()); + } + + + /** Synthetic PDF bytes are queued only; no renderer or printer is invoked. */ + private static String submit(PrintingService service, String device, String title) throws Exception + { + return (service.submit(new JSONObject().put("deviceId", device).put("printerId", "laser").put("title", title).put("format", "PDF") + .put("contentBase64", Base64.getEncoder().encodeToString("%PDF-synthetic".getBytes(StandardCharsets.UTF_8))).put("idempotencyKey", title)).getString("jobId")); + } + + + /** Preserve the same request across retries. */ + private static JSONObject action(String action) + { + return (new JSONObject().put("requestId", UUID.randomUUID().toString()).put("issuedAt", Instant.now().toString()).put("action", action)); + } + + + /** Real HTTP uses native session cookies plus separate device proof headers. */ + private static HttpResponse send(HttpClient client, String base, String path, String method, JSONObject body, String cookie, String device, String proof) throws Exception + { + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(base + path)).header("Origin", base).header("X-QQQ-Client", "api/qqq-print-agent"); + if(cookie != null) + { + request.header("Cookie", cookie); + } + if(device != null) + { + request.header(PrintingAgentAuthorization.DEVICE_HEADER, device); + } + if(proof != null) + { + request.header(PrintingAgentAuthorization.TOKEN_HEADER, proof); + } + request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body.toString())); + if(body != null) + { + request.header("Content-Type", "application/json"); + } + return (client.send(request.build(), HttpResponse.BodyHandlers.ofByteArray())); + } + + + /** Check status before interpreting success; no secret-bearing body is echoed on failure. */ + private static JSONObject json(HttpResponse response, int status) + { + assertEquals(status, response.statusCode()); + return (new JSONObject(new String(response.body(), StandardCharsets.UTF_8))); + } +} diff --git a/src/test/resources/openapi/printing-1.0.0.yaml b/src/test/resources/openapi/printing-1.0.0.yaml new file mode 100644 index 0000000..88032c2 --- /dev/null +++ b/src/test/resources/openapi/printing-1.0.0.yaml @@ -0,0 +1,2078 @@ +components: + schemas: + BasicErrorResponseV1: + description: "The error body of every UI API operation (core and extensions)." + properties: + error: + description: "The error message (the same as message; kept for clients of\ + \ UI API 1.0 to 1.5)." + type: "string" + code: + description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\ + \ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\ + \ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\ + \ Extensions may add codes; treat an unknown code by the HTTP status.\ + \ Since 1.6.0." + type: "string" + message: + description: "A message to show the user. Since 1.6.0." + type: "string" + fieldErrors: + description: "Errors about particular request fields, when known. Since\ + \ 1.6.0." + items: + properties: + field: + description: "The field (or parameter) name." + type: "string" + message: + description: "The error about that field." + type: "string" + code: + description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\ + \ PATTERN." + type: "string" + type: "object" + type: "array" + traceId: + description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\ + \ response header); quote it when reporting a problem. Since 1.6.0." + type: "string" + type: "object" + PrintingAcknowledgmentV1: + properties: + accepted: + type: "boolean" + required: + - "accepted" + type: "object" + PrintingActionRequestV1: + properties: + requestId: + maxLength: 128 + type: "string" + issuedAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + action: + enum: + - "PAUSE_DISPATCH" + - "RESUME_DISPATCH" + - "DISCONNECT" + - "RECONNECT" + - "REVOKE" + - "REFRESH_INVENTORY" + - "CLEAR_QUEUE" + - "CANCEL_SPOOL" + type: "string" + jobId: + maxLength: 128 + type: "string" + required: + - "requestId" + - "issuedAt" + - "action" + type: "object" + PrintingActionV1: + properties: + commandId: + maxLength: 128 + type: "string" + requestId: + maxLength: 128 + type: "string" + issuedAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + deviceId: + maxLength: 128 + type: "string" + action: + enum: + - "PAUSE_DISPATCH" + - "RESUME_DISPATCH" + - "DISCONNECT" + - "RECONNECT" + - "REVOKE" + - "REFRESH_INVENTORY" + - "CLEAR_QUEUE" + - "CANCEL_SPOOL" + type: "string" + status: + enum: + - "PENDING" + - "SUCCEEDED" + - "FAILED" + - "EXPIRED" + - "OUTCOME_UNKNOWN" + type: "string" + createdAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + expiresAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + jobId: + maxLength: 128 + type: "string" + resultCode: + maxLength: 80 + type: "string" + cancelledJobCount: + type: "integer" + receipt: + $ref: "#/components/schemas/PrintingReceiptRequestV1" + required: + - "commandId" + - "requestId" + - "issuedAt" + - "deviceId" + - "action" + - "status" + - "createdAt" + - "expiresAt" + type: "object" + PrintingActionsV1: + properties: + commands: + items: + $ref: "#/components/schemas/PrintingActionV1" + type: "array" + nextCursor: + maxLength: 128 + nullable: true + type: "string" + required: + - "commands" + - "nextCursor" + type: "object" + PrintingClaimV1: + properties: + job: + $ref: "#/components/schemas/PrintingJobV1" + nullable: true + required: + - "job" + type: "object" + PrintingCommandV1: + properties: + commandId: + maxLength: 128 + type: "string" + requestId: + maxLength: 128 + type: "string" + action: + enum: + - "REFRESH_INVENTORY" + - "CANCEL_SPOOL" + type: "string" + createdAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + expiresAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + jobId: + maxLength: 128 + type: "string" + required: + - "commandId" + - "requestId" + - "action" + - "createdAt" + - "expiresAt" + type: "object" + PrintingControlPollRequestV1: + properties: + controlVersion: + type: "integer" + agentVersion: + maxLength: 100 + type: "string" + capabilities: + items: + enum: + - "REFRESH_INVENTORY" + - "CANCEL_SPOOL" + type: "string" + type: "array" + lastAppliedRevision: + type: "integer" + required: + - "controlVersion" + - "agentVersion" + - "capabilities" + - "lastAppliedRevision" + type: "object" + PrintingControlPollV1: + properties: + controlVersion: + type: "integer" + serverTime: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + pollIntervalSeconds: + type: "integer" + desiredState: + $ref: "#/components/schemas/PrintingDesiredStateV1" + commands: + items: + $ref: "#/components/schemas/PrintingCommandV1" + type: "array" + required: + - "controlVersion" + - "serverTime" + - "pollIntervalSeconds" + - "desiredState" + - "commands" + type: "object" + PrintingDesiredStateV1: + properties: + revision: + type: "integer" + dispatchPaused: + type: "boolean" + connectionState: + enum: + - "CONNECTED" + - "DISCONNECTED" + type: "string" + required: + - "revision" + - "dispatchPaused" + - "connectionState" + type: "object" + PrintingEmptyV1: + type: "object" + PrintingEnrollmentRequestV1: + properties: + enrollmentCode: + maxLength: 128 + type: "string" + deviceName: + maxLength: 1024 + type: "string" + agentVersion: + maxLength: 100 + type: "string" + required: + - "enrollmentCode" + - "deviceName" + - "agentVersion" + type: "object" + PrintingEnrollmentV1: + properties: + protocolVersion: + type: "integer" + deviceId: + maxLength: 128 + type: "string" + deviceToken: + description: "Private device proof, returned once; never log." + maxLength: 128 + type: "string" + pollIntervalSeconds: + type: "integer" + required: + - "protocolVersion" + - "deviceId" + - "deviceToken" + - "pollIntervalSeconds" + type: "object" + PrintingEventRequestV1: + properties: + eventId: + maxLength: 128 + type: "string" + status: + enum: + - "RECEIVED" + - "SUBMITTING" + - "SPOOLED" + - "SPOOLER_COMPLETED" + - "FAILED" + - "OUTCOME_UNKNOWN" + type: "string" + message: + maxLength: 2048 + type: "string" + spoolerJobId: + maxLength: 128 + type: "string" + required: + - "eventId" + - "status" + type: "object" + PrintingHeartbeatRequestV1: + properties: + agentVersion: + maxLength: 100 + type: "string" + profile: + $ref: "#/components/schemas/PrintingProfileV1" + required: + - "agentVersion" + type: "object" + PrintingHeartbeatV1: + properties: + serverTime: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + required: + - "serverTime" + type: "object" + PrintingInventoryRequestV1: + properties: + printers: + items: + $ref: "#/components/schemas/PrintingPrinterV1" + type: "array" + required: + - "printers" + type: "object" + PrintingJobV1: + properties: + jobId: + maxLength: 128 + type: "string" + deviceId: + maxLength: 128 + type: "string" + printerId: + maxLength: 128 + type: "string" + title: + maxLength: 1024 + type: "string" + format: + enum: + - "PDF" + - "ZPL" + type: "string" + contentSha256: + maxLength: 64 + type: "string" + contentLength: + type: "integer" + options: + $ref: "#/components/schemas/PrintingOptionsV1" + createdAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + status: + enum: + - "QUEUED" + - "CLAIMED" + - "RECEIVED" + - "SUBMITTING" + - "SPOOLED" + - "SPOOLER_COMPLETED" + - "FAILED" + - "OUTCOME_UNKNOWN" + type: "string" + message: + maxLength: 2048 + type: "string" + spoolerJobId: + maxLength: 128 + type: "string" + required: + - "jobId" + - "deviceId" + - "printerId" + - "title" + - "format" + - "contentSha256" + - "contentLength" + - "options" + - "createdAt" + - "status" + type: "object" + PrintingJobsV1: + properties: + jobs: + items: + $ref: "#/components/schemas/PrintingJobV1" + type: "array" + required: + - "jobs" + type: "object" + PrintingOptionsV1: + properties: + copies: + type: "integer" + scale: + enum: + - "ACTUAL_SIZE" + - "FIT" + type: "string" + paperWidthMm: + type: "number" + paperHeightMm: + type: "number" + type: "object" + PrintingPrinterV1: + properties: + printerId: + maxLength: 128 + type: "string" + name: + maxLength: 1024 + type: "string" + driverName: + maxLength: 1024 + type: "string" + portName: + maxLength: 1024 + type: "string" + status: + enum: + - "READY" + - "OFFLINE" + - "PAUSED" + - "ERROR" + - "UNKNOWN" + type: "string" + formats: + items: + enum: + - "PDF" + - "ZPL" + type: "string" + type: "array" + displayName: + maxLength: 512 + type: "string" + description: + maxLength: 4096 + type: "string" + location: + maxLength: 512 + type: "string" + required: + - "printerId" + - "name" + - "formats" + type: "object" + PrintingProfileV1: + properties: + name: + maxLength: 512 + nullable: true + type: "string" + description: + maxLength: 4096 + nullable: true + type: "string" + location: + maxLength: 512 + nullable: true + type: "string" + type: "object" + PrintingReceiptRequestV1: + properties: + receiptId: + maxLength: 128 + type: "string" + outcome: + enum: + - "INVENTORY_REFRESHED" + - "CANCEL_REQUESTED" + - "ALREADY_ABSENT" + - "OWNERSHIP_MISMATCH" + - "UNSUPPORTED" + - "FAILED" + - "EXPIRED" + - "OUTCOME_UNKNOWN" + type: "string" + completedAt: + description: "RFC3339 UTC ending in uppercase Z." + format: "date-time" + maxLength: 40 + type: "string" + message: + maxLength: 1024 + type: "string" + required: + - "receiptId" + - "outcome" + - "completedAt" + type: "object" + PrintingRetryRequestV1: + properties: + requestId: + maxLength: 128 + type: "string" + confirmDuplicateRisk: + type: "boolean" + required: + - "requestId" + type: "object" + PrintingSessionV1: + properties: + principalId: + maxLength: 200 + type: "string" + tenantId: + maxLength: 200 + type: "string" + deviceId: + maxLength: 128 + type: "string" + required: + - "principalId" + - "tenantId" + type: "object" + PrintingTestRequestV1: + properties: + requestId: + maxLength: 128 + type: "string" + printerId: + maxLength: 128 + type: "string" + format: + enum: + - "PDF" + - "ZPL" + type: "string" + testProfile: + maxLength: 40 + type: "string" + required: + - "requestId" + - "printerId" + - "format" + type: "object" + securitySchemes: + sessionUuidCookie: + description: "The session cookie set by POST /qqq/v1/manageSession (web clients)." + in: "cookie" + name: "sessionUUID" + type: "apiKey" + bearerAuth: + bearerFormat: "JWT or session UUID" + description: "`Authorization: Bearer `, where the token is either\n-\ + \ an OAuth2 access token (a signed JWT) issued by the app's identity provider\ + \ to the app's client,\n or to its native client (`values.nativeClientId`\ + \ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\ + \ grant or auth-code + PKCE. It is verified against the\n provider's keys\ + \ (issuer, audience, expiry, signature) and gives the same user and permissions\n\ + \ as signing in on the web. When it expires (401), refresh it at the provider\ + \ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\ + \ which the server keeps refreshed." + scheme: "bearer" + type: "http" + oauth2: + description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\ + \ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\ + \ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\ + \ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\ + \ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\ + \ grant (`device_authorization_endpoint`, RFC 8628), then send the access\ + \ token\nas `Authorization: Bearer`." + flows: + authorizationCode: + authorizationUrl: "/authorize" + scopes: + email: "Email address" + offline_access: "A refresh token, to renew the access token without signing\ + \ in again" + openid: "OpenID Connect sign-in" + profile: "Name, username and groups (permissions come from the groups)" + tokenUrl: "/token" + type: "oauth2" +info: + contact: + email: "contact@kingsrook.com" + description: "Native current-user printing with secondary device proof and device-control-v1." + title: "QQQ Middleware API extension: printing" + version: "1.0.0" +openapi: "3.0.3" +paths: + /qqq/v1/qbit/printing/session: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingSession" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: false + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: false + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingSessionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Session" + tags: + - "printing" + /qqq/v1/qbit/printing/device/bind: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingBind" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEmptyV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Bind" + tags: + - "printing" + /qqq/v1/qbit/printing/devices/enroll: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingEnroll" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEnrollmentRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEnrollmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Enroll" + tags: + - "printing" + /qqq/v1/qbit/printing/device/heartbeat: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingHeartbeat" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingHeartbeatRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingHeartbeatV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Heartbeat" + tags: + - "printing" + /qqq/v1/qbit/printing/device/printers: + put: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingInventory" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingInventoryRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Inventory" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/claim: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingClaim" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEmptyV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingClaimV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Claim" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/queued: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingQueued" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingJobsV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Queued" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/{jobId}/content: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingContent" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "jobId" + required: true + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/octet-stream: + schema: + format: "binary" + type: "string" + description: "Verified retained document bytes" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Content" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/{jobId}/events: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingEvent" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "jobId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEventRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Event" + tags: + - "printing" + /qqq/v1/qbit/printing/device/test-print: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingTest" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingTestRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingJobV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Test" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/{jobId}/retry: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingRetry" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "jobId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingRetryRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingJobV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Retry" + tags: + - "printing" + /qqq/v1/qbit/printing/device/jobs/{jobId}/cancel: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingCancel" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "jobId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingEmptyV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Cancel" + tags: + - "printing" + /qqq/v1/qbit/printing/device/control/poll: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingPoll" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingControlPollRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingControlPollV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Poll" + tags: + - "printing" + /qqq/v1/qbit/printing/device/control/commands/{commandId}/receipt: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingReceipt" + parameters: + - in: "header" + name: "X-QQQ-Device-Id" + required: true + schema: + maxLength: 36 + type: "string" + - description: "Private secondary device proof; never log." + in: "header" + name: "X-QQQ-Device-Token" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "commandId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingReceiptRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingAcknowledgmentV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Receipt" + tags: + - "printing" + /qqq/v1/qbit/printing/admin/devices/{deviceId}/actions: + post: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingAction" + parameters: + - in: "path" + name: "deviceId" + required: true + schema: + maxLength: 128 + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingActionRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Action" + tags: + - "printing" + /qqq/v1/qbit/printing/admin/devices/{deviceId}/commands: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingCommands" + parameters: + - in: "path" + name: "deviceId" + required: true + schema: + maxLength: 128 + type: "string" + - description: "1 through 200; default 50." + in: "query" + name: "limit" + schema: + type: "integer" + - in: "query" + name: "before" + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingActionsV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Commands" + tags: + - "printing" + /qqq/v1/qbit/printing/admin/devices/{deviceId}/commands/{commandId}: + get: + description: "Requires the current QQQ user session, printing_admin permission\ + \ and native record access. Device operations additionally require secondary\ + \ device headers bound to this principal. No primary device-bearer authentication." + operationId: "printingCommand" + parameters: + - in: "path" + name: "deviceId" + required: true + schema: + maxLength: 128 + type: "string" + - in: "path" + name: "commandId" + required: true + schema: + maxLength: 128 + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Invalid request" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Native QQQ sign-in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Permission, device proof or user binding denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Record not found" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "State or request conflict" + 410: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Request or content expired" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bounded capacity reached" + security: + - sessionUuidCookie: + - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" + summary: "Printing Command" + tags: + - "printing" +servers: +- description: "The QQQ server that serves this document (paths include /qqq/v1)." + url: "/" +tags: +- description: "Operations of the printing extension." + name: "printing" -- 2.54.0 From 3435686e7eb157fee36cc167e268072f6d8e843d Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 09:18:43 -0500 Subject: [PATCH 20/35] fix: enforce native permissions during printing selections Wrap standard extraction with explicit current-user enforcement after callbacks, and authorize preview counts before the standard tenant-filtered count. Apply the wrapper to all14 printing selection processes so trusted internal defaults cannot skip table policies before Transform/Load checks. Validation:109 Java tests pass with zero failures/errors/skips and zero Checkstyle. New regressions exercise count/query denial, callback weakening, selected/filter tenant isolation and process registrations. Native API classes remain byte-identical to the23-check real client/server interop artifact. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 5 +- docs/OPERATIONS.md | 9 +- docs/SESSION-STATE.md | 2 +- docs/TODO.md | 5 +- .../printing/PrintingDeviceProcesses.java | 3 +- .../qbits/printing/PrintingExtract.java | 56 ++++++ .../qbits/printing/PrintingJobProcesses.java | 3 +- .../qbits/printing/PrintingProcesses.java | 3 +- .../qbits/printing/PrintingExtractTest.java | 162 ++++++++++++++++++ 9 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingExtract.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingExtractTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 17490d3..253d7fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,15 @@ owned Windows-job cancellation and bounded command history. - Show device presence, desired versus applied control state, related printers, jobs and queue; include connection/control issues in the printing dashboard. -- Register semantic printing extension1.0.0 at `/qqq/v1/qbit/printing`, using +- Register semantic printing extension `1.0.0` at `/qqq/v1/qbit/printing`, using standard QQQ user sessions and secondary device proof with current native record/process permissions. Legacy bearer transport remains explicit compatibility. - Preserve journal/history and distinguish accepted commands from confirmed execution. Shared-core/provider native authentication acceptance and installed candidate qualification remain required before deployment claims. +- Explicitly enforce current native permissions during selection counts and + extraction, including callbacks and query selections. Pass 109 Java tests and + 23 real .NET client/native QQQ interoperability checks without print jobs. ## 0.1.4-rc.1 — 2026-10-31 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 9931ba5..8b80bea 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -73,8 +73,8 @@ the reviewed identity set under the device lock. Claims authorize the exact job and printer before dispatch, ordered by creation time then identity. An inaccessible printer fails closed rather than granting access through its parent device. -Queue-clear review is bounded to 10,000 jobs. It performs a native Get for each job -and each distinct printer, with the device checked once. Get-specific host policies +Queue-clear review is bounded to 10,000 jobs. It performs a native Get for each +job and each distinct printer, with the device checked once. Get-specific policies remain effective. Large clears on remote databases have not been performance qualified; this review cost is separate from normal claim throughput. @@ -83,6 +83,11 @@ Developer projections that request derived `device_status`, `control_status`, or identity-free projection is rejected rather than returning a fabricated UNKNOWN status; the frozen QQQ dependency has no pre-query projection hook for these fields. +Printing selection processes also enforce native table access before extraction +and preview counts. The printing extract wrapper preserves the core filter, +callbacks, and tenant row locks, then requires permission enforcement on the +query. Later review/load checks still reauthorize the selected records. + Authorization uses the native QQQ context established for each request. Host authentication and session refresh policies determine when permission changes reach that context; printing does not assert that an upstream cached session is diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 0927492..14f78fc 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -4,7 +4,7 @@ Implementation is in the isolated device-controls worktree. Native device/table actions support pause, resume, disconnect, reconnect, revoke, inventory refresh and queued-job clearing; job records support exact owned Windows-job cancellation. Related printers, jobs, pending queue and bounded command history are registered. Derived status separates connection presence, desired state and observed revision. Domain writes use current QQQ permissions; clearing a queue checks every affected job before the transactional mutation. -The primary client transport is the discovered `/qqq/v1/qbit/printing` semantic extension with standard QQQ user sessions and secondary device proof. Authentik requires the shared advertised native PKCE session grant; no private bearer-only fallback substitutes for that authorization. The final isolated Java21 gate passed **106 tests**, zero failures/errors/skips and zero Checkstyle violations. All204 runtime dependencies match the frozen approved hashes. Native HTTP, proof/binding denials, explicit permission enforcement, FIFO eligibility, queue races, cancellation replay and offline command expiry are covered. Cross-runtime client acceptance and protected CI remain pending. No new feature release or local installation has occurred. +The primary client transport is the discovered `/qqq/v1/qbit/printing` semantic extension with standard QQQ user sessions and secondary device proof. Authentik requires the shared advertised native PKCE session grant; no private bearer-only fallback substitutes for that authorization. The final isolated Java 21 gate passed **109 tests**, zero failures/errors/skips and zero Checkstyle violations. All 204 runtime dependencies match the frozen approved hashes. Native HTTP, proof/binding denials, explicit permission enforcement, selection count/extraction, FIFO eligibility, queue races, cancellation replay and offline command expiry are covered. The actual .NET client passed 23 native table-based authentication/binding/control/logout checks without printers or jobs; the final selection-only change preserves those API classes byte for byte. Current protected CI and installed/provider acceptance remain pending. No new feature release or local installation has occurred. The user confirmed all three current Query test prints good. Barcode catalog expansion was removed from printing scope and belongs to document/barcode generation. diff --git a/docs/TODO.md b/docs/TODO.md index 3cfb7c5..11df36d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,7 +1,8 @@ # Follow-up -- [x] Complete native device controls, per-record/selection actions, queue/history and dashboard integration tests;106tests pass with zero Checkstyle violations. -- [ ] Complete real Windows Core client/native QQQ interoperability and installed candidate acceptance after protected CI. +- [x] Complete native device controls, per-record/selection actions, queue/history and dashboard integration tests; 109 tests pass with zero Checkstyle violations. +- [x] Complete 23 actual Windows Core client/native QQQ interoperability checks with table-based authentication, no printers and no jobs. +- [ ] Complete installed candidate acceptance after protected CI. - [ ] Complete standard QQQ discovery/session/device-binding security checks and Windows auth integration; qualify the shared Authentik native grant without a private authentication fork. - [ ] Review protocol fixtures, publish immutable contract inputs and pass protected CI before packaging the new features. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java index 88b3686..89a75dd 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java @@ -50,7 +50,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMeta import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; -import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; import org.json.JSONObject; @@ -138,7 +137,7 @@ public final class PrintingDeviceProcesses values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, action.preview); - QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Transform.class, Load.class, values) + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values) .withName(action.process).withLabel(action.label).withTableName(table).withMinInputRecords(1) .withPermissionRules(PrintingProcesses.permissionRules()); process.withStep(0, new QBackendStepMetaData().withName("prepareControl").withCode(new QCodeReference(Prepare.class))); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingExtract.java b/src/main/java/com/kingsrook/qbits/printing/PrintingExtract.java new file mode 100644 index 0000000..59e792b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingExtract.java @@ -0,0 +1,56 @@ +/* + * 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 com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; + + +/** Printing selections enforce current native table permissions before preview or extraction. */ +public class PrintingExtract extends ExtractViaQueryStep +{ + /** Preserve the standard callback, then prevent it from weakening current-user enforcement. */ + @Override + protected void customizeInputPreQuery(RunBackendStepInput input, QueryInput query) + { + super.customizeInputPreQuery(input, query); + query.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + } + + + /** RC20 exposes no count-input hook; authorize before its unchanged tenant-filtered count. */ + @Override + public Integer doCount(RunBackendStepInput input) throws QException + { + CountInput count = new CountInput(); + count.setTableName(input.getValueString(FIELD_SOURCE_TABLE)); + count.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + PermissionsHelper.checkTablePermissionThrowing(count, TablePermissionSubType.READ); + return (super.doCount(input)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java index eae78b9..27da2fa 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingJobProcesses.java @@ -49,7 +49,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMeta import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; -import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; import org.json.JSONObject; @@ -82,7 +81,7 @@ public class PrintingJobProcesses values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, retry ? "Each selected job will create a new print job. Previously spooled or uncertain output may print twice. Inspect the printer before continuing." : "Only selected jobs still queued in QQQ will be canceled. Jobs already received by a device must be inspected in the Windows companion."); - QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Transform.class, Load.class, values) + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values) .withName(name).withLabel(retry ? "Print again" : "Cancel queued jobs").withTableName(PrintingTables.JOB).withMinInputRecords(1) .withPermissionRules(PrintingProcesses.permissionRules()); if(retry) diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java index 418e54c..3d55382 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java @@ -59,7 +59,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; -import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; import org.json.JSONObject; @@ -200,7 +199,7 @@ public class PrintingProcesses implements BackendStep values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview); - QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, SelectedTransform.class, SelectedLoad.class, values) + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, SelectedTransform.class, SelectedLoad.class, values) .withName(name).withLabel(label).withTableName(PrintingTables.PRINTER).withMinInputRecords(1).withPermissionRules(permissionRules()); if(name.startsWith("printingTest")) { diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingExtractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingExtractTest.java new file mode 100644 index 0000000..2e8b8c7 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingExtractTest.java @@ -0,0 +1,162 @@ +/* + * 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.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback; +import com.kingsrook.qqq.backend.core.actions.reporting.RecordPipe; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** Exercise the actual frozen extract and record pipe, without relying on later Transform checks. */ +class PrintingExtractTest +{ + /** Each test owns its native context. */ + @AfterEach + void clearContext() + { + QContext.clear(); + } + + + /** A table-specific denial precedes both preview count and any streamed row. */ + @Test + void tablePolicyBlocksCountAndExtractEvenWithPermissionWeakeningCallback() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + fixture.instance().getTable(PrintingTables.DEVICE).getPermissionRules().withCustomPermissionChecker(new QCodeReference(DenyRead.class)); + RunBackendStepInput input = input(fixture.device()); + AtomicBoolean callback = new AtomicBoolean(); + input.setCallback(new QProcessCallback() + { + @Override + public void customizeInputPreQuery(RunBackendStepInput step, QueryInput query) + { + callback.set(true); + query.setPermissionEnforcement(PermissionEnforcement.DEFAULT); + } + }); + PrintingExtract extract = prepare(input); + assertThrows(QPermissionDeniedException.class, () -> extract.doCount(input)); + assertThrows(QPermissionDeniedException.class, () -> extract.run(input, new RunBackendStepOutput())); + assertTrue(callback.get()); + assertEquals(0, extract.getRecordPipe().countAvailableRecords()); + } + + + /** Tenant row locks remain authoritative for both selected IDs and query selection. */ + @Test + void nativeSelectionsCountAndStreamOnlyCurrentTenantRows() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + RunBackendStepInput selected = input(fixture.device() + "," + fixture.foreignDevice()); + PrintingExtract extract = prepare(selected); + assertEquals(1, extract.doCount(selected)); + extract.run(selected, new RunBackendStepOutput()); + assertEquals(List.of(fixture.device()), extract.getRecordPipe().consumeAvailableRecords().stream().map(row -> row.getValueString("id")).toList()); + RunBackendStepInput query = input(null); + query.addValue(StreamedETLWithFrontendProcess.FIELD_DEFAULT_QUERY_FILTER, "{}"); + PrintingExtract all = prepare(query); + assertEquals(2, all.doCount(query)); + all.run(query, new RunBackendStepOutput()); + assertEquals(Set.of(fixture.device(), fixture.otherDevice()), all.getRecordPipe().consumeAvailableRecords().stream().map(row -> row.getValueString("id")).collect(Collectors.toSet())); + QContext.getQSession().setPermissions(Set.of()); + assertThrows(QPermissionDeniedException.class, () -> all.doCount(query)); + assertThrows(QPermissionDeniedException.class, () -> all.run(query, new RunBackendStepOutput())); + } + + + /** Every printing streamed-selection process uses the enforced wrapper. */ + @Test + void allPrintingSelectionRegistrationsUseEnforcedExtraction() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + int checked = 0; + for(var process : fixture.instance().getProcesses().values()) + { + var field = process.getInputField(StreamedETLWithFrontendProcess.FIELD_EXTRACT_CODE); + if(field.isPresent() && field.get().getDefaultValue() instanceof QCodeReference code) + { + assertEquals(PrintingExtract.class.getName(), code.getName()); + checked++; + } + } + assertEquals(14, checked); + } + + + /** Real native input fields drive standard selected-ID or filter extraction. */ + private static RunBackendStepInput input(String ids) + { + RunBackendStepInput input = new RunBackendStepInput().withProcessName(PrintingDeviceProcesses.Action.PAUSE.process) + .addValue(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, PrintingTables.DEVICE); + if(ids != null) + { + input.addValue("recordIds", ids); + } + return (input); + } + + + /** Bounded in-memory pipe removes asynchronous worker timing from the authorization assertion. */ + private static PrintingExtract prepare(RunBackendStepInput input) throws QException + { + PrintingExtract extract = new PrintingExtract(); + extract.preRun(input, new RunBackendStepOutput()); + extract.setRecordPipe(new RecordPipe(20)); + return (extract); + } + + + /** Permission varies by table independently of the user's process-administration grant. */ + public static class DenyRead extends PrintingReadPermission + { + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + super.checkPermissionsThrowing(input, metadata); + if(input instanceof QueryInput || input instanceof CountInput) + { + throw new QPermissionDeniedException("Synthetic table read denied"); + } + } + } +} -- 2.54.0 From 23fccbb32644cf187503bcadbfbe201af4fe916c Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 09:35:57 -0500 Subject: [PATCH 21/35] docs: explain standard QQQ session freshness policy Record verified RC20 behavior: OAuth2 caching defaults off and reruns supported host authorization hooks; explicitly cached sessions require normal invalidation or a qualified freshness policy. Table-based sessions reread user rows but do not automatically interpret arbitrary enabled fields. Validation: exact frozen source and a10-check synthetic reproduction. No product code or provider/runtime configuration changes. Long-lived provider renewal and configured native grants remain deployment qualifications. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- docs/OPERATIONS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 8b80bea..c423b87 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -94,6 +94,22 @@ reach that context; printing does not assert that an upstream cached session is fresh. Cookie-authenticated unsafe calls must also satisfy the host's Origin/CSRF policy. The local host requires its canonical same-origin authority. +In the qualified QQQ 4.1.0-RC.20 runtime, OAuth2 `sessionStoreEnabled` defaults +to `false`. Ordinary requests and session resume rerun the configured +`QAuthenticationModuleCustomizerInterface` hooks; the host can load current +permissions, tenant keys, and account status from its authoritative directory. +With session caching enabled, a cache hit skips those hooks. That configuration +requires explicit session-cache invalidation on authorization changes or another +qualified freshness policy. Periodic `manageSession` calls and token renewal alone +do not establish current provider roles or immediate account revocation. + +TABLE_BASED authentication rereads the session and user rows on each request; +deleting either prevents resume. A generic user `enabled` column is not enforced +automatically, and this module does not invoke the OAuth customizer hooks. The +local host grants its configured account the configured printing role and tenant; +this is not a general mutable role directory. Qualify the integrating host's +authorization mapping and provider behavior separately from printing transport. + Device controls use the same tenant-scoped service facade from native management and transport adapters. Adapters must authorize the current user, tenant, device, and action before calling the facade; a device ID or an actor string is not an -- 2.54.0 From 3b73808650b27fd72b8962564bc75e9876e978c9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 10:24:13 -0500 Subject: [PATCH 22/35] fix: prevent legacy downgrade of native device credentials Reject bound device tokens on every legacy device route. Native proof verification continues through standard QQQ sessions and current user permissions. Disable legacy enrollment by default and explicitly in the native host; legacy-only demo and fixtures opt in, preserving eligible unbound-device migration. Validation: 110 Java tests pass with zero failures/errors/skips and zero Checkstyle. Actual native-bind HTTP regressions reject bearer-only claim/content/queue/control; legacy enrollment denial preserves the code for successful native enrollment. Independent review found no remaining blocker. No schema or dependency changes. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 5 +++- docs/OPERATIONS.md | 9 ++++++ docs/SESSION-STATE.md | 2 +- docs/TODO.md | 2 +- .../qbits/printing/PrintingDemo.java | 2 +- .../qbits/printing/PrintingLocalHost.java | 2 +- .../qbits/printing/PrintingRoutes.java | 24 ++++++++++++++- .../qbits/printing/PrintingService.java | 4 +-- .../qbits/printing/PrintingContractTest.java | 30 +++++++++++++++++-- .../qbits/printing/PrintingUiApiTest.java | 30 +++++++++++++++++++ 10 files changed, 100 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 253d7fb..55e106d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,11 @@ execution. Shared-core/provider native authentication acceptance and installed candidate qualification remain required before deployment claims. - Explicitly enforce current native permissions during selection counts and - extraction, including callbacks and query selections. Pass 109 Java tests and + extraction, including callbacks and query selections. Pass 110 Java tests and 23 real .NET client/native QQQ interoperability checks without print jobs. +- Reject account-bound device tokens on legacy routes and disable legacy + enrollment by default. Legacy-only hosts opt in explicitly; existing unbound + devices retain migration compatibility. ## 0.1.4-rc.1 — 2026-10-31 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index c423b87..1b57c0a 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -116,6 +116,15 @@ and action before calling the facade; a device ID or an actor string is not an authorization grant. The legacy `/printing/v1` device bearer transport remains a compatibility interface, not a substitute for native QQQ user permissions. +Once a device is bound to a QQQ user, its token is secondary proof only: all +legacy device endpoints reject it, including claim, content and control polling. +Existing unbound legacy devices remain usable during migration. New legacy +enrollment is disabled by default in `PrintingRoutes` and explicitly disabled in +`PrintingLocalHost`; native enrollment uses `/qqq/v1/qbit/printing/devices/enroll`. +Only a legacy-only host such as `PrintingDemo` should opt into +`PrintingRoutes.LegacyEnrollment.ENABLED`. A rejected legacy enrollment does not +consume the code, which still requires native user and facility authorization. + Pause blocks new claims. Disconnect also expresses that ordinary agent heartbeat, inventory publication, and claiming should stop; the independent authenticated control poll remains available for reconnect. Neither operation cancels in-flight diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 14f78fc..379a218 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -4,7 +4,7 @@ Implementation is in the isolated device-controls worktree. Native device/table actions support pause, resume, disconnect, reconnect, revoke, inventory refresh and queued-job clearing; job records support exact owned Windows-job cancellation. Related printers, jobs, pending queue and bounded command history are registered. Derived status separates connection presence, desired state and observed revision. Domain writes use current QQQ permissions; clearing a queue checks every affected job before the transactional mutation. -The primary client transport is the discovered `/qqq/v1/qbit/printing` semantic extension with standard QQQ user sessions and secondary device proof. Authentik requires the shared advertised native PKCE session grant; no private bearer-only fallback substitutes for that authorization. The final isolated Java 21 gate passed **109 tests**, zero failures/errors/skips and zero Checkstyle violations. All 204 runtime dependencies match the frozen approved hashes. Native HTTP, proof/binding denials, explicit permission enforcement, selection count/extraction, FIFO eligibility, queue races, cancellation replay and offline command expiry are covered. The actual .NET client passed 23 native table-based authentication/binding/control/logout checks without printers or jobs; the final selection-only change preserves those API classes byte for byte. Current protected CI and installed/provider acceptance remain pending. No new feature release or local installation has occurred. +The primary client transport is the discovered `/qqq/v1/qbit/printing` semantic extension with standard QQQ user sessions and secondary device proof. Authentik requires the shared advertised native PKCE session grant; no private bearer-only fallback substitutes for that authorization. The final isolated Java 21 gate passed **110 tests**, zero failures/errors/skips and zero Checkstyle violations. All 204 runtime dependencies match the frozen approved hashes. Native HTTP, proof/binding denials, explicit permission enforcement, selection count/extraction, FIFO eligibility, queue races, cancellation replay and offline command expiry are covered. Bound device tokens are rejected on legacy routes, and new legacy enrollment defaults off; real HTTP regressions prove there is no secondary-proof downgrade. The actual .NET client passed 23 native table-based authentication/binding/control/logout checks without printers or jobs. Current protected CI and installed/provider acceptance remain pending. No new feature release or local installation has occurred. The user confirmed all three current Query test prints good. Barcode catalog expansion was removed from printing scope and belongs to document/barcode generation. diff --git a/docs/TODO.md b/docs/TODO.md index 11df36d..ff7040a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,6 +1,6 @@ # Follow-up -- [x] Complete native device controls, per-record/selection actions, queue/history and dashboard integration tests; 109 tests pass with zero Checkstyle violations. +- [x] Complete native device controls, per-record/selection actions, queue/history, dashboard and legacy-boundary integration tests; 110 tests pass with zero Checkstyle violations. - [x] Complete 23 actual Windows Core client/native QQQ interoperability checks with table-based authentication, no printers and no jobs. - [ ] Complete installed candidate acceptance after protected CI. - [ ] Complete standard QQQ discovery/session/device-binding security checks and Windows auth integration; qualify the shared Authentik native grant without a private authentication fork. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDemo.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDemo.java index 35fbc31..0940132 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDemo.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDemo.java @@ -46,7 +46,7 @@ public final class PrintingDemo int port = Integer.parseInt(System.getenv().getOrDefault("QQQ_PRINT_PORT", "18081")); PrintingService service = new PrintingService(new DriverDataSource(url, "sa", ""), "demo"); service.initialize(); - PrintingRoutes routes = new PrintingRoutes(service, context -> MessageDigest.isEqual(adminToken.getBytes(StandardCharsets.UTF_8), PrintingRoutes.bearer(context).getBytes(StandardCharsets.UTF_8))); + PrintingRoutes routes = new PrintingRoutes(service, context -> MessageDigest.isEqual(adminToken.getBytes(StandardCharsets.UTF_8), PrintingRoutes.bearer(context).getBytes(StandardCharsets.UTF_8)), PrintingRoutes.LegacyEnrollment.ENABLED); Javalin app = Javalin.create(config -> { config.http.maxRequestSize = 29 * 1024 * 1024; diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java index 6e99cff..44dbd46 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java @@ -133,7 +133,7 @@ public class PrintingLocalHost extends AbstractQQQApplication void configure(QApplicationJavalinServer server, boolean frontend) { PrintingRoutes routes = new PrintingRoutes(service, context -> MessageDigest.isEqual( - settings.administratorToken().getBytes(StandardCharsets.UTF_8), PrintingRoutes.bearer(context).getBytes(StandardCharsets.UTF_8))); + settings.administratorToken().getBytes(StandardCharsets.UTF_8), PrintingRoutes.bearer(context).getBytes(StandardCharsets.UTF_8)), PrintingRoutes.LegacyEnrollment.DISABLED); server.withPort(settings.port()).withServeFrontendNext(frontend).withServeFrontendMaterialDashboard(false) .withAdditionalRouteProviders(List.of(routes)).withJavalinConfigCustomizer(config -> { diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java index 655e190..b17ae44 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java @@ -38,6 +38,13 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface public static final String BASE = "/printing/v1"; private final PrintingService service; private final Predicate administrator; + private final LegacyEnrollment legacyEnrollment; + + /** Legacy-only hosts must explicitly opt into bearer-only device enrollment. */ + public enum LegacyEnrollment + { + DISABLED, ENABLED + } @@ -45,9 +52,17 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface ** Attach this route provider to the host's QApplicationJavalinServer. ***************************************************************************/ public PrintingRoutes(PrintingService service, Predicate administrator) + { + this(service, administrator, LegacyEnrollment.DISABLED); + } + + + /** Existing unbound devices may migrate; new legacy enrollment is an explicit host policy. */ + public PrintingRoutes(PrintingService service, Predicate administrator, LegacyEnrollment legacyEnrollment) { this.service = service; this.administrator = administrator; + this.legacyEnrollment = java.util.Objects.requireNonNull(legacyEnrollment); } @@ -88,7 +103,14 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface admin(context); return service.getDeviceCommand(context.pathParam("deviceId"), context.pathParam("commandId")); })); - config.routes.post(BASE + "/enroll", context -> handle(context, () -> service.enroll(body(context)))); + config.routes.post(BASE + "/enroll", context -> handle(context, () -> + { + if(legacyEnrollment != LegacyEnrollment.ENABLED) + { + throw new PrintingException(403, "Enrollment requires native QQQ authentication"); + } + return service.enroll(body(context)); + })); config.routes.put(BASE + "/device/printers", context -> handle(context, () -> { service.inventory(device(context), body(context).getJSONArray("printers")); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index 6009811..1b89b26 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -469,13 +469,13 @@ public class PrintingService /*************************************************************************** - ** Resolve an active device credential without exposing its verifier. + ** Legacy primary credentials are valid only until native user binding. ***************************************************************************/ public String authenticate(String token) throws SQLException { try(Connection connection = dataSource.getConnection()) { - List devices = query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND token_hash=? AND enabled=1 AND revoked_at IS NULL", tenantId, hash(token == null ? "" : token)); + List devices = query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND token_hash=? AND enabled=1 AND revoked_at IS NULL AND bound_user_id IS NULL", tenantId, hash(token == null ? "" : token)); if(devices.isEmpty()) { throw new PrintingException(401, "Invalid device credential"); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java index 52fa428..ce26e74 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingContractTest.java @@ -52,6 +52,32 @@ class PrintingContractTest private static final Path FIXTURES = Path.of(System.getProperty("printing.protocol.fixtures", "../qqq-print-protocol/fixtures")); + /** Default hosts cannot consume enrollment codes through the legacy bearer transport. */ + @Test + void legacyEnrollmentRequiresExplicitHostOptIn() throws Exception + { + PrintingService service = new PrintingService(new DriverDataSource("jdbc:h2:mem:default-policy-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""), "contract-tenant"); + service.initialize(); + JSONObject enrollment = service.createEnrollment("default", "Migration").put("deviceName", "Migration").put("agentVersion", "test"); + PrintingRoutes routes = new PrintingRoutes(service, context -> true); + Javalin app = Javalin.create(routes::acceptJavalinConfig).start("127.0.0.1", 0); + try(HttpClient client = HttpClient.newHttpClient()) + { + String base = "http://127.0.0.1:" + app.port() + PrintingRoutes.BASE; + assertEquals(403, send(client, base, "POST", "/enroll", ADMIN, enrollment).statusCode()); + assertEquals("default", service.enrollmentFacility(enrollment.getString("enrollmentCode"))); + JSONObject existing = service.enroll(enrollment); + assertEquals(200, send(client, base, "POST", "/device/heartbeat", existing.getString("deviceToken"), new JSONObject().put("agentVersion", "test")).statusCode()); + service.authorizeAgent(existing.getString("deviceId"), existing.getString("deviceToken"), "native-user", true); + assertEquals(401, send(client, base, "POST", "/device/heartbeat", existing.getString("deviceToken"), new JSONObject().put("agentVersion", "test")).statusCode()); + } + finally + { + app.stop(); + } + } + + /*************************************************************************** ** Enrollment through spool acknowledgment uses exactly the connector wire DTOs. @@ -61,7 +87,7 @@ class PrintingContractTest { PrintingService service = new PrintingService(new DriverDataSource("jdbc:h2:mem:contract-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""), "contract-tenant"); service.initialize(); - PrintingRoutes routes = new PrintingRoutes(service, context -> ADMIN.equals(PrintingRoutes.bearer(context))); + PrintingRoutes routes = new PrintingRoutes(service, context -> ADMIN.equals(PrintingRoutes.bearer(context)), PrintingRoutes.LegacyEnrollment.ENABLED); Javalin app = Javalin.create(routes::acceptJavalinConfig).start("127.0.0.1", 0); try(HttpClient client = HttpClient.newHttpClient()) { @@ -132,7 +158,7 @@ class PrintingContractTest { PrintingService service = new PrintingService(new DriverDataSource("jdbc:h2:mem:control-contract-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""), "contract-tenant"); service.initialize(); - PrintingRoutes routes = new PrintingRoutes(service, context -> ADMIN.equals(PrintingRoutes.bearer(context))); + PrintingRoutes routes = new PrintingRoutes(service, context -> ADMIN.equals(PrintingRoutes.bearer(context)), PrintingRoutes.LegacyEnrollment.ENABLED); Javalin app = Javalin.create(routes::acceptJavalinConfig).start("127.0.0.1", 0); try(HttpClient client = HttpClient.newHttpClient()) { diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java index 5f979c1..fd7c3ff 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java @@ -52,6 +52,7 @@ import org.json.JSONObject; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -119,6 +120,18 @@ class PrintingUiApiTest assertEquals(403, send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), cookie, device, proof).statusCode()); assertEquals(403, send(client, base, prefix + "/device/bind", "POST", new JSONObject(), cookie, device, "wrong").statusCode()); json(send(client, base, prefix + "/device/bind", "POST", new JSONObject(), cookie, device, proof), 200); + JSONObject codeForNative = service.createEnrollment("default", "Native-only code").put("deviceName", "Native-only code").put("agentVersion", "test"); + assertAll("Native proof cannot authorize the legacy transport", + () -> assertEquals(401, legacy(client, base, "/device/jobs/claim", "POST", new JSONObject(), proof).statusCode()), + () -> assertEquals(401, legacy(client, base, "/device/jobs/" + job + "/content", "GET", null, proof).statusCode()), + () -> assertEquals(401, legacy(client, base, "/device/jobs/queued", "GET", null, proof).statusCode()), + () -> assertEquals(401, legacy(client, base, "/device/control/poll", "POST", new JSONObject(), proof).statusCode()), + () -> assertEquals(403, legacy(client, base, "/enroll", "POST", codeForNative, null).statusCode())); + assertEquals("QUEUED", service.getJob(job).getString("status")); + assertEquals("default", service.enrollmentFacility(codeForNative.getString("enrollmentCode"))); + JSONObject nativeOnly = json(send(client, base, prefix + "/devices/enroll", "POST", codeForNative, cookie, null, null), 200); + service.authorizeAgent(nativeOnly.getString("deviceId"), nativeOnly.getString("deviceToken"), "operator", false); + assertEquals(401, legacy(client, base, "/device/jobs/queued", "GET", null, nativeOnly.getString("deviceToken")).statusCode()); assertEquals(403, send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), cookie, null, null).statusCode()); JSONObject others = service.enrollForUser(service.createEnrollment("default", "Other owner").put("deviceName", "Other owner").put("agentVersion", "test"), "another-principal"); assertEquals(403, send(client, base, prefix + "/device/heartbeat", "POST", new JSONObject().put("agentVersion", "test"), cookie, others.getString("deviceId"), others.getString("deviceToken")).statusCode()); @@ -336,6 +349,23 @@ class PrintingUiApiTest } + /** Legacy requests intentionally carry no QQQ cookie or native secondary-proof headers. */ + private static HttpResponse legacy(HttpClient client, String base, String path, String method, JSONObject body, String token) throws Exception + { + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(base + PrintingRoutes.BASE + path)).header("Origin", base); + if(token != null) + { + request.header("Authorization", "Bearer " + token); + } + if(body != null) + { + request.header("Content-Type", "application/json"); + } + request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body.toString())); + return (client.send(request.build(), HttpResponse.BodyHandlers.ofByteArray())); + } + + /** Check status before interpreting success; no secret-bearing body is echoed on failure. */ private static JSONObject json(HttpResponse response, int status) { -- 2.54.0 From ad53b69c0f10ab609238e42cf70235da2840ee79 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 11:06:12 -0500 Subject: [PATCH 23/35] docs: prepare native printing 0.2.0 prerelease Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 5 +++-- README.md | 8 +++++--- docs/TODO.md | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e106d..c38cf48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased — 0.2.0-rc.1 candidate +## 0.2.0-rc.1 — 2026-10-31 - Add native device/table selection actions for pause, resume, disconnect, reconnect, revocation, inventory refresh and queued-job clearing, plus exact @@ -15,7 +15,8 @@ candidate qualification remain required before deployment claims. - Explicitly enforce current native permissions during selection counts and extraction, including callbacks and query selections. Pass 110 Java tests and - 23 real .NET client/native QQQ interoperability checks without print jobs. + 25 real .NET client/native QQQ interoperability checks without print jobs, + including rejection of legacy enrollment and bound-token downgrade attempts. - Reject account-bound device tokens on legacy routes and disable legacy enrollment by default. Legacy-only hosts opt in explicitly; existing unbound devices retain migration compatibility. diff --git a/README.md b/README.md index 816255f..77e3b36 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https:// 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. -The device-controls branch adds native record/selection actions, per-device printers/jobs/queue/history, desired versus observed status and standard-session authentication at the discovered `/qqq/v1/qbit/printing` extension. Device credentials become secondary identity proof alongside the current QQQ user's permissions. These changes are under integration testing and are not included in the published release above. See the [authentication contract](https://github.com/QRun-IO/qqq-print-protocol/blob/main/docs/QQQ-AUTHENTICATION.md) when that coordinated contract is published; legacy `/printing/v1` remains explicit compatibility. +The 0.2.0-rc.1 source adds native record/selection actions, per-device printers/jobs/queue/history, desired versus observed status and standard-session authentication at the discovered `/qqq/v1/qbit/printing` extension. Device credentials become secondary identity proof alongside the current QQQ user's permissions. Upgrade the server before the 0.2.0 Windows client, then explicitly associate an existing unbound pairing during sign-in. These changes are not included in the published release above. See the [authentication contract](https://github.com/QRun-IO/qqq-print-protocol/blob/main/docs/QQQ-AUTHENTICATION.md); bound devices cannot fall back to legacy `/printing/v1` authentication. ```mermaid flowchart LR @@ -20,7 +20,7 @@ See the [screen walkthrough](docs/UI-WALKTHROUGH.md), [storage and retention](do ## Build and verify -Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This feature branch pins protocol commit `cc3c4e50e8f6f4fbc93d351fb51edb4f5438bbd3`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). +Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `cc3c4e50e8f6f4fbc93d351fb51edb4f5438bbd3`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). ```powershell .\mvnw.cmd verify @@ -31,7 +31,9 @@ On Linux/macOS use `bash ./mvnw verify` and the corresponding wrapper commands. Checkstyle runs during validate. Tests exercise real H2 persistence, native QQQ metadata/processes and real loopback HTTP through Javalin, including the shared fixtures and withdrawal of a locally exposed printer. Keep `../qqq-print-protocol` checked out or pass `-Dprinting.protocol.fixtures=`. -## Local server +## Legacy protocol demo + +The new Windows client needs the [authenticated local application](#authenticated-local-application) below, or a QQQ application hosting the native printing extension. This protocol-only demo is for explicitly enabled legacy diagnostics; it does not provide native user sign-in. Set `QQQ_PRINT_ADMIN_TOKEN` to a random value of at least 24 characters in the process environment; never put it in source or command arguments. Then: diff --git a/docs/TODO.md b/docs/TODO.md index ff7040a..099ed8a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,10 +1,10 @@ # Follow-up - [x] Complete native device controls, per-record/selection actions, queue/history, dashboard and legacy-boundary integration tests; 110 tests pass with zero Checkstyle violations. -- [x] Complete 23 actual Windows Core client/native QQQ interoperability checks with table-based authentication, no printers and no jobs. +- [x] Complete 25 actual Windows Core client/native QQQ interoperability checks with table-based authentication, no printers and no jobs. - [ ] Complete installed candidate acceptance after protected CI. - [ ] Complete standard QQQ discovery/session/device-binding security checks and Windows auth integration; qualify the shared Authentik native grant without a private authentication fork. -- [ ] Review protocol fixtures, publish immutable contract inputs and pass protected CI before packaging the new features. +- [x] Review and merge immutable protocol inputs (cc3c4e5), pass protected CI and merge the server and Windows implementations normally. Release tagging and installed acceptance remain separate. - [x] Replace the test-document logo with the exact official Query dog asset; preserve aspect ratio and verify all PDF/thermal profiles with 73 passing tests, zero Checkstyle violations and reviewed previews. - [x] Publish v0.1.4-rc.1 after all required release gates, verify checksums and all three asset attestations, and complete local native deployment acceptance. -- 2.54.0 From 696c623b4d69cc5b1372b0b6975ce09241101f80 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 11:29:13 -0500 Subject: [PATCH 24/35] docs: document the published 0.2.0 prerelease Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 77e3b36..a079476 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector initiates outbound HTTPS polling; the cloud needs no inbound route into the facility. -**Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. The current published release is [**0.1.4-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.4-rc.1). The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with verified build attestations for all three assets. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR. +**Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. The current published release is [**0.2.0-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.2.0-rc.1). The tagged build passed 110 tests and zero Checkstyle violations. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with verified build attestations for all three assets. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR. 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. -The 0.2.0-rc.1 source adds native record/selection actions, per-device printers/jobs/queue/history, desired versus observed status and standard-session authentication at the discovered `/qqq/v1/qbit/printing` extension. Device credentials become secondary identity proof alongside the current QQQ user's permissions. Upgrade the server before the 0.2.0 Windows client, then explicitly associate an existing unbound pairing during sign-in. These changes are not included in the published release above. See the [authentication contract](https://github.com/QRun-IO/qqq-print-protocol/blob/main/docs/QQQ-AUTHENTICATION.md); bound devices cannot fall back to legacy `/printing/v1` authentication. +The 0.2.0-rc.1 source adds native record/selection actions, per-device printers/jobs/queue/history, desired versus observed status and standard-session authentication at the discovered `/qqq/v1/qbit/printing` extension. Device credentials become secondary identity proof alongside the current QQQ user's permissions. Upgrade the server before the 0.2.0 Windows client, then explicitly associate an existing unbound pairing during sign-in. These changes are included in the published release above. See the [authentication contract](https://github.com/QRun-IO/qqq-print-protocol/blob/main/docs/QQQ-AUTHENTICATION.md); bound devices cannot fall back to legacy `/printing/v1` authentication. ```mermaid flowchart LR @@ -63,7 +63,7 @@ Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator The protocol-only `PrintingDemo` and synthetic, mock-authenticated `PrintingUiDemo` remain separate. Do not point the synthetic demo at real paired printer data. The local host retains database document storage until an actual dedicated S3 binding is configured in a deployment-owned application; it does not claim a remote cloud deployment. -Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The published release passed 73 tests and zero Checkstyle violations; the user confirmed all three Query diagnostic outputs good. Production S3/IAM and remote deployment remain separate qualification work. New device-controls/authentication source requires its own integration gates and release. +Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The previously accepted 0.1.4-rc.1 release passed 73 tests and zero Checkstyle violations; the user confirmed all three Query diagnostic outputs good. Production S3/IAM and remote deployment remain separate qualification work. The 0.2.0 controls/authentication release passed 25 real client/native QQQ interoperability checks; installed Windows acceptance remains in progress. ## Add to a QQQ application -- 2.54.0 From 2ed454301ee39ebe216be45c8ada69d24c587103 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 16:12:42 -0500 Subject: [PATCH 25/35] feat(printing): add authenticated realtime device transport Replace native job/control polling with client-initiated WebSockets, durable state and receipt reconciliation, independent verified document streams and native administrator settings for transfer, connection and admission limits. Preserve QQQ current-user authorization and device binding on every operation. Validation: Maven verify passes 117 tests with zero Checkstyle violations; OpenAPI generation/check and diff checks pass. Consumer CI pins protocol 67be986cb76d6b5d9a9f72cad054193b0d11b0d5. Installation is separate. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 11 + README.md | 11 +- docs/OPERATIONS.md | 17 +- docs/REALTIME.md | 100 +++ docs/SESSION-STATE.md | 10 + docs/TODO.md | 8 + pom.xml | 2 +- .../qbits/printing/PrintingAdmission.java | 4 + .../printing/PrintingAdmissionSettings.java | 178 +++++ .../printing/PrintingDeviceControls.java | 1 + .../printing/PrintingDocumentStorage.java | 20 +- .../printing/PrintingDocumentStream.java | 90 +++ .../qbits/printing/PrintingHome.java | 2 + .../qbits/printing/PrintingLocalHost.java | 14 +- .../qbits/printing/PrintingQBitProducer.java | 1 + .../qbits/printing/PrintingRealtimeBus.java | 91 +++ .../printing/PrintingRealtimePolicy.java | 172 +++++ .../printing/PrintingRealtimeProcesses.java | 256 +++++++ .../printing/PrintingRealtimeRoutes.java | 669 ++++++++++++++++++ .../printing/PrintingRetentionProcesses.java | 7 +- .../qbits/printing/PrintingRoutes.java | 7 +- .../qbits/printing/PrintingService.java | 231 +++++- .../qbits/printing/PrintingUiApi.java | 8 +- .../qbits/printing/PrintingUiApiSpec.java | 10 +- .../resources/openapi/qqq-v1/printing.yaml | 5 +- .../qbits/printing/PrintingAdmissionTest.java | 6 +- .../printing/PrintingDocumentStorageTest.java | 34 + .../PrintingRealtimeProcessesTest.java | 201 ++++++ .../qbits/printing/PrintingRealtimeTest.java | 291 ++++++++ .../PrintingRetentionProcessesTest.java | 2 +- .../qbits/printing/PrintingSocketFixture.java | 157 ++++ .../printing/PrintingStorageBindingTest.java | 2 +- .../printing/PrintingUiApiContractTest.java | 2 +- .../qbits/printing/PrintingUiApiTest.java | 3 + 35 files changed, 2568 insertions(+), 57 deletions(-) create mode 100644 docs/REALTIME.md create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingAdmissionSettings.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStream.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeBus.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingRealtimePolicy.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeProcesses.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeProcessesTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 896cdd1..4fa8ff0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: QRun-IO/qqq-print-protocol - ref: cc3c4e50e8f6f4fbc93d351fb51edb4f5438bbd3 + ref: 67be986cb76d6b5d9a9f72cad054193b0d11b0d5 path: qqq-print-protocol persist-credentials: false - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index c38cf48..fb09571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.3.0-rc.1 — unreleased + +- Add client-initiated realtime WebSocket delivery under native printing API 1.1, + with current QQQ session and record permissions revalidated during connection use. +- Deliver desired state, device commands, inventory, lifecycle events and receipts + over the socket; native claims require acknowledged current settings and state. +- Stream retained documents independently from controls and expose document, + concurrency, timing, admission and retention batch limits in native admin forms. +- Preserve durable job/command identities across reconnects. Deployment and actual + Windows realtime acceptance remain pending until the candidate gates complete. + ## 0.2.0-rc.1 — 2026-10-31 - Add native device/table selection actions for pause, resume, disconnect, diff --git a/README.md b/README.md index a079476..ef69717 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # QQQ Printing -Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector initiates outbound HTTPS polling; the cloud needs no inbound route into the facility. +Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. The real-time transport described here is unreleased source; published 0.2.0-rc.1 uses the earlier polling transport. **Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. The current published release is [**0.2.0-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.2.0-rc.1). The tagged build passed 110 tests and zero Checkstyle violations. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with verified build attestations for all three assets. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR. @@ -11,16 +11,17 @@ The 0.2.0-rc.1 source adds native record/selection actions, per-device printers/ ```mermaid flowchart LR A[QQQ application / durable job] --> B[Printing QBit and JDBC queue] - C[Windows service] -->|Outbound HTTPS polling| B + C[Windows service] <-->|Client-initiated WSS: commands and status| B + C -->|Separate outbound HTTPS document streams| B C --> D[Windows spooler] D --> E[Local laser / Zebra] ``` -See the [screen walkthrough](docs/UI-WALKTHROUGH.md), [storage and retention](docs/STORAGE-AND-RETENTION.md), [operations and troubleshooting](docs/OPERATIONS.md), [contributing](CONTRIBUTING.md), [security reporting](SECURITY.md), [changelog](CHANGELOG.md), and [Apache-2.0 license](LICENSE). +See the [real-time host integration](docs/REALTIME.md), [screen walkthrough](docs/UI-WALKTHROUGH.md), [storage and retention](docs/STORAGE-AND-RETENTION.md), [operations and troubleshooting](docs/OPERATIONS.md), [contributing](CONTRIBUTING.md), [security reporting](SECURITY.md), [changelog](CHANGELOG.md), and [Apache-2.0 license](LICENSE). ## Build and verify -Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `cc3c4e50e8f6f4fbc93d351fb51edb4f5438bbd3`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). +Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `67be986cb76d6b5d9a9f72cad054193b0d11b0d5`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). ```powershell .\mvnw.cmd verify @@ -120,7 +121,7 @@ Metadata names follow QBit conventions: tables and data sources use `printing_*` Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. Removing a queue from local selection makes it unavailable without deleting its approval/history. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows. -Application code calls `printing.submit(JSONObject)` with the shared `SubmitJobRequest` fields. A stable business idempotency key returns the same job when retried; changing document/options/target under the same key fails. PDF is sent as bytes encoded once in base64; ZPL is UTF-8 printer-ready bytes. Default limit is 20 MiB. Queue approval and format compatibility are checked before insertion. +Application code calls `printing.submit(JSONObject)` with the shared `SubmitJobRequest` fields. A stable business idempotency key returns the same job when retried; changing document/options/target under the same key fails. PDF is sent as bytes encoded once in base64; ZPL is UTF-8 printer-ready bytes. Default decoded limits are 100 MiB for PDF and 20 MiB for ZPL, both configurable in the native Printing connection and limits process. Queue approval and format compatibility are checked before insertion. ```java JSONObject job = printing.submit(new JSONObject() diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 1b57c0a..02d2f15 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -40,7 +40,7 @@ The host configures resource ceilings through `PrintingQBitConfig.withAdmissionL `PrintingAdmissionLimits.defaults()` returns these defaults. To override them, construct the record with device/tenant pairs in this order: outstanding jobs, outstanding bytes, retained bytes, hourly admissions, cumulative printers. Values must be positive. Use the same configuration on every QQQ node serving a tenant. Size retained capacity for actual document sizes and the chosen retention period; 10,000 jobs/day is a sizing target, not a measured hardware throughput result. These are logical application byte limits, not an S3 billing limit: object versions, multipart fragments and provider overhead require their own deployment monitoring and lifecycle policy. -Admission is serialized per tenant in SQL before storage writes. Pending and failed upload reservations stay charged until exact-object cleanup succeeds; failed admissions that reached a committed reservation also consume the hourly allowance. Legacy reservations with unknown length conservatively reserve the maximum 20 MiB each, against the tenant when their device is unknown. Existing payloads are backfilled without removal. Apply this additive accounting migration with older application writers stopped; mixed-version writers or direct SQL changes bypass accounting and are unsupported. +Admission is serialized per tenant in SQL before storage writes. Pending and failed upload reservations stay charged until exact-object cleanup succeeds; failed admissions that reached a committed reservation also consume the hourly allowance. Legacy reservations with unknown length block new allocations until exact cleanup resolves them; a fixed-size guess cannot safely account for configurable document limits. Existing payloads are backfilled without removal. Apply this additive accounting migration with older application writers stopped; mixed-version writers or direct SQL changes bypass accounting and are unsupported. HTTP 429 means capacity, the rolling-hour allowance, cumulative inventory history, or an existing upload reservation prevents a new allocation. The response explains the category. Reuse the same idempotency/request identity when retrying: a completed matching replay returns its existing job even at capacity or after the original retry source expires. Wait for outstanding work or the hourly window, investigate failed cleanup/storage access, or have the host administrator adjust a justified capacity limit. Retention only removes eligible successful documents; it does not erase failed, uncertain or active work to make space. @@ -125,13 +125,12 @@ Only a legacy-only host such as `PrintingDemo` should opt into `PrintingRoutes.LegacyEnrollment.ENABLED`. A rejected legacy enrollment does not consume the code, which still requires native user and facility authorization. -Pause blocks new claims. Disconnect also expresses that ordinary agent heartbeat, -inventory publication, and claiming should stop; the independent authenticated -control poll remains available for reconnect. Neither operation cancels in-flight -jobs. Resume preserves connection state; reconnect preserves pause. Revocation -invalidates the enrolled credential permanently, blocks control polling, and -requires separate local re-enrollment. It cannot be undone with resume/reconnect. -Desired state, the last observed revision, and the last poll are separate facts: +Pause blocks new claims. Disconnect stops dispatch and inventory publication while +the authenticated control WebSocket remains available for a reconnect command. +Neither operation cancels an already submitted spooler job. Resume preserves +connection state; reconnect preserves pause. Revocation invalidates device proof, +closes its channel and requires separate local re-enrollment. It cannot be undone +with resume/reconnect. Desired state, observed revision and last contact are separate facts: a successful server action does not prove an offline device applied it. Queue clear atomically changes only this device's QUEUED jobs to FAILED with the @@ -171,7 +170,7 @@ cancellation request, not that paper was withdrawn or a physical print was undon | --- | --- | | Enrollment rejected | Code expiration/single use, correct server, administrator-created facility/device enrollment | | No printers in QQQ | Service access to Windows queues; local exposure selection; connector enrollment and inventory publication | -| Printer listed but submission rejected | Both `available` and `enabled`, format capability and 20 MiB decoded-size limit | +| Printer listed but submission rejected | Both `available` and `enabled`, format capability and configured PDF/ZPL decoded-size limits | | Job stays queued | Connector connectivity, selected queue availability and device assignment | | Job stays received/submitting after restart | Inspect local journal/spooler; automatic uncertain-output replay is intentionally absent | | Printing app missing or access denied | QBit registration, `printing_admin`, matching `printing_tenant`, and correct QQQ backend | diff --git a/docs/REALTIME.md b/docs/REALTIME.md new file mode 100644 index 0000000..e94dd41 --- /dev/null +++ b/docs/REALTIME.md @@ -0,0 +1,100 @@ +# Real-time printing host integration + +This source implements printing extension **1.1.0** (wire envelope v1). It is not +part of the published 0.2.0-rc.1 artifacts. Upgrade server and Windows connector +together; a real-time connector rejects a server that advertises only extension +1.0. There is no timed job/control polling fallback. + +## Connection and authorization + +The Windows service initiates `wss:///qqq/v1/qbit/printing/device/realtime` +using subprotocol `qqq-printing.v1`. No facility listener or inbound firewall rule +is required. Plain WebSocket is allowed only for explicit loopback development. +Configure the cloud ingress to forward WebSocket upgrades, preserve the original +HTTPS origin and allow the configured heartbeat interval/timeout. + +The service discovers authentication through the QQQ UI/UX API and uses the +standard current QQQ session, client channel and approved bound-device proof. +Credentials are not URL parameters. Device proof does not replace the user's +authority. The server validates the current session, user/tenant, channel policy +and operation permissions; socket establishment does not grant permanent rights. +Session expiry, logout, revocation and permission denial require correction or +sign-in through the configured QQQ provider. Reconnect cannot bypass them. + +Register the route provider alongside the ordinary native QQQ endpoints: + +```java +server.withAdditionalRouteProviders(List.of(existingHttpRoutes, + new PrintingRealtimeRoutes(printingService))); +``` + +Use the **same PrintingService instance** in the QBit configuration and route +provider. `PrintingLocalHost.configure` shows the complete launcher integration. +The native HTTP claim route requires a connected socket that has applied the +current desired state and settings. Registering just the older HTTP provider is +insufficient. Close the provider when its host stops. + +## Independent control and document lanes + +The socket carries small JSON state, commands, inventory, heartbeats, job events +and durable receipts. A job notification triggers a finite HTTP claim drain; +an empty queue waits for another push. No timer repeatedly fetches claims or +commands. Heartbeats, session renewal and reconnect backoff are lifecycle work, +not job polling. + +Documents use separate outbound same-origin HTTPS transfers. The Windows service +streams them to protected staging files and checks declared length and SHA-256 +before native submission. The socket reader remains active during a slow download. +PDF rendering and ZPL spooling receive the verified file rather than a base64 +payload in the control pipe. Limits bound staging, messages and concurrency. + +Receipts remain on disk until the server acknowledges their committed IDs. A +reconnect resynchronizes current state/settings and replays retained receipts. +An interrupted native submission remains uncertain and is never automatically +reprinted. A successful spooler submission does not prove paper physically printed. + +## Native administrator settings + +Printing home links to two standard QQQ processes. They enforce current +administrator and tenant access on the backend as well as in metadata: + +- **Printing connection and limits**: PDF/ZPL size, copies, staging space, + concurrent downloads/prints, bandwidth, request/download timeouts, heartbeat, + reconnect backoff/jitter, acknowledgment timeout and control message queues. + Defaults are 100 MiB PDF, 20 MiB ZPL and one download/print lane. Saved policy + changes have a durable revision and are pushed to connected devices. +- **Printing queue and storage limits**: device/tenant outstanding counts and + bytes, retained bytes, hourly allocations and printer counts. New admissions + use the persisted policy inside their SQL transaction. Reducing limits preserves + existing jobs and history; it does not silently remove documents. + +Fields use readable MiB/GiB/seconds with exact conversion, required values and +cross-field validation. Unsupported client ceilings fail explicitly rather than +silently clamping. Storage retention remains in the separate native retention +process, with a one-year default. + +HTTP ingress/body limits remain host-owned. The local host derives its upload +body ceiling at startup from the persisted document limits (including base64 +expansion for legacy submission). Raising a document limit above the running +transport ceiling requires a host restart or corresponding production ingress +configuration. Download streaming does not base64-expand documents. + +## Host ownership and deployment boundary + +Commit wakeups are emitted after SQL commit. A subscription exists before initial +state reconstruction, closing the empty-claim/reconnect notification race. +`PrintingRealtimeBus` is an explicit host-owned notification and connection +ownership seam. The default implementation is in-process; this source does **not** +provide a distributed broker or claim tested multi-node production support. + +Use one writer/application host with its configured service/bus. Multiple route +providers sharing that explicit in-process bus fence a device's prior connection. +A multi-node deployment needs a host-provided shared implementation preserving +both post-commit notifications and single-channel ownership, and its own +qualification. Separate independent service instances do not magically publish +notifications to one another. + +The existing `/printing/v1` routes remain explicit legacy diagnostic compatibility. +They are not a real-time fallback and cannot authorize a native bound device. +The full message and settings contract is in +[the shared protocol](https://github.com/QRun-IO/qqq-print-protocol). diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 379a218..7ac9ea0 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -82,3 +82,13 @@ Final QBit UI pass: metadata uses printing_* table/data-source names, printing-p Durable target correction: core DurableJobsRuntime.effectLoop constructs a system session without user tenant keys. PrintEnqueueEffectTarget now has a private trusted resolver for that system-only outbox path, bound to the current instance PrintingQBitConfig service and its fixed tenant. Inline users retain scope checks; native process authorization is unchanged. Registered-target tests cover system deliver/reconcile, idempotent replay, foreign-tenant device rejection, cached target reuse across instances, and inline wrong/missing-tenant denial. `mvnw.cmd -o verify -Pui-demo dependency:build-classpath` passed all 16 tests and zero Checkstyle violations after the durable target correction, explicit disabled table writes, final dashboard timestamps, and frontend RC.37 profile update. UI runtime classpath regenerated at target/ui-runtime-classpath.txt. Primary identifiers remain non-hidden because QQQ excludes hidden fields from ordinary query results; explicit frontend column hints keep them out of the default operational grid. + +## Real-time transport implementation resumed — 2026-10-31 + +User resumed implementation after explicitly rejecting periodic job/control polling. Active isolated worktrees: qbit-printing-wt-realtime, qqq-print-protocol-wt-realtime, qqq-print-agent-wt-job-table (all branch feat/realtime-printing-20261031). Installed 0.2.0-rc.1 remains unchanged and is still polling; do not claim it is real-time. + +Protocol extension 1.1.0: client-initiated authenticated WSS, qqq-printing.v1 subprotocol, current QQQ session and secondary bound-device proof. Small state/commands/events/receipts travel directly over WebSocket; separate HTTPS documents stream to protected files. No timed polling fallback. Commit notifications and finite event-triggered claims, bounded concurrency, persistent receipts and reconnect reconciliation are being implemented. Tenant administrator forms expose document, transfer, connection, queue/rate/printer/storage limits. Default PDF limit100MiB, ZPL20MiB; both configurable. Root owns native forms/admission settings, server agent owns route/storage/service, Windows agent owns transport/pipeline, inventory agent completed protocol70definitions/72fixtures and now owns Jobs UI expansion/sorting. No live install or new physical print yet. New-source gates/integration are in progress; previous0.2 gates do not qualify these changes. + +## Real-time source verification — 2026-10-31 + +Server source0.3.0-rc.1 passes117 Java tests and zero Checkstyle violations, including real native-session WebSocket lifecycle, sessionId/sessionUUID, logout/channel rejection, replacement fencing, partial revision acknowledgments, no idle-inventory feedback, and21MiB native storage streaming/integrity. Protocol70definitions/72fixtures passes; both consumer workflows pin signed protocol67be986cb76d6b5d9a9f72cad054193b0d11b0d5. Native admin policy tests cover permission/tenant checks, exact250.5MiB conversion, revision persistence and invalid-capacity rejection. A private actual.NET/Java fixture passed13 functional checks with25MiB content, durable receipt replay, channel replacement and sign-out denial; its initial fixture JVM needed explicit maintenance-thread shutdown handling, so this is not installed lifecycle qualification. Root is preparing the final refreshed fixture run. Current installed0.2 remains unchanged; release/install acceptance is pending. diff --git a/docs/TODO.md b/docs/TODO.md index 099ed8a..bbdc046 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -79,3 +79,11 @@ The Windows gate, MSI rebuild, installed UI acceptance, release evidence and fre - [x] Complete independent review after the five baseline security findings are remediated; current details are in SESSION-STATE.md. - [ ] Complete remote deployment and production S3/IAM qualification; local native acceptance and isolated storage tests do not establish these. + +## Real-time upgrade (active) + +- [ ] Complete authenticated outbound WebSocket transport and automatic durable recovery; remove production job/control polling fallback. +- [ ] Verify concurrent control while large streamed documents download, bounds/cleanup, real concurrency and exact event replay. +- [ ] Verify native administrator limits forms, live policy delivery, admission overrides and configurable document sizes. +- [ ] Complete consistent sortable/expandable Windows Jobs and Printers tables. +- [ ] Run coordinated server/client/protocol gates and cross-runtime native integration before packaging/installing. diff --git a/pom.xml b/pom.xml index a2b161c..4d3bbcf 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.kingsrookqbit-build-parent2.0.0 com.kingsrook.qbits qbit-printing - 0.2.0-rc.1 + 0.3.0-rc.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/PrintingAdmission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingAdmission.java index c81bf9b..b1bb55d 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingAdmission.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingAdmission.java @@ -153,6 +153,10 @@ final class PrintingAdmission ***************************************************************************/ static void reserve(Connection connection, String tenant, String device, String id, int bytes, PrintingAdmissionLimits limits) throws SQLException { + if(number(connection, "SELECT COUNT(*) FROM printing_document_orphan WHERE tenant_id=? AND content_length IS NULL", tenant) != 0) + { + throw new PrintingException(429, "An old upload has an unknown document size; complete exact orphan cleanup before admitting new documents"); + } if(number(connection, "SELECT COUNT(*) FROM printing_resource_usage WHERE tenant_id=? AND device_id=?", tenant, device) == 0) { execute(connection, "INSERT INTO printing_resource_usage VALUES (?, ?, 0)", tenant, device); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingAdmissionSettings.java b/src/main/java/com/kingsrook/qbits/printing/PrintingAdmissionSettings.java new file mode 100644 index 0000000..254ce07 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingAdmissionSettings.java @@ -0,0 +1,178 @@ +/* + * 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 javax.sql.DataSource; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import org.json.JSONObject; + + +/******************************************************************************* + ** Tenant admission overrides are read in the same transaction as admission. + *******************************************************************************/ +public class PrintingAdmissionSettings +{ + private static final List FIELDS = List.of("deviceOutstandingJobs", "tenantOutstandingJobs", "deviceOutstandingBytes", "tenantOutstandingBytes", + "deviceRetainedBytes", "tenantRetainedBytes", "deviceJobsPerHour", "tenantJobsPerHour", "devicePrinters", "tenantPrinters"); + private static final long MAX_EXACT_JSON_INTEGER = 9_007_199_254_740_991L; + private final DataSource source; + private final String tenant; + private final PrintingAdmissionLimits defaults; + + + + /*************************************************************************** + ** The host supplies immutable defaults until an administrator saves an override. + ***************************************************************************/ + public PrintingAdmissionSettings(DataSource source, String tenant, PrintingAdmissionLimits defaults) + { + this.source = Objects.requireNonNull(source); + this.tenant = Objects.requireNonNull(tenant); + this.defaults = Objects.requireNonNull(defaults); + } + + + + /*************************************************************************** + ** The enclosing service ensures the tenant settings row before public reads. + ***************************************************************************/ + public JSONObject get() throws SQLException + { + try(Connection connection = source.getConnection()) + { + return (json(effective(connection))); + } + } + + + + /*************************************************************************** + ** Validate all limits before one atomic tenant-scoped update. + ***************************************************************************/ + public JSONObject save(JSONObject requested) throws SQLException + { + PrintingAdmissionLimits limits = parse(requested); + JSONObject value = json(limits); + try(Connection connection = source.getConnection(); var update = connection.prepareStatement("UPDATE printing_settings SET admission_policy_json=? WHERE tenant_id=?")) + { + update.setString(1, value.toString()); + update.setString(2, tenant); + if(update.executeUpdate() != 1) + { + throw new SQLException("The tenant admission settings row is unavailable"); + } + } + return (value); + } + + + + /*************************************************************************** + ** Do not open a nested connection while a queue transaction holds its locks. + ***************************************************************************/ + public PrintingAdmissionLimits effective(Connection connection) throws SQLException + { + try(var query = connection.prepareStatement("SELECT admission_policy_json FROM printing_settings WHERE tenant_id=?")) + { + query.setString(1, tenant); + try(var rows = query.executeQuery()) + { + if(!rows.next()) + { + return (defaults); + } + String stored = rows.getString(1); + return (stored == null ? defaults : parse(new JSONObject(stored))); + } + } + } + + + + /*************************************************************************** + ** Every configured limit is explicit, positive and exactly representable. + ***************************************************************************/ + private static PrintingAdmissionLimits parse(JSONObject value) + { + if(!value.keySet().equals(java.util.Set.copyOf(FIELDS))) + { + throw new IllegalArgumentException("Specify all admission limits without unknown fields"); + } + try + { + return (new PrintingAdmissionLimits(integer(value, "deviceOutstandingJobs"), integer(value, "tenantOutstandingJobs"), + number(value, "deviceOutstandingBytes"), number(value, "tenantOutstandingBytes"), number(value, "deviceRetainedBytes"), number(value, "tenantRetainedBytes"), + integer(value, "deviceJobsPerHour"), integer(value, "tenantJobsPerHour"), integer(value, "devicePrinters"), integer(value, "tenantPrinters"))); + } + catch(ArithmeticException exception) + { + throw new IllegalArgumentException("Admission counts must fit the supported integer range", exception); + } + } + + + + /*************************************************************************** + ** Counts follow the database's integer representation without truncation. + ***************************************************************************/ + private static int integer(JSONObject value, String field) + { + return (Math.toIntExact(number(value, field))); + } + + + + /*************************************************************************** + ** Reject quoted numbers, fractions, overflow and disabled capacity boundaries. + ***************************************************************************/ + private static long number(JSONObject value, String field) + { + if(!(value.opt(field) instanceof Number number)) + { + throw new IllegalArgumentException(field + " must be a positive integer"); + } + long parsed = new BigDecimal(number.toString()).longValueExact(); + if(parsed < 1 || parsed > MAX_EXACT_JSON_INTEGER) + { + throw new IllegalArgumentException(field + " is outside the supported positive range"); + } + return (parsed); + } + + + + /*************************************************************************** + ** Native forms and host defaults share one stable field vocabulary. + ***************************************************************************/ + private static JSONObject json(PrintingAdmissionLimits value) + { + return (new JSONObject().put("deviceOutstandingJobs", value.deviceOutstandingJobs()).put("tenantOutstandingJobs", value.tenantOutstandingJobs()) + .put("deviceOutstandingBytes", value.deviceOutstandingBytes()).put("tenantOutstandingBytes", value.tenantOutstandingBytes()) + .put("deviceRetainedBytes", value.deviceRetainedBytes()).put("tenantRetainedBytes", value.tenantRetainedBytes()) + .put("deviceJobsPerHour", value.deviceJobsPerHour()).put("tenantJobsPerHour", value.tenantJobsPerHour()) + .put("devicePrinters", value.devicePrinters()).put("tenantPrinters", value.tenantPrinters())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java index 741b501..beb3cf2 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java @@ -281,6 +281,7 @@ final class PrintingDeviceControls { throw new PrintingException(409, "Unknown control revision"); } + applied = Math.max(applied, device.getLong("control_applied_revision")); String now = Instant.now().toString(); update(connection, "UPDATE printing_device SET control_version=1,control_capabilities=?,control_last_poll_at=?,control_applied_revision=?,agent_version=?,last_seen_at=? WHERE tenant_id=? AND id=?", capabilities.toString(), now, applied, version, now, tenant, deviceId); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java index de7f8a9..7445564 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStorage.java @@ -94,15 +94,22 @@ public class PrintingDocumentStorage ** Bound reads even if stored content was replaced outside this service. ***************************************************************************/ public byte[] get(String table, String reference) throws SQLException + { + return (get(table, reference, Math.toIntExact(PrintingRealtimePolicy.defaults().get("maxPdfBytes")))); + } + + + /** Internal retry callers supply the exact persisted length, independent of the current format policy. */ + public byte[] get(String table, String reference, int maximum) throws SQLException { return (execute(() -> { try(InputStream stream = new StorageAction().getInputStream(input(table, reference))) { - byte[] bytes = stream.readNBytes(20 * 1024 * 1024 + 1); - if(bytes.length > 20 * 1024 * 1024) + byte[] bytes = stream.readNBytes(maximum); + if(stream.read() != -1) { - throw new PrintingException(502, "Stored print document exceeds the allowed size"); + throw new PrintingException(502, "Stored print document exceeds its declared length"); } return bytes; } @@ -110,6 +117,13 @@ public class PrintingDocumentStorage } + /** Return the native backend stream; the request owns closing it and enforcing its declared length. */ + public InputStream open(String table, String reference) throws SQLException + { + return (execute(() -> new StorageAction().getInputStream(input(table, reference)))); + } + + /*************************************************************************** ** Backend deletion is idempotent; callers mark receipts only after success. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStream.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStream.java new file mode 100644 index 0000000..91e6d06 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDocumentStream.java @@ -0,0 +1,90 @@ +/* + * 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.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + + +/** Length and digest verification remains bounded while transferring a retained object. */ +final class PrintingDocumentStream extends FilterInputStream +{ + private final long length; + private final String expectedHash; + private final MessageDigest digest; + private long received; + private boolean verified; + + + /** Wrap a source with immutable document integrity metadata. */ + PrintingDocumentStream(InputStream input, long length, String expectedHash) + { + super(input); + this.length = length; + this.expectedHash = expectedHash; + try + { + digest = MessageDigest.getInstance("SHA-256"); + } + catch(NoSuchAlgorithmException exception) + { + throw new IllegalStateException(exception); + } + } + + + @Override + public int read() throws IOException + { + byte[] one = new byte[1]; + return (read(one, 0, 1) == -1 ? -1 : Byte.toUnsignedInt(one[0])); + } + + + @Override + public int read(byte[] bytes, int offset, int count) throws IOException + { + int read = in.read(bytes, offset, (int) Math.min(count, Math.max(1, length - received + 1))); + if(read > 0) + { + received += read; + if(received > length) + { + throw new IOException("Stored print document exceeds its declared length"); + } + digest.update(bytes, offset, read); + } + else if(read == -1 && !verified) + { + verified = true; + if(received != length || !HexFormat.of().formatHex(digest.digest()).equals(expectedHash)) + { + throw new IOException("Stored print document integrity check failed"); + } + } + return (read); + } +} \ No newline at end of file diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java b/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java index 6425b7a..b127dac 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java @@ -153,6 +153,8 @@ public class PrintingHome extends AbstractWidgetRenderer .withBlock(text("Scope: current tenant. Devices/printers: now. Job exceptions/recent work: last 24 hours.")) .withBlock(text("Enroll a print device").withLink(new BlockLink("/app/printingEnrollDevice/?returnTo=%2Fapp%2Fprinting%2F"))) .withBlock(text("Printing retention settings").withLink(new BlockLink("/app/printingRetentionSettings/?returnTo=%2Fapp%2Fprinting%2F"))) + .withBlock(text("Printing connection and limits").withLink(new BlockLink("/app/printingRealtimeSettings/?returnTo=%2Fapp%2Fprinting%2F"))) + .withBlock(text("Printing queue and storage limits").withLink(new BlockLink("/app/printingAdmissionSettings/?returnTo=%2Fapp%2Fprinting%2F"))) .withBlock(text("Run retention cleanup").withLink(new BlockLink("/app/printingRunRetention/?returnTo=%2Fapp%2Fprinting%2F"))) .withBlock(text("Printing setup and help").withLink(new BlockLink("https://github.com/QRun-IO/qbit-printing/blob/main/docs/OPERATIONS.md"))); if(count(DEVICE, new QQueryFilter()) == 0) diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java index 44dbd46..b90eafc 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingLocalHost.java @@ -132,13 +132,23 @@ public class PrintingLocalHost extends AbstractQQQApplication ***************************************************************************/ void configure(QApplicationJavalinServer server, boolean frontend) { + long uploadBodyLimit; + try + { + PrintingRealtimePolicy policy = service.realtimePolicy(); + uploadBodyLimit = ((Math.max(policy.get("maxPdfBytes"), policy.get("maxZplBytes")) + 2) / 3) * 4 + 65536; + } + catch(java.sql.SQLException exception) + { + throw new IllegalStateException("Cannot load printing transport limits", exception); + } PrintingRoutes routes = new PrintingRoutes(service, context -> MessageDigest.isEqual( settings.administratorToken().getBytes(StandardCharsets.UTF_8), PrintingRoutes.bearer(context).getBytes(StandardCharsets.UTF_8)), PrintingRoutes.LegacyEnrollment.DISABLED); server.withPort(settings.port()).withServeFrontendNext(frontend).withServeFrontendMaterialDashboard(false) - .withAdditionalRouteProviders(List.of(routes)).withJavalinConfigCustomizer(config -> + .withAdditionalRouteProviders(List.of(routes, new PrintingRealtimeRoutes(service))).withJavalinConfigCustomizer(config -> { config.jetty.host = "127.0.0.1"; - config.http.maxRequestSize = 29 * 1024 * 1024; + config.http.maxRequestSize = uploadBodyLimit; config.routes.before(PrintingLocalHost::guardOrigin); }); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java index ca31f1f..053e8a8 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java @@ -176,6 +176,7 @@ public class PrintingQBitProducer implements QBitProducer PrintingDeviceProcesses.register(instance); PrintingUiApi.register(instance); PrintingRetentionProcesses.register(instance, config); + PrintingRealtimeProcesses.register(instance, app); PrintingHome.register(instance, app); instance.addApp(app); QAppMetaData parent = instance.getApp("admin"); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeBus.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeBus.java new file mode 100644 index 0000000..5662ec8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeBus.java @@ -0,0 +1,91 @@ +/* + * 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.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiConsumer; + + +/** Host-owned commit notification bridge. Callbacks only schedule bounded work. */ +public class PrintingRealtimeBus +{ + private final CopyOnWriteArrayList> listeners = new CopyOnWriteArrayList<>(); + private final java.util.concurrent.ConcurrentHashMap owners = new java.util.concurrent.ConcurrentHashMap<>(); + + + /** Atomically fence the preceding connection across route providers sharing this host bus. */ + public void acquire(String tenant, String device, String generation, Runnable replaced) + { + Owner previous = owners.put(tenant + ":" + device, new Owner(generation, replaced)); + if(previous != null) + { + previous.replaced().run(); + } + } + + + /** A replaced connection cannot claim or send even if its local close callback is delayed. */ + public boolean owns(String tenant, String device, String generation) + { + Owner current = owners.get(tenant + ":" + device); + return (current != null && current.generation().equals(generation)); + } + + + /** Release only the exact generation; old disconnects cannot remove a replacement. */ + public void release(String tenant, String device, String generation) + { + owners.computeIfPresent(tenant + ":" + device, (key, owner) -> owner.generation().equals(generation) ? null : owner); + } + + + /** Ownership contains no device token or user credential. */ + private record Owner(String generation, Runnable replaced) + { + } + + + /** Register before reading the durable snapshot to avoid a reconnect lost wakeup. */ + public AutoCloseable subscribe(BiConsumer listener) + { + listeners.add(listener); + return (() -> listeners.remove(listener)); + } + + + /** Publish only after commit. Shared-host implementations must preserve this nonblocking contract. */ + public void publish(String tenant, String device) + { + for(BiConsumer listener : listeners) + { + try + { + listener.accept(tenant, device); + } + catch(RuntimeException ignored) + { + // A stopped listener must not turn a successful commit into an apparent failure. + // Reconnection always reconstructs work from the durable database. + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimePolicy.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimePolicy.java new file mode 100644 index 0000000..5116fb6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimePolicy.java @@ -0,0 +1,172 @@ +/* + * 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.util.LinkedHashMap; +import java.util.Map; +import org.json.JSONObject; + + +/** Validated administrator policy, independent of any individual client connection. */ +public final class PrintingRealtimePolicy +{ + /** Canonical policy defaults and validation ranges. */ + private static final Map FIELDS = fields(); + private final JSONObject values; + + + /** Keep the validated policy private. */ + private PrintingRealtimePolicy(JSONObject values) + { + this.values = values; + } + + + /** Operational defaults; administrators can explicitly change every limit. */ + public static PrintingRealtimePolicy defaults() + { + JSONObject values = new JSONObject(); + FIELDS.forEach((name, range) -> values.put(name, range[0])); + return (new PrintingRealtimePolicy(values)); + } + + + /** Require a complete policy and reject unknown, fractional or out-of-range values. */ + public static PrintingRealtimePolicy fromJson(JSONObject input) + { + if(!input.keySet().equals(FIELDS.keySet())) + { + throw new PrintingException(400, "Realtime settings require exactly the documented policy fields"); + } + JSONObject values = new JSONObject(); + FIELDS.forEach((name, range) -> + { + Object raw = input.get(name); + if(!(raw instanceof Number number) || !number.toString().matches("[0-9]+")) + { + throw new PrintingException(400, "Realtime setting must be an integer: " + name); + } + long value; + try + { + value = Long.parseLong(number.toString()); + } + catch(NumberFormatException exception) + { + throw new PrintingException(400, "Realtime setting exceeds its range: " + name); + } + if(value < range[1] || value > range[2]) + { + throw new PrintingException(400, "Realtime setting exceeds its range: " + name); + } + values.put(name, value); + }); + PrintingRealtimePolicy policy = new PrintingRealtimePolicy(values); + policy.atLeast("maxQueuedFrameBytes", policy.get("maxFrameBytes")); + policy.atLeast("maxStagedBytes", Math.max(policy.get("maxPdfBytes"), policy.get("maxZplBytes"))); + policy.atLeast("downloadTimeoutMillis", Math.max(policy.get("downloadConnectTimeoutMillis"), policy.get("downloadIdleTimeoutMillis"))); + policy.atLeast("heartbeatTimeoutSeconds", 2 * policy.get("heartbeatIntervalSeconds")); + policy.atLeast("reconnectMaxDelayMillis", policy.get("reconnectMinDelayMillis")); + return (policy); + } + + + /** Read a validated named setting without exposing mutable JSON. */ + public long get(String name) + { + return (values.getLong(name)); + } + + + /** Wire settings include a server-generated durable revision. */ + public JSONObject toJson(long revision) + { + return (new JSONObject(values.toString()).put("revision", revision)); + } + + + /** Submission and retained-document retrieval use the configured format bound. */ + public long documentLimit(String format) + { + if(!"PDF".equals(format) && !"ZPL".equals(format)) + { + throw new PrintingException(400, "Only PDF and ZPL are supported"); + } + return (get("PDF".equals(format) ? "maxPdfBytes" : "maxZplBytes")); + } + + + /** A client must explicitly support the policy; never silently lower administrator limits. */ + public void requireClientLimits(JSONObject limits) + { + for(String name : new String[] {"maxPdfBytes", "maxZplBytes", "maxFrameBytes", "maxQueuedFrames", "maxQueuedFrameBytes", "maxConcurrentDownloads", "maxConcurrentPrints", "maxStagedBytes", "maxCopies"}) + { + Object value = limits.opt(name); + if(!(value instanceof Number number) || !number.toString().matches("[0-9]+") || number.longValue() < get(name)) + { + throw new PrintingException(409, "Client cannot support configured realtime limit: " + name); + } + } + } + + + /** Validate a cross-field lower bound. */ + + + private void atLeast(String name, long minimum) + { + if(get(name) < minimum) + { + throw new PrintingException(400, "Realtime settings conflict at " + name); + } + } + + + /** Canonical policy defaults and validation ranges. */ + private static Map fields() + { + Map fields = new LinkedHashMap<>(); + fields.put("maxPdfBytes", new long[] {104857600, 1, Integer.MAX_VALUE}); + fields.put("maxCopies", new long[] {100, 1, 32767}); + fields.put("maxZplBytes", new long[] {20971520, 1, Integer.MAX_VALUE}); + fields.put("maxFrameBytes", new long[] {1048576, 8192, 16777216}); + fields.put("maxQueuedFrames", new long[] {64, 8, 4096}); + fields.put("maxQueuedFrameBytes", new long[] {4194304, 8192, 67108864}); + fields.put("maxConcurrentDownloads", new long[] {1, 1, 16}); + fields.put("maxConcurrentPrints", new long[] {1, 1, 16}); + fields.put("maxStagedBytes", new long[] {536870912, 1, 9007199254740991L}); + fields.put("maxDownloadBytesPerSecond", new long[] {0, 0, Integer.MAX_VALUE}); + fields.put("requestTimeoutMillis", new long[] {30000, 1000, 300000}); + fields.put("downloadConnectTimeoutMillis", new long[] {30000, 1000, 300000}); + fields.put("webSocketHandshakeTimeoutMillis", new long[] {30000, 1000, 300000}); + fields.put("ackTimeoutMillis", new long[] {15000, 1000, 300000}); + fields.put("downloadIdleTimeoutMillis", new long[] {30000, 1000, 3600000}); + fields.put("downloadTimeoutMillis", new long[] {600000, 1000, 86400000}); + fields.put("nativeSubmissionTimeoutMillis", new long[] {120000, 1000, 86400000}); + fields.put("heartbeatIntervalSeconds", new long[] {20, 5, 300}); + fields.put("heartbeatTimeoutSeconds", new long[] {60, 10, 900}); + fields.put("reconnectMinDelayMillis", new long[] {1000, 100, 60000}); + fields.put("reconnectMaxDelayMillis", new long[] {30000, 1000, 300000}); + fields.put("reconnectJitterPercent", new long[] {20, 0, 50}); + return (java.util.Collections.unmodifiableMap(fields)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeProcesses.java new file mode 100644 index 0000000..064db94 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeProcesses.java @@ -0,0 +1,256 @@ +/* + * 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.math.BigDecimal; +import java.sql.SQLException; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import org.json.JSONObject; + + +/******************************************************************************* + ** Native tenant settings use human units without exposing connection secrets. + *******************************************************************************/ +public class PrintingRealtimeProcesses implements BackendStep +{ + public static final String SETTINGS = "printingRealtimeSettings"; + public static final String ADMISSION = "printingAdmissionSettings"; + private static final long MEBIBYTE = 1024L * 1024; + private static final List DOCUMENTS = List.of( + new Setting("maxPdfMiB", "maxPdfBytes", "Maximum PDF size (MiB)", MEBIBYTE), + new Setting("maxZplMiB", "maxZplBytes", "Maximum ZPL size (MiB)", MEBIBYTE), + new Setting("maxCopies", "maxCopies", "Maximum copies per job", 1), + new Setting("maxStagedMiB", "maxStagedBytes", "Maximum staged documents on each device (MiB)", MEBIBYTE)); + private static final List TRANSFERS = List.of( + new Setting("maxConcurrentDownloads", "maxConcurrentDownloads", "Concurrent document downloads", 1), + new Setting("maxConcurrentPrints", "maxConcurrentPrints", "Concurrent printer submissions", 1), + new Setting("downloadKiBPerSecond", "maxDownloadBytesPerSecond", "Download bandwidth (KiB/second; 0 means unlimited)", 1024), + new Setting("requestTimeoutSeconds", "requestTimeoutMillis", "Request timeout (seconds)", 1000), + new Setting("downloadConnectSeconds", "downloadConnectTimeoutMillis", "Download connection timeout (seconds)", 1000), + new Setting("downloadIdleSeconds", "downloadIdleTimeoutMillis", "Download idle timeout (seconds)", 1000), + new Setting("downloadTotalSeconds", "downloadTimeoutMillis", "Total download timeout (seconds)", 1000), + new Setting("nativeSubmissionSeconds", "nativeSubmissionTimeoutMillis", "Native printer submission timeout (seconds)", 1000)); + private static final List CONNECTION = List.of( + new Setting("handshakeSeconds", "webSocketHandshakeTimeoutMillis", "WebSocket connection timeout (seconds)", 1000), + new Setting("heartbeatIntervalSeconds", "heartbeatIntervalSeconds", "Connection heartbeat interval (seconds)", 1), + new Setting("heartbeatTimeoutSeconds", "heartbeatTimeoutSeconds", "Unresponsive connection timeout (seconds)", 1), + new Setting("reconnectMinSeconds", "reconnectMinDelayMillis", "Initial reconnect delay (seconds)", 1000), + new Setting("reconnectMaxSeconds", "reconnectMaxDelayMillis", "Maximum reconnect delay (seconds)", 1000), + new Setting("reconnectJitterPercent", "reconnectJitterPercent", "Reconnect jitter (%)", 1), + new Setting("ackTimeoutSeconds", "ackTimeoutMillis", "Acknowledgment timeout (seconds)", 1000)); + private static final List CONTROL = List.of( + new Setting("maxFrameKiB", "maxFrameBytes", "Maximum control message (KiB)", 1024), + new Setting("maxQueuedFrames", "maxQueuedFrames", "Maximum queued control messages", 1), + new Setting("maxQueuedControlMiB", "maxQueuedFrameBytes", "Maximum queued control message data (MiB)", MEBIBYTE)); + private static final List ADMISSION_COUNTS = List.of( + new Setting("deviceOutstandingJobs", "deviceOutstandingJobs", "Outstanding jobs per device", 1), + new Setting("tenantOutstandingJobs", "tenantOutstandingJobs", "Outstanding jobs across this tenant", 1), + new Setting("deviceJobsPerHour", "deviceJobsPerHour", "Jobs allocated per device per hour", 1), + new Setting("tenantJobsPerHour", "tenantJobsPerHour", "Jobs allocated across this tenant per hour", 1), + new Setting("devicePrinters", "devicePrinters", "Printers per device", 1), + new Setting("tenantPrinters", "tenantPrinters", "Printers across this tenant", 1)); + private static final List ADMISSION_STORAGE = List.of( + new Setting("deviceOutstandingMiB", "deviceOutstandingBytes", "Outstanding document data per device (MiB)", MEBIBYTE), + new Setting("tenantOutstandingMiB", "tenantOutstandingBytes", "Outstanding document data across this tenant (MiB)", MEBIBYTE), + new Setting("deviceRetainedGiB", "deviceRetainedBytes", "Retained document data per device (GiB)", 1024 * MEBIBYTE), + new Setting("tenantRetainedGiB", "tenantRetainedBytes", "Retained document data across this tenant (GiB)", 1024 * MEBIBYTE)); + + + + /*************************************************************************** + ** Typed native forms group limits by purpose and reuse normal QQQ permissions. + ***************************************************************************/ + public static void register(QInstance instance, QAppMetaData app) + { + QProcessMetaData process = new QProcessMetaData().withName(SETTINGS).withLabel("Printing connection and limits") + .withPermissionRules(PrintingProcesses.permissionRules()) + .withStep(backend("prepare")) + .withStep(new QFrontendStepMetaData().withName("review").withLabel("Current connection policy") + .withViewFields(List.of(text("transport", "Connection"), text("scope", "Settings scope"), text("revisionLabel", "Current policy"))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))) + .withStep(form("documents", "Document limits", DOCUMENTS)) + .withStep(form("transfers", "Document transfers", TRANSFERS)) + .withStep(form("connection", "Connection recovery", CONNECTION)) + .withStep(form("control", "Control channel capacity", CONTROL)) + .withStep(backend("execute")) + .withStep(new QFrontendStepMetaData().withName("result").withLabel("Result").withViewFields(List.of(text("result", "Result"))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))); + instance.addProcess(process); + app.withChild(process); + QProcessMetaData admission = new QProcessMetaData().withName(ADMISSION).withLabel("Printing queue and storage limits") + .withPermissionRules(PrintingProcesses.permissionRules()).withStep(backend("prepare")) + .withStep(new QFrontendStepMetaData().withName("review").withLabel("Current admission policy") + .withViewFields(List.of(text("scope", "Settings scope"))).withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))) + .withStep(form("counts", "Queue, rate and printer limits", ADMISSION_COUNTS)) + .withStep(form("capacity", "Document storage capacity", ADMISSION_STORAGE)) + .withStep(backend("execute")) + .withStep(new QFrontendStepMetaData().withName("result").withLabel("Result").withViewFields(List.of(text("result", "Result"))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))); + instance.addProcess(admission); + app.withChild(admission); + } + + + + /*************************************************************************** + ** Each backend entrypoint independently checks the current user and tenant. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + PrintingControlAuthorization.principal(); + boolean admission = ADMISSION.equals(input.getProcessName()); + if(!SETTINGS.equals(input.getProcessName()) && !admission) + { + throw new QException("Unknown printing connection settings action"); + } + PrintingService service = PrintingProcesses.currentService(); + try + { + if("prepare".equals(input.getStepName())) + { + JSONObject policy = admission ? service.getAdmissionSettings() : service.getRealtimeSettings(); + for(List group : groups(admission)) + { + for(Setting setting : group) + { + output.addValue(setting.input(), BigDecimal.valueOf(policy.getLong(setting.policy())).divide(BigDecimal.valueOf(setting.unit()))); + } + } + output.addValue("transport", "The device opens a two-way WebSocket to this server. Documents use separate HTTPS transfers; job and command polling is not used."); + output.addValue("scope", admission ? "These limits apply to this tenant. Capacity is checked when jobs and printers are admitted. Changing a limit does not remove existing jobs, documents or history." + : "These settings apply to this tenant. Saved changes are sent to connected devices. Unsupported device limits require an explicit configuration correction; they are not silently reduced."); + output.addValue("revisionLabel", admission ? "Tenant admission policy" : "Policy revision " + policy.getLong("revision")); + } + else if("execute".equals(input.getStepName())) + { + JSONObject requested = new JSONObject(); + for(List group : groups(admission)) + { + for(Setting setting : group) + { + requested.put(setting.policy(), number(input, setting)); + } + } + JSONObject saved = admission ? service.saveAdmissionSettings(requested) : service.saveRealtimeSettings(requested); + output.addValue("result", admission ? "Queue, rate, printer and storage limits saved for this tenant. Existing jobs and history were preserved." + : "Connection and document limits saved as policy revision " + saved.getLong("revision") + ". Connected devices receive the update over their existing WebSocket; no documents or job history were removed."); + } + else + { + throw new QException("Unknown printing connection settings step"); + } + } + catch(SQLException | PrintingException | IllegalArgumentException exception) + { + throw new QException("Connection settings were not saved: " + exception.getMessage(), exception); + } + } + + + + /*************************************************************************** + ** Numeric conversions reject missing, rounded and overflowing input values. + ***************************************************************************/ + private static long number(RunBackendStepInput input, Setting setting) throws QException + { + Object value = input.getValue(setting.input()); + if(value == null) + { + throw new QException("Specify " + setting.label().toLowerCase(java.util.Locale.ROOT)); + } + try + { + return (new BigDecimal(value.toString()).multiply(BigDecimal.valueOf(setting.unit())).longValueExact()); + } + catch(NumberFormatException | ArithmeticException exception) + { + throw new QException(setting.label() + " must be a valid number within the supported range", exception); + } + } + + + + /*************************************************************************** + ** Keep reading and writing the same complete set of advertised policy fields. + ***************************************************************************/ + private static List> groups(boolean admission) + { + return (admission ? List.of(ADMISSION_COUNTS, ADMISSION_STORAGE) : List.of(DOCUMENTS, TRANSFERS, CONNECTION, CONTROL)); + } + + + + /*************************************************************************** + ** Human-readable units remain local to the form, never part of wire identity. + ***************************************************************************/ + private static QFrontendStepMetaData form(String name, String label, List settings) + { + return (new QFrontendStepMetaData().withName(name).withLabel(label) + .withFormFields(settings.stream().map(setting -> new QFieldMetaData(setting.input(), setting.unit() == 1 ? QFieldType.INTEGER : QFieldType.DECIMAL) + .withLabel(setting.label()).withIsRequired(true)).toList()) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))); + } + + + + /*************************************************************************** + ** Every step uses the same native backend and read-only result widgets. + ***************************************************************************/ + private static QBackendStepMetaData backend(String name) + { + return (new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRealtimeProcesses.class))); + } + + + + /*************************************************************************** + ** View fields contain policy descriptions rather than editable identifiers. + ***************************************************************************/ + private static QFieldMetaData text(String name, String label) + { + return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label)); + } + + + + /*************************************************************************** + ** Unit factors make the form reversible without losing byte precision. + ***************************************************************************/ + private record Setting(String input, String policy, String label, long unit) + { + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java new file mode 100644 index 0000000..c70393a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java @@ -0,0 +1,669 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import com.kingsrook.qqq.backend.core.actions.profile.UserProfiles; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher; +import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface; +import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils; +import io.javalin.config.JavalinConfig; +import io.javalin.http.Context; +import io.javalin.http.HttpResponseException; +import io.javalin.websocket.WsContext; +import org.eclipse.jetty.websocket.api.Callback; +import org.json.JSONArray; +import org.json.JSONObject; + + +/** Current-user authenticated outbound channels. SQL is authoritative; notifications are only wakeups. */ +public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterface, AutoCloseable +{ + public static final String PATH = "/qqq/v1/qbit/printing/device/realtime"; + public static final String SUBPROTOCOL = "qqq-printing.v1"; + private final PrintingService service; + private final Map channels = new ConcurrentHashMap<>(); + private final ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor(); + private final AutoCloseable subscription; + private QInstance instance; + + + /** Register this provider alongside the host's ordinary native QQQ endpoints. */ + public PrintingRealtimeRoutes(PrintingService service) + { + this.service = java.util.Objects.requireNonNull(service); + service.withRealtimeClaimGuard(this::requireReady); + subscription = service.getRealtimeBus().subscribe((tenant, device) -> + { + if(service.getTenantId().equals(tenant)) + { + channels.values().stream().filter(channel -> device == null || device.equals(channel.device)).forEach(Channel::changed); + } + }); + } + + + @Override + public void setQInstance(QInstance value) + { + instance = value; + } + + + @Override + public void acceptJavalinConfig(JavalinConfig config) + { + config.routes.wsBeforeUpgrade(PATH, context -> + { + try + { + validateUpgrade(context); + authorize(context); + 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"))); + } + catch(Exception exception) + { + throw new HttpResponseException(exception instanceof QAuthenticationException ? 401 : 403, "Realtime authentication or origin denied", Map.of()); + } + finally + { + QContext.clear(); + } + }); + config.routes.ws(PATH, socket -> + { + socket.onConnect(context -> + { + String device = context.header(PrintingAgentAuthorization.DEVICE_HEADER); + Channel next = new Channel(context, device, context.attribute("printingRealtimeCredential")); + next.policy = service.realtimePolicy(); + Channel previous = channels.put(device, next); + if(previous != null) + { + previous.stop("Connection replaced"); + } + service.getRealtimeBus().acquire(service.getTenantId(), device, next.generation, () -> next.stop("Connection replaced")); + context.session.setMaxTextMessageSize(next.policy.get("maxFrameBytes")); + context.session.setMaxBinaryMessageSize(0); + context.session.setIdleTimeout(Duration.ofMillis(next.policy.get("webSocketHandshakeTimeoutMillis"))); + }); + socket.onMessage(context -> + { + Channel channel = current(context); + if(channel != null) + { + channel.accept(context.message()); + } + }); + socket.onBinaryMessage(context -> context.closeSession(1003, "Only JSON text is supported")); + socket.onClose(context -> remove(context)); + socket.onError(context -> remove(context)); + }); + config.events.serverStopping(this::close); + } + + + /** Resolve only the current socket generation. */ + + + private Channel current(WsContext context) + { + Channel channel = channels.get(context.header(PrintingAgentAuthorization.DEVICE_HEADER)); + return (channel != null && channel.socket.session == context.session ? channel : null); + } + + + /** Remove only the closing socket generation. */ + + + private void remove(WsContext context) + { + Channel channel = current(context); + if(channel != null) + { + channel.stop("Connection closed"); + } + } + + + /** Gate native claims on acknowledged current settings. */ + + + private void requireReady(String device) throws java.sql.SQLException + { + Channel channel = channels.get(device); + if(channel == null || !channel.ready(service.getRealtimeSettings().getLong("revision"))) + { + throw new PrintingException(409, "An authenticated realtime connection must apply current state and settings before claiming work"); + } + } + + + /** Reject query credentials, non-session authentication and foreign origins. */ + + + private static void validateUpgrade(Context context) + { + if(context.queryString() != null || !SUBPROTOCOL.equals(context.header("Sec-WebSocket-Protocol")) + || ((context.cookie("sessionUUID") == null || context.cookie("sessionUUID").isBlank()) && (context.cookie("sessionId") == null || context.cookie("sessionId").isBlank())) + || context.header("Authorization") != null || context.header("x-api-key") != null || context.header("X-QQQ-In-Process-Session") != null) + { + throw new PrintingException(403, "A standard QQQ session and the printing subprotocol are required"); + } + URI request = URI.create(context.url()); + URI origin = URI.create(java.util.Objects.toString(context.header("Origin"), "")); + boolean loopback = Set.of("localhost", "127.0.0.1", "[::1]", "::1").contains(request.getHost()); + if(!("https".equals(request.getScheme()) || (loopback && "http".equals(request.getScheme()))) + || !java.util.Objects.equals(request.getScheme(), origin.getScheme()) + || !java.util.Objects.equals(request.getRawAuthority(), origin.getRawAuthority()) + || origin.getRawUserInfo() != null || origin.getRawQuery() != null || origin.getRawFragment() != null + || !(origin.getPath().isEmpty() || "/".equals(origin.getPath()))) + { + throw new PrintingException(403, "Realtime origin must match the authenticated server origin"); + } + } + + + /** Revalidate the standard current QQQ session and secondary device ownership. */ + + + private void authorize(Context context) throws Exception + { + ExecutorSessionUtils.setupSession(context, instance); + PrintingAgentAuthorization.device(context.header(PrintingAgentAuthorization.DEVICE_HEADER), context.header(PrintingAgentAuthorization.TOKEN_HEADER), false); + } + + + /** Only transport credentials survive upgrade; user roles and session authority are never cached. */ + private static final class Credential + { + private final String cookie; + private final String cookieName; + private final String proof; + private final String clientChannel; + + + /** Capture immutable authenticated transport inputs before Jetty releases the HTTP request. */ + private Credential(String cookieName, String cookie, String proof, String clientChannel) + { + this.cookieName = cookieName; + this.cookie = cookie; + this.proof = proof; + this.clientChannel = clientChannel; + } + + + @Override + public String toString() + { + return ("Printing realtime credential [redacted]"); + } + } + + + @Override + public void close() + { + channels.values().forEach(channel -> channel.stop("Server stopping")); + try + { + subscription.close(); + } + catch(Exception exception) + { + throw new IllegalStateException("Unable to remove realtime subscription", exception); + } + finally + { + workers.shutdown(); + } + } + + + /** One serial bounded mailbox per socket; network waits never run on the committing caller. */ + private final class Channel + { + private final WsContext socket; + private final String device; + private final String generation = UUID.randomUUID().toString(); + private final Credential credential; + private final ArrayDeque mailbox = new ArrayDeque<>(); + private PrintingRealtimePolicy policy = PrintingRealtimePolicy.defaults(); + private JSONObject hello; + private boolean running; + private boolean dirty; + private boolean stopped; + private long queuedBytes; + private long appliedState = -1; + private long appliedSettings = -1; + private long sentState = -1; + private long sentSettings = -1; + + + /** Bind one mailbox to its authenticated socket. */ + + + private Channel(WsContext socket, String device, Credential credential) + { + this.socket = socket; + this.device = device; + this.credential = credential; + } + + + /** Re-enter the configured native authentication module for every operation after HTTP upgrade. */ + private void authorizeCurrent() throws Exception + { + QContext.init(instance, null); + var module = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication()); + var session = module.createSession(instance, Map.of(credential.cookieName, credential.cookie)); + UserProfiles.applyIfInstalled(instance, session); + QContext.init(instance, session, null, null); + if(session.getCredentialScope() != null) + { + throw new QAuthenticationException("Realtime printing requires a current user session"); + } + String channel = credential.clientChannel == null || credential.clientChannel.isBlank() ? null : credential.clientChannel.strip(); + if(channel != null && (!ClientChannelsMetaData.of(instance).isAllowedChannel(ClientChannelsMetaData.channelName(channel)) || channel.length() > 200)) + { + throw new QPermissionDeniedException("The current client channel is not allowed"); + } + session.setClientChannel(channel == null ? null : channel.strip()); + PrintingAgentAuthorization.device(device, credential.proof, false); + } + + + /** Read synchronized connection application state. */ + + + private synchronized boolean ready(long revision) + { + return (!stopped && socket.session.isOpen() && service.getRealtimeBus().owns(service.getTenantId(), device, generation) + && hello != null && appliedState == sentState && appliedSettings == sentSettings && appliedSettings == revision); + } + + + /** Bound inbound work before scheduling any database operation. */ + + + private synchronized void accept(String frame) + { + int bytes = frame.getBytes(StandardCharsets.UTF_8).length; + if(bytes > policy.get("maxFrameBytes") || mailbox.size() >= policy.get("maxQueuedFrames") || queuedBytes + bytes > policy.get("maxQueuedFrameBytes")) + { + stop("Realtime mailbox limit exceeded"); + return; + } + mailbox.add(frame); + queuedBytes += bytes; + schedule(); + } + + + /** Coalesce commit wakeups without blocking the committing caller. */ + + + private synchronized void changed() + { + dirty = true; + schedule(); + } + + + /** Start at most one serial worker for this connection. */ + + + private void schedule() + { + if(!running && !stopped) + { + running = true; + workers.execute(this::drain); + } + } + + + /** Process bounded inbound work and coalesced durable-state wakeups. */ + + + private void drain() + { + while(true) + { + String frame; + synchronized(this) + { + frame = mailbox.poll(); + if(frame != null) + { + queuedBytes -= frame.getBytes(StandardCharsets.UTF_8).length; + } + else if(!dirty || hello == null || stopped) + { + running = false; + return; + } + else + { + dirty = false; + } + } + try + { + if(channels.get(device) != this || stopped || !service.getRealtimeBus().owns(service.getTenantId(), device, generation)) + { + return; + } + authorizeCurrent(); + if(frame == null) + { + snapshot(false); + } + else + { + receive(new JSONObject(frame)); + } + } + catch(Exception exception) + { + if(reject(frame, exception)) + { + return; + } + } + finally + { + QContext.clear(); + } + } + } + + + /** Apply one authenticated protocol operation. */ + + + private void receive(JSONObject frame) throws Exception + { + if(!frame.keySet().equals(Set.of("type", "messageId", "payload")) || !(frame.opt("messageId") instanceof String messageId) + || !messageId.matches("[A-Za-z0-9._-]{1,200}") || !(frame.opt("payload") instanceof JSONObject payload)) + { + throw new PrintingException(400, "Invalid realtime envelope"); + } + String type = frame.getString("type"); + if(hello == null && !"HELLO".equals(type)) + { + throw new PrintingException(409, "HELLO is required first"); + } + switch(type) + { + case "HELLO" -> + { + if(hello != null || !payload.keySet().equals(Set.of("agentVersion", "capabilities", "lastAppliedRevision", "lastSettingsRevision", "limits")) + || !payload.getJSONArray("capabilities").toList().contains("realtime-v1") + || payload.getJSONArray("capabilities").length() > 3 + || !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL").containsAll(payload.getJSONArray("capabilities").toList())) + { + throw new PrintingException(409, "Realtime capability is required"); + } + hello = payload; + snapshot(true); + return; + } + case "STATE_APPLIED" -> + { + long state = integer(payload, "lastAppliedRevision"); + long settings = integer(payload, "lastSettingsRevision"); + if(state > sentState || settings > sentSettings || state < appliedState || settings < appliedSettings) + { + throw new PrintingException(409, "Only the current delivered state and settings may be acknowledged"); + } + service.applyRealtimeState(device, controlRequest(state), settings); + synchronized(this) + { + appliedState = state; + appliedSettings = settings; + } + changed(); + } + case "JOB_EVENT" -> + { + String job = payload.getString("jobId"); + PrintingControlAuthorization.job(device, job); + service.event(device, job, payload.getJSONObject("event")); + } + case "COMMAND_RECEIPT" -> + { + String command = payload.getString("commandId"); + PrintingControlAuthorization.table(PrintingTables.COMMAND); + service.getDeviceCommand(device, command); + PrintingUiApiSpec.command(device, command); + service.deviceControlReceipt(device, command, payload.getJSONObject("receipt")); + changed(); + } + case "HEARTBEAT" -> service.heartbeat(device, payload); + case "INVENTORY" -> service.inventory(device, payload.getJSONArray("printers"), PrintingAgentAuthorization.inventory(device)); + case "PING" -> + { + send("PONG", new JSONObject().put("serverTime", Instant.now().toString())); + return; + } + default -> throw new PrintingException(400, "Unsupported realtime message"); + } + send("ACK", new JSONObject().put("acknowledgedMessageId", messageId)); + } + + + /** Domain errors retain their identity and never masquerade as a durable acknowledgment. */ + private boolean reject(String request, Exception exception) + { + System.getLogger(PrintingRealtimeRoutes.class.getName()).log(System.Logger.Level.WARNING, + "Realtime operation rejected: " + exception.getClass().getSimpleName() + " at " + (exception.getStackTrace().length == 0 ? "unknown" : exception.getStackTrace()[0])); + boolean authorization = exception instanceof QAuthenticationException || exception instanceof QPermissionDeniedException; + int status = exception instanceof PrintingException printing ? printing.getStatus() : authorization ? 403 : exception instanceof org.json.JSONException ? 400 : 500; + boolean connectionError = request == null || hello == null || sentSettings < 0 || status == 401 || status == 403 || status >= 500; + String code = switch(status) + { + case 401, 403 -> "AUTHORIZATION_REQUIRED"; + case 404 -> "NOT_FOUND"; + case 410 -> "EXPIRED"; + case 409 -> connectionError ? "CONFIGURATION_REQUIRED" : "CONFLICT"; + case 400, 413 -> "INVALID_MESSAGE"; + default -> "OPERATION_FAILED"; + }; + String id = UUID.randomUUID().toString(); + try + { + if(request != null) + { + String requested = new JSONObject(request).optString("messageId"); + if(requested.matches("[A-Za-z0-9._-]{1,200}")) + { + id = requested; + } + } + String error = new JSONObject().put("type", "ERROR").put("messageId", id).put("payload", new JSONObject().put("code", code) + .put("message", "The realtime operation was rejected; inspect the current state before retrying").put("retryable", status >= 500)).toString(); + CompletableFuture sent = new CompletableFuture<>(); + socket.session.sendText(error, Callback.from(() -> sent.complete(null), sent::completeExceptionally)); + sent.get(policy.get("ackTimeoutMillis"), TimeUnit.MILLISECONDS); + } + catch(Exception failure) + { + connectionError = true; + } + if(connectionError) + { + stop("Realtime operation rejected"); + } + return (connectionError); + } + + + /** Reuse the durable control protocol with advertised supported operations. */ + + + private JSONObject controlRequest(long state) + { + JSONArray capabilities = new JSONArray(); + for(Object value : hello.getJSONArray("capabilities")) + { + if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL").contains(value)) + { + capabilities.put(value); + } + } + return (new JSONObject().put("controlVersion", 1).put("agentVersion", hello.getString("agentVersion")) + .put("capabilities", capabilities).put("lastAppliedRevision", state)); + } + + + /** Reconstruct current state and pending commands after registration or commit. */ + + + private void snapshot(boolean initial) throws Exception + { + JSONObject settings = service.getRealtimeSettings(); + if(initial && integer(hello, "lastSettingsRevision") > settings.getLong("revision")) + { + throw new PrintingException(409, "Unknown realtime settings revision"); + } + JSONObject values = new JSONObject(settings.toString()); + values.remove("revision"); + policy = PrintingRealtimePolicy.fromJson(values); + policy.requireClientLimits(hello.getJSONObject("limits")); + socket.session.setMaxTextMessageSize(policy.get("maxFrameBytes")); + socket.session.setIdleTimeout(Duration.ofSeconds(policy.get("heartbeatTimeoutSeconds"))); + JSONObject state = service.deviceControlPoll(device, controlRequest(initial ? integer(hello, "lastAppliedRevision") : Math.max(0, appliedState))); + PrintingControlAuthorization.table(PrintingTables.COMMAND); + JSONObject desired = state.getJSONObject("desiredState"); + long previousState; + long previousSettings; + synchronized(this) + { + previousState = sentState; + previousSettings = sentSettings; + sentState = desired.getLong("revision"); + sentSettings = settings.getLong("revision"); + } + if(initial) + { + send("WELCOME", new JSONObject().put("connectionId", UUID.randomUUID().toString()).put("serverTime", Instant.now().toString()) + .put("desiredState", desired).put("settings", settings)); + } + else + { + if(previousSettings != sentSettings) + { + send("SETTINGS", settings); + } + if(previousState != sentState) + { + send("DESIRED_STATE", desired); + } + } + if(!ready(sentSettings)) + { + return; + } + for(Object item : state.getJSONArray("commands")) + { + JSONObject command = (JSONObject) item; + PrintingUiApiSpec.command(device, command.getString("commandId")); + send("COMMAND", command); + } + send("JOB_AVAILABLE", new JSONObject()); + } + + + /** Revalidate authority and await one bounded asynchronous socket write. */ + + + private void send(String type, JSONObject payload) throws Exception + { + authorizeCurrent(); + synchronized(this) + { + if(stopped || channels.get(device) != this || !service.getRealtimeBus().owns(service.getTenantId(), device, generation)) + { + throw new PrintingException(409, "Connection was replaced"); + } + } + String frame = new JSONObject().put("type", type).put("messageId", UUID.randomUUID().toString()).put("payload", payload).toString(); + if(frame.getBytes(StandardCharsets.UTF_8).length > policy.get("maxFrameBytes")) + { + throw new PrintingException(413, "Realtime frame limit exceeded"); + } + CompletableFuture sent = new CompletableFuture<>(); + socket.session.sendText(frame, Callback.from(() -> sent.complete(null), sent::completeExceptionally)); + sent.get(policy.get("ackTimeoutMillis"), TimeUnit.MILLISECONDS); + } + + + /** Fence and remove this connection without affecting a replacement. */ + + + private synchronized void stop(String reason) + { + if(!stopped) + { + stopped = true; + mailbox.clear(); + queuedBytes = 0; + channels.remove(device, this); + service.getRealtimeBus().release(service.getTenantId(), device, generation); + socket.closeSession(1008, reason); + } + } + } + + + /** Reject absent, fractional and negative revision values. */ + + + private static long integer(JSONObject value, String name) + { + Object raw = value.get(name); + if(!(raw instanceof Number number) || !number.toString().matches("[0-9]+")) + { + throw new PrintingException(400, "Expected nonnegative integer " + name); + } + return (number.longValue()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionProcesses.java index 4f0cc08..785af57 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRetentionProcesses.java @@ -63,6 +63,7 @@ public class PrintingRetentionProcesses implements BackendStep .withStep(review("Current retention and storage")) .withStep(new QFrontendStepMetaData().withName("settings").withLabel("Edit retention") .withFormFields(List.of(new QFieldMetaData("days", QFieldType.INTEGER).withLabel("Keep successful job documents for days (1–36500)").withIsRequired(true), + new QFieldMetaData("batchSize", QFieldType.INTEGER).withLabel("Maximum documents per cleanup run (1–200)").withIsRequired(true), new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Enable document retention cleanup").withIsRequired(true))) .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))) .withStep(backend("execute")).withStep(result()); @@ -165,11 +166,12 @@ public class PrintingRetentionProcesses implements BackendStep { Integer days = input.getValueInteger("days"); Boolean enabled = input.getValueBoolean("enabled"); - if(days == null || enabled == null) + Integer batchSize = input.getValueInteger("batchSize"); + if(days == null || enabled == null || batchSize == null) { throw new QException("Specify retention days and whether cleanup is enabled"); } - service.saveRetentionSettings(days, enabled); + service.saveRetentionSettings(days, enabled, batchSize); output.addValue("result", "Printing retention settings saved for this tenant. Changing the policy does not run cleanup immediately."); } else if("execute".equals(input.getStepName()) && (RUN_NOW.equals(input.getProcessName()) || scheduled)) @@ -198,6 +200,7 @@ public class PrintingRetentionProcesses implements BackendStep JSONObject settings = service.getRetentionSettings(); output.addValue("days", settings.getInt("days")); output.addValue("enabled", settings.getBoolean("enabled")); + output.addValue("batchSize", settings.getInt("batchSize")); output.addValue("policy", (settings.getBoolean("enabled") ? "Enabled" : "Disabled") + ": retain successful job documents for " + settings.getInt("days") + " days; at most " + settings.getInt("batchSize") + " documents per run."); String table = settings.optString("storageTableName", ""); String label = QContext.getQInstance().getTable(table) == null ? table : QContext.getQInstance().getTable(table).getLabel(); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java index b17ae44..485390b 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java @@ -224,9 +224,12 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface /*************************************************************************** ** Parse bounded protocol JSON; the server also configures request limits. ***************************************************************************/ - private static JSONObject body(Context context) + private JSONObject body(Context context) throws java.sql.SQLException { - if(context.bodyAsBytes().length > 29 * 1024 * 1024) + PrintingRealtimePolicy policy = service.realtimePolicy(); + long maximum = context.path().equals(BASE + "/admin/jobs") + ? ((Math.max(policy.get("maxPdfBytes"), policy.get("maxZplBytes")) + 2) / 3) * 4 + 65536 : 2097152; + if((context.req().getContentLengthLong() > maximum) || context.bodyAsBytes().length > maximum) { throw new PrintingException(413, "Request is too large"); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index 1b89b26..e82338c 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -47,7 +47,6 @@ import org.json.JSONObject; public class PrintingService { static final String CANCELED_BEFORE_DISPATCH = "Canceled by operator before dispatch"; - private static final int MAX_CONTENT_BYTES = 20 * 1024 * 1024; private static final String JOB_COLUMNS = "id, tenant_id, device_id, printer_id, title, format, content_hash, content_length, options_json, idempotency_key, created_at, status, message, spooler_job_id, printer_row_id"; private final DataSource dataSource; private final String tenantId; @@ -55,6 +54,121 @@ public class PrintingService private PrintingAdmissionLimits admissionLimits = PrintingAdmissionLimits.defaults(); private String storageTableName; private PrintingRetentionPolicy defaultRetentionPolicy = new PrintingRetentionPolicy(true, 365, 200); + private PrintingRealtimeBus realtimeBus = new PrintingRealtimeBus(); + private RealtimeClaimGuard realtimeClaimGuard = device -> + { + throw new PrintingException(503, "The host has not registered the realtime printing route provider"); + }; + + + /** Native claims require a synchronized current-user channel, not a polling fallback. */ + @FunctionalInterface + public interface RealtimeClaimGuard + { + /** Check this connection's authoritative applied state. */ + void requireReady(String device) throws SQLException; + } + + + /** Route-provider lifecycle owns the readiness guard. */ + void withRealtimeClaimGuard(RealtimeClaimGuard guard) + { + realtimeClaimGuard = guard; + } + + + /** Enforce readiness on native HTTP claims before selecting any job. */ + public void requireRealtimeReady(String device) throws SQLException + { + realtimeClaimGuard.requireReady(device); + } + + + /** Persist application observations before the WebSocket acknowledgment is emitted. */ + void applyRealtimeState(String device, JSONObject control, long settings) throws SQLException + { + if(settings > getRealtimeSettings().getLong("revision")) + { + throw new PrintingException(409, "Realtime settings changed before acknowledgment"); + } + deviceControlPoll(device, control); + try(Connection connection = dataSource.getConnection()) + { + if(update(connection, "UPDATE printing_device SET realtime_applied_revision=CASE WHEN realtime_applied_revision=?)", settings, settings, tenantId, device, tenantId, settings) != 1) + { + throw new PrintingException(409, "Realtime settings changed before acknowledgment"); + } + } + } + + + /** A host may share this event bus across services using the same database. */ + public PrintingService withRealtimeBus(PrintingRealtimeBus bus) + { + realtimeBus = java.util.Objects.requireNonNull(bus); + return (this); + } + + + /** Instance-owned notifications never contain credentials or document bytes. */ + public PrintingRealtimeBus getRealtimeBus() + { + return (realtimeBus); + } + + + /** Complete persisted tenant settings; defaults apply until the first administrator edit. */ + public JSONObject getRealtimeSettings() throws SQLException + { + ensureRetentionSettings(); + try(Connection connection = dataSource.getConnection()) + { + JSONObject row = query(connection, "SELECT realtime_policy_json, realtime_revision FROM printing_settings WHERE tenant_id=?", tenantId).getFirst(); + PrintingRealtimePolicy policy = row.isNull("realtime_policy_json") ? PrintingRealtimePolicy.defaults() + : PrintingRealtimePolicy.fromJson(new JSONObject(row.getString("realtime_policy_json"))); + return (policy.toJson(row.getLong("realtime_revision"))); + } + } + + + /** The native adapter authorizes the administrator before this atomic update. */ + public JSONObject saveRealtimeSettings(JSONObject request) throws SQLException + { + PrintingRealtimePolicy policy = PrintingRealtimePolicy.fromJson(request); + ensureRetentionSettings(); + JSONObject result; + try(Connection connection = dataSource.getConnection()) + { + connection.setAutoCommit(false); + try + { + long revision = query(connection, "SELECT realtime_revision FROM printing_settings WHERE tenant_id=? FOR UPDATE", tenantId).getFirst().getLong("realtime_revision") + 1; + if(revision < 1 || revision > 9007199254740991L) + { + throw new PrintingException(409, "Realtime settings revision capacity is exhausted"); + } + update(connection, "UPDATE printing_settings SET realtime_policy_json=?, realtime_revision=? WHERE tenant_id=?", request.toString(), revision, tenantId); + connection.commit(); + result = policy.toJson(revision); + } + catch(Exception exception) + { + connection.rollback(); + throw exception; + } + } + realtimeBus.publish(tenantId, null); + return (result); + } + + + /** Resolve immutable validated policy values for one request. */ + public PrintingRealtimePolicy realtimePolicy() throws SQLException + { + JSONObject settings = getRealtimeSettings(); + settings.remove("revision"); + return (PrintingRealtimePolicy.fromJson(settings)); + } @@ -68,6 +182,24 @@ public class PrintingService } + /** Native tenant administrator admission settings. */ + public JSONObject getAdmissionSettings() throws SQLException + { + ensureRetentionSettings(); + return (new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).get()); + } + + + /** Save validated tenant overrides without changing retained job receipts. */ + public JSONObject saveAdmissionSettings(JSONObject request) throws SQLException + { + ensureRetentionSettings(); + JSONObject result = new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).save(request); + realtimeBus.publish(tenantId, null); + return (result); + } + + /*************************************************************************** ** Bind a host-owned storage table before exposing this service to requests. @@ -147,6 +279,10 @@ public class PrintingService "CREATE TABLE IF NOT EXISTS printing_settings (tenant_id VARCHAR(200) PRIMARY KEY, retention_days INTEGER NOT NULL, retention_enabled INTEGER NOT NULL, last_run_at VARCHAR(40), last_run_count INTEGER, last_run_error VARCHAR(500))", "ALTER TABLE printing_settings ADD COLUMN IF NOT EXISTS lease_token VARCHAR(36)", "ALTER TABLE printing_settings ADD COLUMN IF NOT EXISTS lease_expires_at VARCHAR(40)", + "ALTER TABLE printing_settings ADD COLUMN IF NOT EXISTS realtime_policy_json VARCHAR(8000)", + "ALTER TABLE printing_settings ADD COLUMN IF NOT EXISTS realtime_revision BIGINT DEFAULT 0 NOT NULL", + "ALTER TABLE printing_settings ADD COLUMN IF NOT EXISTS admission_policy_json VARCHAR(8000)", + "ALTER TABLE printing_settings ADD COLUMN IF NOT EXISTS retention_batch_size INTEGER", "CREATE TABLE IF NOT EXISTS printing_document_orphan (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, storage_table VARCHAR(200) NOT NULL, reference VARCHAR(500) NOT NULL, binding VARCHAR(64) NOT NULL, created_at VARCHAR(40) NOT NULL)", "CREATE INDEX IF NOT EXISTS printing_orphan_tenant_idx ON printing_document_orphan(tenant_id, created_at, id)", "UPDATE printing_job SET printer_row_id=(SELECT p.id FROM printing_printer p WHERE p.device_id=printing_job.device_id AND p.printer_id=printing_job.printer_id AND p.tenant_id=printing_job.tenant_id) WHERE printer_row_id IS NULL", @@ -154,6 +290,7 @@ public class PrintingService "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS display_name VARCHAR(512)", "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS description VARCHAR(4096)", "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS location VARCHAR(512)", + "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS realtime_applied_revision BIGINT DEFAULT -1 NOT NULL", "ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS queue_name VARCHAR(1024)", "ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS display_name VARCHAR(512)", "ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS description VARCHAR(4096)", @@ -227,14 +364,18 @@ public class PrintingService ***************************************************************************/ public JSONObject deviceAction(String deviceId, JSONObject request, String actor) throws SQLException { - return (new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor)); + JSONObject result = new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor); + realtimeBus.publish(tenantId, deviceId); + return (result); } /** Native authorization supplies the exact reviewed queue identity set, never a predicate executed inside SQL locks. */ JSONObject deviceAction(String deviceId, JSONObject request, String actor, Set authorizedQueue) throws SQLException { - return (new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor, authorizedQueue)); + JSONObject result = new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor, authorizedQueue); + realtimeBus.publish(tenantId, deviceId); + return (result); } @@ -604,7 +745,7 @@ public class PrintingService { incoming.add(required(printers.getJSONObject(index).optString("printerId"), "printerId")); } - PrintingAdmission.inventory(connection, tenantId, deviceId, incoming, admissionLimits); + PrintingAdmission.inventory(connection, tenantId, deviceId, incoming, new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).effective(connection)); update(connection, "UPDATE printing_printer SET status='UNKNOWN', available=0 WHERE device_id=? AND available=1", deviceId); for(int index = 0; index < printers.length(); index++) { @@ -656,6 +797,7 @@ public class PrintingService throw new PrintingException(404, "Printer not found"); } } + realtimeBus.publish(tenantId, deviceId); } @@ -666,6 +808,11 @@ public class PrintingService public JSONObject submit(JSONObject request) throws SQLException { String format = required(request.optString("format"), "format"); + long maximum = realtimePolicy().documentLimit(format); + if(request.getString("contentBase64").length() > ((maximum + 2) / 3) * 4) + { + throw new PrintingException(413, "Encoded document exceeds its configured format limit"); + } byte[] content; try { @@ -675,13 +822,13 @@ public class PrintingService { throw new PrintingException(400, "Invalid document encoding"); } - if(content.length > MAX_CONTENT_BYTES) + if(content.length > maximum) { - throw new PrintingException(413, "Document exceeds 20971520 bytes"); + throw new PrintingException(413, "Document exceeds configured " + format + " limit of " + maximum + " bytes"); } if(content.length == 0) { - throw new PrintingException(400, "Document must contain 1 to 20971520 bytes"); + throw new PrintingException(400, "Document must contain at least one byte"); } if("PDF".equals(format) && !new String(content, 0, Math.min(5, content.length), StandardCharsets.US_ASCII).equals("%PDF-")) { @@ -721,6 +868,7 @@ public class PrintingService throw new PrintingException(400, "Only PDF and ZPL are supported"); } JSONObject options = printOptions(request); + long contentLimit = realtimePolicy().documentLimit(format); String id = UUID.randomUUID().toString(); String reference = storageTableName == null ? null : documentStorage.prefix() + id + "." + format.toLowerCase(java.util.Locale.ROOT); String binding = storageTableName == null ? null : documentStorage.binding(storageTableName); @@ -774,7 +922,11 @@ public class PrintingService { throw new PrintingException(400, "Printer is not enabled for this format"); } - PrintingAdmission.reserve(connection, tenantId, deviceId, id, contentLength, admissionLimits); + if(contentLength > contentLimit) + { + throw new PrintingException(413, "Document exceeds the current configured format limit"); + } + PrintingAdmission.reserve(connection, tenantId, deviceId, id, contentLength, new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).effective(connection)); if(storageTableName != null) { reserveUpload(connection, id, storageTableName, reference, binding, deviceId, contentLength, key); @@ -806,6 +958,7 @@ public class PrintingService update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId); JSONObject result = job(query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE id=?", id).getFirst()); connection.commit(); + realtimeBus.publish(tenantId, deviceId); return (result); } catch(Exception exception) @@ -832,7 +985,7 @@ public class PrintingService /*************************************************************************** ** Preserve request intent: reject malformed types instead of coercing them. ***************************************************************************/ - private static JSONObject printOptions(JSONObject request) + private JSONObject printOptions(JSONObject request) throws SQLException { if(request.has("options") && !(request.opt("options") instanceof JSONObject)) { @@ -845,7 +998,7 @@ public class PrintingService { if(!(copies instanceof Number number)) { - throw new PrintingException(400, "Copies must be an integer between 1 and 100"); + throw new PrintingException(400, "Copies must be an integer within the configured limit"); } try { @@ -853,11 +1006,11 @@ public class PrintingService } catch(ArithmeticException | NumberFormatException exception) { - throw new PrintingException(400, "Copies must be an integer between 1 and 100"); + throw new PrintingException(400, "Copies must be an integer within the configured limit"); } } Object scale = options.has("scale") ? options.opt("scale") : "ACTUAL_SIZE"; - if(count < 1 || count > 100 || !(scale instanceof String) || !Set.of("ACTUAL_SIZE", "FIT").contains(scale)) + if(count < 1 || count > realtimePolicy().get("maxCopies") || !(scale instanceof String) || !Set.of("ACTUAL_SIZE", "FIT").contains(scale)) { throw new PrintingException(400, "Invalid print options"); } @@ -982,6 +1135,7 @@ public class PrintingService } update(connection, "UPDATE printing_job SET status='FAILED', message=? WHERE id=?", CANCELED_BEFORE_DISPATCH, jobId); connection.commit(); + realtimeBus.publish(tenantId, deviceId); return (new JSONObject().put("accepted", true)); } catch(Exception exception) @@ -1067,6 +1221,41 @@ public class PrintingService } + /** Stream external documents without holding a database connection or buffering the entire body. */ + public java.io.InputStream openContent(String deviceId, String jobId) throws SQLException + { + JSONObject row; + try(Connection connection = dataSource.getConnection()) + { + requireDevice(connection, deviceId); + row = deviceJob(connection, deviceId, jobId, true); + if("QUEUED".equals(row.getString("status"))) + { + throw new PrintingException(409, "Claim the job before downloading"); + } + if(row.getInt("content_available") == 0) + { + throw new PrintingException(410, "Print document expired under the host retention policy"); + } + } + if(row.getLong("content_length") > realtimePolicy().documentLimit(row.getString("format"))) + { + throw new PrintingException(413, "Document exceeds the current configured format limit"); + } + java.io.InputStream input; + if(row.isNull("content_reference")) + { + input = new java.io.ByteArrayInputStream(Base64.getDecoder().decode(row.getString("content_base64"))); + } + else + { + verifyStoredReference(row); + input = documentStorage.open(row.getString("content_storage_table"), row.getString("content_reference")); + } + return (new PrintingDocumentStream(input, row.getLong("content_length"), row.getString("content_hash"))); + } + + /*************************************************************************** ** Record a replay-safe lifecycle event without reprinting ambiguous jobs. @@ -1120,6 +1309,7 @@ public class PrintingService } update(connection, "UPDATE printing_job SET status=?, message=?, spooler_job_id=? WHERE id=?", status, message, spooler == null ? row.optString("spooler_job_id") : spooler, jobId); connection.commit(); + realtimeBus.publish(tenantId, deviceId); } catch(Exception exception) { @@ -1404,7 +1594,7 @@ public class PrintingService String cutoff = Instant.now().minusSeconds(row.getInt("retention_days") * 86400L).toString(); long backlog = query(connection, "SELECT COUNT(*) AS total FROM printing_job WHERE tenant_id=? AND content_available=1 AND status IN ('SPOOLED','SPOOLER_COMPLETED') AND completed_at result = PrintingAgentAuthorization.claim(device); + case "Claim" -> + { + service.requireRealtimeReady(device); + result = PrintingAgentAuthorization.claim(device); + } case "Queued" -> { PrintingControlAuthorization.table(PrintingTables.JOB); @@ -204,7 +208,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec case "Content" -> { PrintingControlAuthorization.job(device, job); - return (service.content(device, job)); + return (service.openContent(device, job)); } case "Event" -> { @@ -275,7 +279,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec /** Receipt and history access apply child-record security independently of device access. */ - private static void command(String device, String id) throws Exception + static void command(String device, String id) throws Exception { QRecord command = PrintingControlAuthorization.record(PrintingTables.COMMAND, id); if(!device.equals(command.getValueString("device_id"))) diff --git a/src/main/resources/openapi/qqq-v1/printing.yaml b/src/main/resources/openapi/qqq-v1/printing.yaml index 88032c2..b7445d4 100644 --- a/src/main/resources/openapi/qqq-v1/printing.yaml +++ b/src/main/resources/openapi/qqq-v1/printing.yaml @@ -604,9 +604,10 @@ components: info: contact: email: "contact@kingsrook.com" - description: "Native current-user printing with secondary device proof and device-control-v1." + description: "Native current-user printing with secondary device proof, device-control-v1\ + \ and realtime-v1." title: "QQQ Middleware API extension: printing" - version: "1.0.0" + version: "1.1.0" openapi: "3.0.3" paths: /qqq/v1/qbit/printing/session: diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingAdmissionTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingAdmissionTest.java index 17424c1..8c4e666 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingAdmissionTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingAdmissionTest.java @@ -160,13 +160,13 @@ class PrintingAdmissionTest /*************************************************************************** - ** Legacy upload intents with unknown size reserve the maximum allowed bytes. + ** Legacy unknown sizes block new admission even when current capacity exceeds their historical estimate. ***************************************************************************/ @Test void legacyUnknownUploadChargesTenantCapacityUntilExactCleanup() throws Exception { long unknownBytes = 20L * 1024 * 1024; - Fixture fixture = fixture(limits(10, 20, 100_000_000, 200_000_000, 100_000_000, unknownBytes, 100, 200, 10, 20)); + Fixture fixture = fixture(limits(10, 20, 100_000_000, 200_000_000, 100_000_000, 200_000_000, 100, 200, 10, 20)); String orphan = UUID.randomUUID().toString(); String reference = fixture.documents.prefix() + orphan + ".zpl"; fixture.documents.objects.put(reference, DOCUMENT.clone()); @@ -572,7 +572,7 @@ class PrintingAdmissionTest ** Read immutable synthetic bytes for the retry path. ***********************************************************************/ @Override - public byte[] get(String table, String reference) throws SQLException + public byte[] get(String table, String reference, int maximum) throws SQLException { gets.incrementAndGet(); byte[] content = objects.get(reference); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingDocumentStorageTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingDocumentStorageTest.java index adfbfa1..eacbf68 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingDocumentStorageTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingDocumentStorageTest.java @@ -255,6 +255,40 @@ class PrintingDocumentStorageTest } + /** Native StorageAction content larger than the old cap is consumed incrementally and verified. */ + @Test + void streamsLargePdfAndRejectsChangedLengthWithoutBufferingInTheAdapter() throws Exception + { + Fixture fixture = fixture(); + fixture.service.inventory(fixture.device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic") + .put("formats", new JSONArray(List.of("PDF"))))); + byte[] document = new byte[21 * 1024 * 1024]; + System.arraycopy("%PDF-1.7\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII), 0, document, 0, 9); + JSONObject submitted = fixture.service.submit(new JSONObject().put("deviceId", fixture.device).put("printerId", "queue").put("title", "Large synthetic PDF") + .put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString(document)).put("idempotencyKey", "large-pdf")); + fixture.service.claim(fixture.device); + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + long count = 0; + try(var stream = fixture.service.openContent(fixture.device, submitted.getString("jobId"))) + { + byte[] buffer = new byte[65536]; + int read; + while((read = stream.read(buffer)) != -1) + { + count += read; + digest.update(buffer, 0, read); + } + } + assertEquals(document.length, count); + assertEquals(submitted.getString("contentSha256"), java.util.HexFormat.of().formatHex(digest.digest())); + fixture.storage.put("printing_documents", fixture.storage.lastReference, "PDF", new byte[] {1, 2, 3}); + try(var stream = fixture.service.openContent(fixture.device, submitted.getString("jobId"))) + { + assertThrows(java.io.IOException.class, stream::readAllBytes); + } + } + + /*************************************************************************** ** Synthetic bytes never leave the test process. diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeProcessesTest.java new file mode 100644 index 0000000..1e5e33a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeProcessesTest.java @@ -0,0 +1,201 @@ +/* + * 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.math.BigDecimal; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Native settings forms preserve exact values and enforce current authority. + *******************************************************************************/ +class PrintingRealtimeProcessesTest +{ + /*************************************************************************** + ** Opening settings cannot save them until the operator passes native review. + ***************************************************************************/ + @Test + void nativeReviewIsReadOnly() throws Exception + { + PrintingService service = fixture(); + var preview = new RunProcessAction().execute(new RunProcessInput().withProcessName(PrintingRealtimeProcesses.SETTINGS)); + assertFalse(preview.getException().isPresent(), () -> preview.getException().toString()); + assertEquals("review", preview.getProcessState().getNextStepName().orElseThrow()); + assertEquals(0, service.getRealtimeSettings().getLong("revision")); + } + + + + /*************************************************************************** + ** Large PDFs and fractional human units round-trip without a fixed 20 MiB cap. + ***************************************************************************/ + @Test + void exactPolicyPersistsAndRejectsPartialOrInconsistentEdits() throws Exception + { + PrintingService service = fixture(); + RunBackendStepInput save = prepared(PrintingRealtimeProcesses.SETTINGS); + save.addValue("maxPdfMiB", new BigDecimal("250.5")); + save.addValue("maxConcurrentDownloads", 3); + new PrintingRealtimeProcesses().run(save, new RunBackendStepOutput()); + assertEquals(262668288L, service.getRealtimeSettings().getLong("maxPdfBytes")); + assertEquals(3, service.getRealtimeSettings().getInt("maxConcurrentDownloads")); + assertEquals(1, service.getRealtimeSettings().getLong("revision")); + RunBackendStepInput fractionalByte = prepared(PrintingRealtimeProcesses.SETTINGS).addValue("maxPdfMiB", new BigDecimal("0.1")); + assertThrows(QException.class, () -> new PrintingRealtimeProcesses().run(fractionalByte, new RunBackendStepOutput())); + RunBackendStepInput tooLittleDisk = prepared(PrintingRealtimeProcesses.SETTINGS).addValue("maxStagedMiB", 1); + assertThrows(QException.class, () -> new PrintingRealtimeProcesses().run(tooLittleDisk, new RunBackendStepOutput())); + assertThrows(QException.class, () -> new PrintingRealtimeProcesses().run(input(PrintingRealtimeProcesses.SETTINGS, "execute"), new RunBackendStepOutput())); + assertEquals(1, service.getRealtimeSettings().getLong("revision")); + } + + + + /*************************************************************************** + ** Backend invocation cannot bypass either tenant scope or administrator role. + ***************************************************************************/ + @Test + void currentUserAndTenantAreRequired() throws Exception + { + fixture(); + QInstance instance = QContext.getQInstance(); + RunBackendStepInput save = prepared(PrintingRealtimeProcesses.SETTINGS); + QContext.init(instance, session("foreign", true)); + assertThrows(QPermissionDeniedException.class, () -> new PrintingRealtimeProcesses().run(save, new RunBackendStepOutput())); + QContext.init(instance, session("tenant-a", false)); + assertThrows(QPermissionDeniedException.class, () -> new PrintingRealtimeProcesses().run(save, new RunBackendStepOutput())); + } + + + + /*************************************************************************** + ** Queue limits use the same typed native forms and survive new service objects. + ***************************************************************************/ + @Test + void admissionSettingsPersistAndRejectInvalidCapacity() throws Exception + { + PrintingService service = fixture(); + RunBackendStepInput save = prepared(PrintingRealtimeProcesses.ADMISSION).addValue("deviceJobsPerHour", 15000).addValue("deviceOutstandingMiB", new BigDecimal("10240.5")); + new PrintingRealtimeProcesses().run(save, new RunBackendStepOutput()); + assertEquals(15000, service.getAdmissionSettings().getInt("deviceJobsPerHour")); + assertEquals(10737942528L, service.getAdmissionSettings().getLong("deviceOutstandingBytes")); + RunBackendStepInput zero = prepared(PrintingRealtimeProcesses.ADMISSION).addValue("devicePrinters", 0); + assertThrows(QException.class, () -> new PrintingRealtimeProcesses().run(zero, new RunBackendStepOutput())); + assertTrue(service.getAdmissionSettings().getInt("devicePrinters") > 0); + } + + + + /*************************************************************************** + ** Prepared form values exercise the same complete fields used by the UI. + ***************************************************************************/ + private static RunBackendStepInput prepared(String process) throws Exception + { + RunBackendStepOutput output = new RunBackendStepOutput(); + new PrintingRealtimeProcesses().run(input(process, "prepare"), output); + RunBackendStepInput save = input(process, "execute"); + output.getValues().forEach(save::addValue); + return (save); + } + + + + /*************************************************************************** + ** Each test owns a database and normal authenticated tenant session. + ***************************************************************************/ + private static PrintingService fixture() throws Exception + { + DriverDataSource source = new DriverDataSource("jdbc:h2:mem:realtime-ui-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + PrintingService service = new PrintingService(source, "tenant-a"); + service.initialize(); + QInstance instance = new QInstance(); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule.class)); + instance.addTable(new QTableMetaData().withName("fixture").withBackendName("memory").withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.STRING))); + instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion("test").withNamespace("printing").withConfig(new PrintingQBitConfig().withService(service))); + QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing"); + PrintingRealtimeProcesses.register(instance, app); + instance.addApp(app); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.MOCK)); + QContext.init(instance, session("tenant-a", true)); + return (service); + } + + + + /*************************************************************************** + ** Negative authorization cases retain a real user to test role and tenant. + ***************************************************************************/ + private static QSession session(String tenant, boolean administrator) + { + QSession session = new QSession().withUser(new QUser().withIdReference("operator")).withSecurityKeyValue("printing_tenant", tenant); + if(administrator) + { + session.withPermissions("printing_admin"); + } + return (session); + } + + + + /*************************************************************************** + ** Backend entrypoints use explicit process and step names. + ***************************************************************************/ + private static RunBackendStepInput input(String process, String step) + { + RunBackendStepInput input = new RunBackendStepInput().withProcessName(process); + input.setStepName(step); + return (input); + } + + + + /*************************************************************************** + ** Never leak current tenant authority into another test. + ***************************************************************************/ + @AfterEach + void cleanup() + { + QContext.clear(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java new file mode 100644 index 0000000..4afd67e --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java @@ -0,0 +1,291 @@ +/* + * 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.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig; +import io.javalin.Javalin; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** Real native authentication and WebSocket lifecycle with durable synthetic jobs only. */ +public class PrintingRealtimeTest +{ + @TempDir + Path directory; + + + /** Commit wakeups, exact receipts, replacement and revocation all use actual QQQ sessions. */ + @Test + void authenticatedPushReconcilesDurableWorkWithoutHttpControlPolling() throws Exception + { + String jdbc = "jdbc:h2:file:" + directory.resolve("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 = launcher.getQInstance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance) + .map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService(); + String base = "http://127.0.0.1:" + app.get().port(); + JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Synthetic realtime").put("deviceName", "Synthetic realtime").put("agentVersion", "test"), "operator"); + String device = enrolled.getString("deviceId"); + String proof = enrolled.getString("deviceToken"); + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue").put("status", "READY").put("formats", new JSONArray().put("ZPL")))); + service.enablePrinter(device, "queue", true); + try(HttpClient http = HttpClient.newHttpClient()) + { + String cookie = login(http, base); + assertThrows(Exception.class, () -> PrintingSocketFixture.connect(http, base, "sessionUUID=invalid", device, proof)); + assertThrows(Exception.class, () -> PrintingSocketFixture.connect(http, base, cookie, device, "wrong-proof")); + try(PrintingSocketFixture first = PrintingSocketFixture.connect(http, base, cookie, device, proof)) + { + first.take("JOB_AVAILABLE"); + first.send("INVENTORY", "idle-inventory", new JSONObject().put("printers", new JSONArray().put(new JSONObject().put("printerId", "queue") + .put("name", "Synthetic queue").put("status", "READY").put("formats", new JSONArray().put("ZPL"))))); + assertEquals("idle-inventory", first.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId")); + org.junit.jupiter.api.Assertions.assertNull(first.received.poll(300, TimeUnit.MILLISECONDS), "Idle inventory must not create a self-sustaining job wakeup"); + sharedBusRoundTrip(launcher, service, jdbc, http, cookie, device, first); + first.received.clear(); + String job = service.submit(request(device, "one")).getString("jobId"); + first.take("JOB_AVAILABLE"); + assertEquals(job, service.claim(device).getJSONObject("job").getString("jobId")); + JSONObject event = new JSONObject().put("eventId", "stable-event").put("status", "RECEIVED"); + first.send("JOB_EVENT", "event-stable-event", new JSONObject().put("jobId", job).put("event", event)); + assertEquals("event-stable-event", first.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId")); + assertEquals("RECEIVED", service.getJob(job).getString("status")); + JSONObject policy = service.getRealtimeSettings(); + policy.remove("revision"); + service.saveRealtimeSettings(policy.put("maxCopies", 99)); + assertEquals(1, first.take("SETTINGS").getJSONObject("payload").getLong("revision")); + first.apply(0, 0); + assertEquals(409, assertThrows(PrintingException.class, () -> service.requireRealtimeReady(device)).getStatus()); + first.apply(0, 1); + service.requireRealtimeReady(device); + JSONObject pause = service.deviceAction(device, action("PAUSE_DISPATCH"), "operator"); + JSONObject desired = first.take("DESIRED_STATE").getJSONObject("payload"); + while(!desired.getBoolean("dispatchPaused")) + { + desired = first.take("DESIRED_STATE").getJSONObject("payload"); + } + first.apply(desired.getLong("revision"), 1); + assertTrue(service.claim(device).isNull("job")); + assertFalse(pause.isEmpty()); + JSONObject command = service.deviceAction(device, action("REFRESH_INVENTORY"), "operator"); + JSONObject pushed = first.take("COMMAND").getJSONObject("payload"); + assertEquals(command.getString("commandId"), pushed.getString("commandId")); + JSONObject receipt = new JSONObject().put("receiptId", "stable-receipt").put("outcome", "INVENTORY_REFRESHED").put("completedAt", Instant.now().toString()); + JSONObject delivery = new JSONObject().put("commandId", pushed.getString("commandId")).put("receipt", receipt); + first.send("COMMAND_RECEIPT", "receipt-stable-receipt", delivery); + first.take("ACK"); + first.send("COMMAND_RECEIPT", "receipt-pruned", new JSONObject().put("commandId", UUID.randomUUID().toString()).put("receipt", receipt)); + assertEquals("NOT_FOUND", first.take("ERROR").getJSONObject("payload").getString("code")); + first.send("PING", "after-pruned-receipt", new JSONObject()); + first.take("PONG"); + try(PrintingSocketFixture second = PrintingSocketFixture.connect(http, base, cookie, device, proof)) + { + assertEquals(1008, first.closed.get(10, TimeUnit.SECONDS)); + second.send("COMMAND_RECEIPT", "receipt-stable-receipt", delivery); + assertEquals("receipt-stable-receipt", second.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId")); + second.send("JOB_EVENT", "event-stable-event", new JSONObject().put("jobId", job).put("event", event)); + second.take("ACK"); + assertEquals("RECEIVED", service.getJob(job).getString("status")); + service.deviceAction(device, action("REVOKE"), "operator"); + assertEquals(1008, second.closed.get(10, TimeUnit.SECONDS)); + } + } + authenticationChangesAreImmediate(launcher, service, http, base, cookie); + } + finally + { + launcher.stop(); + } + } + + + /** Cookie variants, changed provider roles, channel policy and logout are checked after upgrade. */ + private static void authenticationChangesAreImmediate(QApplicationLauncher launcher, PrintingService service, HttpClient http, String base, String cookie) throws Exception + { + JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Authentication fixture").put("deviceName", "Authentication fixture").put("agentVersion", "test"), "operator"); + String device = enrolled.getString("deviceId"); + String proof = enrolled.getString("deviceToken"); + var table = launcher.getQInstance().getTable(PrintingTables.DEVICE); + var originalRules = table.getPermissionRules(); + try(PrintingSocketFixture channel = PrintingSocketFixture.connect(http, base, cookie.replace("sessionUUID=", "sessionId="), device, proof)) + { + table.withPermissionRules(PrintingProcesses.permissionRules().withDeniedClients(java.util.Set.of("api"))); + channel.send("PING", "channel-denied", new JSONObject()); + assertEquals("AUTHORIZATION_REQUIRED", channel.take("ERROR").getJSONObject("payload").getString("code")); + assertEquals(1008, channel.closed.get(10, TimeUnit.SECONDS)); + } + finally + { + table.withPermissionRules(originalRules); + } + var auth = launcher.getQInstance().getAuthentication(); + var originalModule = auth.getModuleCodeReference(); + try(PrintingSocketFixture channel = PrintingSocketFixture.connect(http, base, cookie, device, proof)) + { + auth.withModuleCodeReference(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(RemovedRoleAuthentication.class)); + service.getRealtimeBus().publish("demo", device); + assertEquals("AUTHORIZATION_REQUIRED", channel.take("ERROR").getJSONObject("payload").getString("code")); + assertEquals(1008, channel.closed.get(10, TimeUnit.SECONDS)); + } + finally + { + auth.withModuleCodeReference(originalModule); + } + try(PrintingSocketFixture channel = PrintingSocketFixture.connect(http, base, cookie, device, proof)) + { + HttpResponse logout = http.send(HttpRequest.newBuilder(URI.create(base + "/qqq/v1/logout")).header("Origin", base).header("Cookie", cookie) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, logout.statusCode()); + channel.send("PING", "logged-out", new JSONObject()); + assertEquals("AUTHORIZATION_REQUIRED", channel.take("ERROR").getJSONObject("payload").getString("code")); + assertEquals(1008, channel.closed.get(10, TimeUnit.SECONDS)); + } + } + + + /** Native authentication still verifies the actual password/session; only current role membership changes. */ + public static class RemovedRoleAuthentication extends PrintingLocalAuthentication + { + @Override + public com.kingsrook.qqq.backend.core.model.session.QSession createSession(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance, java.util.Map context) + throws com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException + { + var session = super.createSession(instance, context); + session.setPermissions(java.util.Set.of()); + return (session); + } + } + + + /** Two independent route providers receive only their scoped commit wakeups through an explicitly shared bus. */ + private static void sharedBusRoundTrip(QApplicationLauncher launcher, PrintingService service, String jdbc, HttpClient http, String cookie, String firstDevice, PrintingSocketFixture first) throws Exception + { + PrintingService sibling = new PrintingService(new DriverDataSource(jdbc, "sa", ""), "demo").withRealtimeBus(service.getRealtimeBus()); + PrintingRealtimeRoutes routes = new PrintingRealtimeRoutes(sibling); + routes.setQInstance(launcher.getQInstance()); + Javalin other = Javalin.create(config -> + { + config.jetty.host = "127.0.0.1"; + routes.acceptJavalinConfig(config); + }).start(0); + JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Second synthetic device").put("deviceName", "Second synthetic device").put("agentVersion", "test"), "operator"); + String secondDevice = enrolled.getString("deviceId"); + service.inventory(secondDevice, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue").put("formats", new JSONArray().put("ZPL")))); + service.enablePrinter(secondDevice, "queue", true); + try(PrintingSocketFixture second = PrintingSocketFixture.open(http, "http://127.0.0.1:" + other.port(), cookie, secondDevice, enrolled.getString("deviceToken"))) + { + String early = service.submit(request(secondDevice, "commit-between-upgrade-and-hello")).getString("jobId"); + second.initialize(); + second.take("JOB_AVAILABLE"); + assertEquals("QUEUED", sibling.getJob(early).getString("status")); + sibling.cancel(secondDevice, early); + first.received.clear(); + second.received.clear(); + String firstJob = sibling.submit(request(firstDevice, "other-service-to-first-hub")).getString("jobId"); + first.take("JOB_AVAILABLE"); + String secondJob = service.submit(request(secondDevice, "first-service-to-other-hub")).getString("jobId"); + second.take("JOB_AVAILABLE"); + assertEquals("QUEUED", sibling.getJob(firstJob).getString("status")); + assertEquals("QUEUED", service.getJob(secondJob).getString("status")); + service.cancel(firstDevice, firstJob); + sibling.cancel(secondDevice, secondJob); + } + finally + { + other.stop(); + } + } + + + /** A real browser login supplies only its own temporary test session. */ + private static String login(HttpClient client, String base) throws Exception + { + HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/qqq/v1/manageSession")).header("Origin", base) + .header("Authorization", "Basic " + Base64.getEncoder().encodeToString("operator:synthetic-realtime-password-1234".getBytes(StandardCharsets.UTF_8))) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + return (response.headers().allValues("set-cookie").stream().filter(value -> value.startsWith("sessionUUID=")).findFirst().orElseThrow().split(";", 2)[0]); + } + + + /** Stable synthetic submission identity; no physical printer exists in this fixture. */ + private static JSONObject request(String device, String key) + { + return (new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Synthetic realtime") + .put("format", "ZPL").put("contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(StandardCharsets.US_ASCII))).put("idempotencyKey", key)); + } + + + /** Native actions retain their existing temporal replay boundary. */ + private static JSONObject action(String name) + { + return (new JSONObject().put("action", name).put("requestId", UUID.randomUUID().toString()).put("issuedAt", Instant.now().toString())); + } + + + /** Policy updates preserve validation, tenant isolation and durable revisions. */ + @Test + void policyRejectsInconsistentLimitsAndPersistsRevisions() throws Exception + { + DriverDataSource source = new DriverDataSource("jdbc:h2:mem:policy-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + PrintingService first = new PrintingService(source, "one"); + first.initialize(); + PrintingService other = new PrintingService(source, "two"); + JSONObject values = first.getRealtimeSettings(); + assertEquals(104857600, values.getInt("maxPdfBytes")); + values.remove("revision"); + JSONObject invalid = new JSONObject(values.toString()).put("heartbeatTimeoutSeconds", 10); + assertThrows(PrintingException.class, () -> first.saveRealtimeSettings(invalid)); + assertThrows(PrintingException.class, () -> first.saveRealtimeSettings(new JSONObject(values.toString()).put("maxCopies", 1.5))); + values.put("maxPdfBytes", 30 * 1024 * 1024); + assertEquals(1, first.saveRealtimeSettings(values).getLong("revision")); + assertEquals(1, new PrintingService(source, "one").getRealtimeSettings().getLong("revision")); + assertNotEquals(first.getRealtimeSettings().getLong("maxPdfBytes"), other.getRealtimeSettings().getLong("maxPdfBytes")); + assertThrows(PrintingException.class, () -> PrintingRealtimePolicy.defaults().requireClientLimits(new JSONObject().put("maxPdfBytes", 1))); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRetentionProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRetentionProcessesTest.java index 8ff5e9f..2ca990b 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingRetentionProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRetentionProcessesTest.java @@ -105,7 +105,7 @@ class PrintingRetentionProcessesTest { PrintingService service = fixture(); PrintingRetentionProcesses step = new PrintingRetentionProcesses(); - RunBackendStepInput save = input(PrintingRetentionProcesses.SETTINGS, "execute").addValue("days", 730).addValue("enabled", false); + RunBackendStepInput save = input(PrintingRetentionProcesses.SETTINGS, "execute").addValue("days", 730).addValue("enabled", false).addValue("batchSize", 100); QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("foreign"))); assertThrows(QPermissionDeniedException.class, () -> step.run(save, new RunBackendStepOutput())); QContext.init(QContext.getQInstance(), new QSession().withSecurityKeyValue("printing_tenant", "tenant-a")); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java new file mode 100644 index 0000000..f40c7f3 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java @@ -0,0 +1,157 @@ +/* + * 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 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 received = new LinkedBlockingQueue<>(); + final CompletableFuture closed = new CompletableFuture<>(); + private final StringBuilder text = new StringBuilder(); + private 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 + { + PrintingSocketFixture fixture = new PrintingSocketFixture(); + 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) + .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 + { + send("HELLO", "hello", new JSONObject().put("agentVersion", "synthetic-test").put("capabilities", new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL")) + .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) + { + socket.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 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 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(); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingStorageBindingTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingStorageBindingTest.java index 5aed4ae..6c70741 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingStorageBindingTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingStorageBindingTest.java @@ -405,7 +405,7 @@ class PrintingStorageBindingTest ** Unexpected reads are observable independently of returned bytes. ************************************************************************/ @Override - public byte[] get(String table, String reference) + public byte[] get(String table, String reference, int maximum) { calls.add("GET " + reference); return (objects.get(reference).clone()); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java index a27ef28..2f2613f 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java @@ -61,7 +61,7 @@ class PrintingUiApiContractTest var model = version.generateExtensionOpenAPIModel("qqq", extension); assertEquals(17, model.getPaths().size()); assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/"))); - assertEquals("1.0.0", extension.getSemanticVersion()); + assertEquals("1.1.0", extension.getSemanticVersion()); assertTrue(generated.contains("X-QQQ-Device-Token")); assertTrue(generated.contains("application/octet-stream")); assertTrue(generated.contains("principalId")); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java index fd7c3ff..b09589b 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java @@ -136,6 +136,8 @@ class PrintingUiApiTest JSONObject others = service.enrollForUser(service.createEnrollment("default", "Other owner").put("deviceName", "Other owner").put("agentVersion", "test"), "another-principal"); assertEquals(403, send(client, base, prefix + "/device/heartbeat", "POST", new JSONObject().put("agentVersion", "test"), cookie, others.getString("deviceId"), others.getString("deviceToken")).statusCode()); assertEquals(device, json(send(client, base, prefix + "/session", "GET", null, cookie, device, proof), 200).getString("deviceId")); + assertEquals(409, send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), cookie, device, proof).statusCode()); + PrintingSocketFixture channel = PrintingSocketFixture.connect(client, base, cookie, device, proof); assertEquals(job, json(send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), cookie, device, proof), 200).getJSONObject("job").getString("jobId")); HttpResponse downloaded = send(client, base, prefix + "/device/jobs/" + job + "/content", "GET", null, cookie, device, proof); assertEquals(200, downloaded.statusCode()); @@ -146,6 +148,7 @@ class PrintingUiApiTest JSONObject action = action("REVOKE"); json(send(client, base, prefix + "/admin/devices/" + device + "/actions", "POST", action, cookie, null, null), 200); assertEquals(403, send(client, base, prefix + "/device/heartbeat", "POST", new JSONObject().put("agentVersion", "test"), cookie, device, proof).statusCode()); + channel.close(); JSONObject nativeEnrollment = service.createEnrollment("default", "Native enrollment").put("deviceName", "Native enrollment").put("agentVersion", "test"); JSONObject fresh = json(send(client, base, prefix + "/devices/enroll", "POST", nativeEnrollment, cookie, null, null), 200); service.authorizeAgent(fresh.getString("deviceId"), fresh.getString("deviceToken"), "operator", false); -- 2.54.0 From e793b099221494ff4f8bef18b2e1a02f34f22796 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 16:25:47 -0500 Subject: [PATCH 26/35] fix(printing): version realtime claims and align connection freshness Advertise native printing API 2.0.0 because mandatory synchronized WebSocket claims are incompatible with the published HTTP-only native API. Keep the framework path and wire envelope unchanged. Derive dashboard freshness from the configured heartbeat timeout and record authorized control-channel liveness. Validation: final Maven verify passed118 tests with zero Checkstyle violations. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 2 +- docs/REALTIME.md | 13 +++++- docs/SESSION-STATE.md | 4 ++ .../qbits/printing/PrintingHome.java | 46 +++++++++++++++---- .../printing/PrintingRealtimeRoutes.java | 1 + .../qbits/printing/PrintingService.java | 11 +++++ .../qbits/printing/PrintingUiApi.java | 4 +- .../resources/openapi/qqq-v1/printing.yaml | 2 +- .../qbits/printing/PrintingHomeTest.java | 44 +++++++++++++++++- .../qbits/printing/PrintingRealtimeTest.java | 39 ++++++++++++++++ .../printing/PrintingUiApiContractTest.java | 4 +- 11 files changed, 151 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb09571..6db2c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.3.0-rc.1 — unreleased -- Add client-initiated realtime WebSocket delivery under native printing API 1.1, +- Add client-initiated realtime WebSocket delivery under native printing API 2.0, with current QQQ session and record permissions revalidated during connection use. - Deliver desired state, device commands, inventory, lifecycle events and receipts over the socket; native claims require acknowledged current settings and state. diff --git a/docs/REALTIME.md b/docs/REALTIME.md index e94dd41..2288a20 100644 --- a/docs/REALTIME.md +++ b/docs/REALTIME.md @@ -1,9 +1,9 @@ # Real-time printing host integration -This source implements printing extension **1.1.0** (wire envelope v1). It is not +This source implements printing extension **2.0.0** (wire envelope v1). It is not part of the published 0.2.0-rc.1 artifacts. Upgrade server and Windows connector together; a real-time connector rejects a server that advertises only extension -1.0. There is no timed job/control polling fallback. +1.x. There is no timed job/control polling fallback. ## Connection and authorization @@ -98,3 +98,12 @@ The existing `/printing/v1` routes remain explicit legacy diagnostic compatibili They are not a real-time fallback and cannot authorize a native bound device. The full message and settings contract is in [the shared protocol](https://github.com/QRun-IO/qqq-print-protocol). + +## Printing API major upgrade + +The extension is **2.0.0** because native HTTP claims now require a synchronized +WebSocket. A published 1.0 HTTP-only connector no longer meets that precondition; +this is a breaking behavior change even though request bodies remain wire v1. +The QQQ framework API `/qqq/v1`, extension path `/qbit/printing` and WebSocket +subprotocol `qqq-printing.v1` remain unchanged. Upgrade server and client together; +an older client must report upgrade-required instead of using polling fallback. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 7ac9ea0..1ebfba2 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -92,3 +92,7 @@ Protocol extension 1.1.0: client-initiated authenticated WSS, qqq-printing.v1 su ## Real-time source verification — 2026-10-31 Server source0.3.0-rc.1 passes117 Java tests and zero Checkstyle violations, including real native-session WebSocket lifecycle, sessionId/sessionUUID, logout/channel rejection, replacement fencing, partial revision acknowledgments, no idle-inventory feedback, and21MiB native storage streaming/integrity. Protocol70definitions/72fixtures passes; both consumer workflows pin signed protocol67be986cb76d6b5d9a9f72cad054193b0d11b0d5. Native admin policy tests cover permission/tenant checks, exact250.5MiB conversion, revision persistence and invalid-capacity rejection. A private actual.NET/Java fixture passed13 functional checks with25MiB content, durable receipt replay, channel replacement and sign-out denial; its initial fixture JVM needed explicit maintenance-thread shutdown handling, so this is not installed lifecycle qualification. Root is preparing the final refreshed fixture run. Current installed0.2 remains unchanged; release/install acceptance is pending. + +## Final real-time API gate — 2026-10-31 + +Native printing extension is now2.0.0, because requiring a synchronized WebSocket for previously HTTP-only native claims is a breaking behavior change. Framework/qqq/v1, wirev1 and qqq-printing.v1 remain unchanged; product0.3.0-rc.1. Full final-source verify passed118tests, zero failures/errors/skips and zero Checkstyle violations. Configured heartbeat timeout now determines dashboard freshness (90s legacy floor) and authorized PING records control-only contact. Published1.0 OpenAPI baseline remains preserved. Protocol repin and final Windows verification are pending; no live deployment. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java b/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java index b127dac..d3f8a1c 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingHome.java @@ -128,8 +128,8 @@ public class PrintingHome extends AbstractWidgetRenderer QWidgetData data = switch(input.getWidgetMetaData().getName()) { case "printingHomeHeader" -> header(); - case "printingHomeAttention" -> attention(now); - case "printingHomeOffline" -> statistic(DEVICE, offline(now), "Enabled devices · now · disconnected by an administrator or unseen for 90 seconds"); + case "printingHomeAttention" -> attention(now, freshnessSeconds()); + case "printingHomeOffline" -> offlineStatistic(now); case "printingHomePrinters" -> statistic(PRINTER, printerIssues(), "Enabled printers · now · target 0 needing attention"); case "printingHomeFailures" -> statistic(JOB, failures(now), "Jobs created in last 24 hours · target 0 failed or uncertain"); case "printingHomePending" -> statistic(JOB, statuses(PENDING), "Jobs · now · target queue drains after printing"); @@ -169,17 +169,17 @@ public class PrintingHome extends AbstractWidgetRenderer /*************************************************************************** ** Severity order: failed work, disconnected devices, then printer issues. ***************************************************************************/ - private static TableData attention(Instant now) throws QException + private static TableData attention(Instant now, long freshnessSeconds) throws QException { List> rows = new ArrayList<>(); for(QRecord job : query(JOB, failures(now).withOrderBy(new QFilterOrderBy("created_at", false)).withLimit(10))) { rows.add(issue(JOB, job, job.getValueString("title"), job.getValueString("status"), "Check the local queue and physical output before retrying an uncertain job.", "created_at")); } - for(QRecord device : query(DEVICE, offline(now).withOrderBy(new QFilterOrderBy("last_seen_at", true)).withLimit(10))) + for(QRecord device : query(DEVICE, offline(now, freshnessSeconds).withOrderBy(new QFilterOrderBy("last_seen_at", true)).withLimit(10))) { boolean disconnected = "DISCONNECTED".equals(device.getValueString("connection_state")); - rows.add(issue(DEVICE, device, device.getValueString("name"), disconnected ? "Administratively disconnected" : "No connection for 90 seconds", + rows.add(issue(DEVICE, device, device.getValueString("name"), disconnected ? "Administratively disconnected" : "No connection for " + freshnessSeconds + " seconds", disconnected ? "Open the device and use Reconnect; review its observed control revision." : "Open the Windows agent and check its service, sign-in and outbound connection.", "last_seen_at")); } for(QRecord device : query(DEVICE, new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1) @@ -263,12 +263,40 @@ public class PrintingHome extends AbstractWidgetRenderer + /*************************************************************************** + ** Current tenant policy controls freshness, retaining the legacy grace period. + ***************************************************************************/ + private static long freshnessSeconds() throws QException + { + try + { + return (Math.max(90, PrintingProcesses.currentService().realtimePolicy().get("heartbeatTimeoutSeconds"))); + } + catch(java.sql.SQLException exception) + { + throw new QException("Could not read printing connection freshness settings", exception); + } + } + + + + /*************************************************************************** + ** Explain the exact threshold used by the linked native filter. + ***************************************************************************/ + private static StatisticsData offlineStatistic(Instant now) throws QException + { + long seconds = freshnessSeconds(); + return (statistic(DEVICE, offline(now, seconds), "Enabled devices · now · disconnected by an administrator or unseen for " + seconds + " seconds")); + } + + + /*************************************************************************** ** Either authenticated connection proves liveness; requested disconnect stays visible. ***************************************************************************/ - private static QQueryFilter offline(Instant now) + private static QQueryFilter offline(Instant now, long freshnessSeconds) { - QQueryFilter unseen = new QQueryFilter().withSubFilter(stale("last_seen_at", now)).withSubFilter(stale("control_last_poll_at", now)); + QQueryFilter unseen = new QQueryFilter().withSubFilter(stale("last_seen_at", now, freshnessSeconds)).withSubFilter(stale("control_last_poll_at", now, freshnessSeconds)); QQueryFilter disconnected = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) .withCriteria("connection_state", QCriteriaOperator.EQUALS, "DISCONNECTED").withSubFilter(unseen); return (new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(disconnected)); @@ -279,10 +307,10 @@ public class PrintingHome extends AbstractWidgetRenderer /*************************************************************************** ** Legacy devices may never have sent a control poll. ***************************************************************************/ - private static QQueryFilter stale(String field, Instant now) + private static QQueryFilter stale(String field, Instant now, long freshnessSeconds) { return (new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) - .withCriteria(field, QCriteriaOperator.IS_BLANK).withCriteria(field, QCriteriaOperator.LESS_THAN, now.minusSeconds(90).toString())); + .withCriteria(field, QCriteriaOperator.IS_BLANK).withCriteria(field, QCriteriaOperator.LESS_THAN, now.minusSeconds(freshnessSeconds).toString())); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java index c70393a..44834b5 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java @@ -481,6 +481,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf case "INVENTORY" -> service.inventory(device, payload.getJSONArray("printers"), PrintingAgentAuthorization.inventory(device)); case "PING" -> { + service.realtimeContact(device); send("PONG", new JSONObject().put("serverTime", Instant.now().toString())); return; } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index e82338c..9543efd 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -102,6 +102,17 @@ public class PrintingService } + /** Authenticated transport contact does not imply applied controls or normal device traffic. */ + void realtimeContact(String device) throws SQLException + { + try(Connection connection = dataSource.getConnection()) + { + requireDevice(connection, device); + update(connection, "UPDATE printing_device SET control_last_poll_at=? WHERE tenant_id=? AND id=? AND enabled=1", Instant.now().toString(), tenantId, device); + } + } + + /** A host may share this event bus across services using the same database. */ public PrintingService withRealtimeBus(PrintingRealtimeBus bus) { diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java index 933d65d..8fbbdce 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java @@ -49,10 +49,10 @@ public final class PrintingUiApi MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension()); } - /** Semantic extension version 1.1.0 adds authenticated realtime delivery. */ + /** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims. */ public static MiddlewareSpecExtension extension() { - return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("1.1.0") + return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.0.0") .withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1.") .withSpec(Session.class) .withSpec(Bind.class) diff --git a/src/main/resources/openapi/qqq-v1/printing.yaml b/src/main/resources/openapi/qqq-v1/printing.yaml index b7445d4..aea1287 100644 --- a/src/main/resources/openapi/qqq-v1/printing.yaml +++ b/src/main/resources/openapi/qqq-v1/printing.yaml @@ -607,7 +607,7 @@ info: description: "Native current-user printing with secondary device proof, device-control-v1\ \ and realtime-v1." title: "QQQ Middleware API extension: printing" - version: "1.1.0" + version: "2.0.0" openapi: "3.0.3" paths: /qqq/v1/qbit/printing/session: diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingHomeTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingHomeTest.java index e3eae75..331a664 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingHomeTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingHomeTest.java @@ -140,6 +140,46 @@ class PrintingHomeTest + /*************************************************************************** + ** Slow configured heartbeats remain healthy and every explanation matches. + ***************************************************************************/ + @Test + void configuredHeartbeatTimeoutControlsFreshness() throws Exception + { + DriverDataSource source = source(); + QInstance instance = instance(source); + seed(source, "tenant-a"); + QContext.init(instance, viewer("tenant-a", true)); + try + { + PrintingService service = PrintingProcesses.currentService(); + JSONObject policy = service.getRealtimeSettings(); + policy.remove("revision"); + service.saveRealtimeSettings(policy.put("heartbeatIntervalSeconds", 300).put("heartbeatTimeoutSeconds", 900)); + try(Connection connection = source.getConnection(); PreparedStatement contact = connection.prepareStatement("UPDATE printing_device SET control_last_poll_at=? WHERE tenant_id=?")) + { + contact.setString(1, java.time.Instant.now().minusSeconds(400).toString()); + contact.setString(2, "tenant-a"); + contact.executeUpdate(); + } + StatisticsData healthy = (StatisticsData) render(instance, "printingHomeOffline"); + assertEquals(0, healthy.getCount()); + assertTrue(healthy.getCountContext().contains("900 seconds")); + assertFalse(JsonUtils.toJson(render(instance, "printingHomeAttention")).contains("No connection for")); + service.saveRealtimeSettings(policy.put("heartbeatIntervalSeconds", 20).put("heartbeatTimeoutSeconds", 60)); + StatisticsData stale = (StatisticsData) render(instance, "printingHomeOffline"); + assertEquals(1, stale.getCount()); + assertTrue(stale.getCountContext().contains("90 seconds")); + assertTrue(JsonUtils.toJson(render(instance, "printingHomeAttention")).contains("No connection for 90 seconds")); + } + finally + { + QContext.clear(); + } + } + + + /*************************************************************************** ** Empty state guides setup; an unprivileged or unscoped caller gets no data. ***************************************************************************/ @@ -147,7 +187,7 @@ class PrintingHomeTest void guidedEmptyStateAndAuthorization() throws Exception { QInstance instance = instance(source()); - QContext.init(instance, viewer("tenant-empty", true)); + QContext.init(instance, viewer("tenant-a", true)); try { String header = JsonUtils.toJson(render(instance, "printingHomeHeader")); @@ -158,7 +198,7 @@ class PrintingHomeTest TableData attention = (TableData) render(instance, "printingHomeAttention"); assertTrue(attention.getRows().isEmpty()); assertTrue(attention.getNoRowsFoundHTML().contains("No issues")); - QContext.init(instance, viewer("tenant-empty", false)); + QContext.init(instance, viewer("tenant-a", false)); assertThrows(QPermissionDeniedException.class, () -> render(instance, "printingHomeFailures")); QContext.init(instance, new QSession().withUser(new QUser().withIdReference("unscoped")).withPermission("printing_admin")); assertThrows(QPermissionDeniedException.class, () -> render(instance, "printingHomeFailures")); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java index 4afd67e..7821981 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java @@ -53,6 +53,44 @@ public class PrintingRealtimeTest Path directory; + /** A transport ping while disconnected proves contact, never state application or normal heartbeat. */ + private static void disconnectedPingUpdatesContactOnly(PrintingService service, String device, PrintingSocketFixture socket) throws Exception + { + service.deviceAction(device, action("DISCONNECT"), "operator"); + JSONObject desired = socket.take("DESIRED_STATE").getJSONObject("payload"); + while(!"DISCONNECTED".equals(desired.getString("connectionState"))) + { + desired = socket.take("DESIRED_STATE").getJSONObject("payload"); + } + JSONObject before = deviceSnapshot(service, device); + long applied = before.getJSONObject("control").getLong("lastAppliedRevision"); + assertTrue(applied < desired.getLong("revision")); + Instant sent = Instant.now(); + socket.send("PING", "disconnected-contact", new JSONObject()); + socket.take("PONG"); + JSONObject after = deviceSnapshot(service, device); + assertFalse(Instant.parse(after.getJSONObject("control").getString("lastPollAt")).isBefore(sent)); + assertEquals(applied, after.getJSONObject("control").getLong("lastAppliedRevision")); + assertEquals(before.getString("lastSeenAt"), after.getString("lastSeenAt")); + assertEquals("DISCONNECTED", after.getJSONObject("control").getJSONObject("desiredState").getString("connectionState")); + } + + + /** Select the exact synthetic device when the shared-bus test has created others. */ + private static JSONObject deviceSnapshot(PrintingService service, String device) throws Exception + { + for(Object value : service.devices()) + { + JSONObject row = (JSONObject) value; + if(device.equals(row.getString("deviceId"))) + { + return (row); + } + } + throw new AssertionError("Synthetic device was not found"); + } + + /** Commit wakeups, exact receipts, replacement and revocation all use actual QQQ sessions. */ @Test void authenticatedPushReconcilesDurableWorkWithoutHttpControlPolling() throws Exception @@ -130,6 +168,7 @@ public class PrintingRealtimeTest second.send("JOB_EVENT", "event-stable-event", new JSONObject().put("jobId", job).put("event", event)); second.take("ACK"); assertEquals("RECEIVED", service.getJob(job).getString("status")); + disconnectedPingUpdatesContactOnly(service, device, second); service.deviceAction(device, action("REVOKE"), "operator"); assertEquals(1008, second.closed.get(10, TimeUnit.SECONDS)); } diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java index 2f2613f..9b3b6f2 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java @@ -56,12 +56,12 @@ class PrintingUiApiContractTest Files.createDirectories(baseline.getParent()); Files.writeString(baseline, generated); } - assertEquals(List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(baseline), 1, + assertEquals(List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(baseline), 2, "mvnw.cmd -Dtest=PrintingUiApiContractTest -Dprinting.openapi.update=true test")); var model = version.generateExtensionOpenAPIModel("qqq", extension); assertEquals(17, model.getPaths().size()); assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/"))); - assertEquals("1.1.0", extension.getSemanticVersion()); + assertEquals("2.0.0", extension.getSemanticVersion()); assertTrue(generated.contains("X-QQQ-Device-Token")); assertTrue(generated.contains("application/octet-stream")); assertTrue(generated.contains("principalId")); -- 2.54.0 From 55f426673545522a3b8de599da585e732cdf8740 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 16:29:28 -0500 Subject: [PATCH 27/35] chore(ci): pin the verified printing API 2 contract Use signed protocol df3729ac632d4a34a3fb8423acef465042a624fc for reproducible consumer checks and record the successful final API 2 cross-runtime qualification. The deployed 0.2 package remains unchanged. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- .github/workflows/ci.yml | 2 +- README.md | 2 +- docs/SESSION-STATE.md | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fa8ff0..4ca4a8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: QRun-IO/qqq-print-protocol - ref: 67be986cb76d6b5d9a9f72cad054193b0d11b0d5 + ref: df3729ac632d4a34a3fb8423acef465042a624fc path: qqq-print-protocol persist-credentials: false - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/README.md b/README.md index ef69717..8f6490c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ See the [real-time host integration](docs/REALTIME.md), [screen walkthrough](doc ## Build and verify -Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `67be986cb76d6b5d9a9f72cad054193b0d11b0d5`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). +Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `df3729ac632d4a34a3fb8423acef465042a624fc`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). ```powershell .\mvnw.cmd verify diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 1ebfba2..6537dbd 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -96,3 +96,7 @@ Server source0.3.0-rc.1 passes117 Java tests and zero Checkstyle violations, inc ## Final real-time API gate — 2026-10-31 Native printing extension is now2.0.0, because requiring a synchronized WebSocket for previously HTTP-only native claims is a breaking behavior change. Framework/qqq/v1, wirev1 and qqq-printing.v1 remain unchanged; product0.3.0-rc.1. Full final-source verify passed118tests, zero failures/errors/skips and zero Checkstyle violations. Configured heartbeat timeout now determines dashboard freshness (90s legacy floor) and authorized PING records control-only contact. Published1.0 OpenAPI baseline remains preserved. Protocol repin and final Windows verification are pending; no live deployment. + +## Coordinated protocol and cross-runtime verification — 2026-10-31 + +Current immutable consumer pin is df3729ac632d4a34a3fb8423acef465042a624fc (71definitions/73fixtures); it supersedes earlier67be986 pre-major-correction checkpoints. Final API2.0 Java/Release.NET interoperability passed13 checks with25MiB content and exact durable replay, temporary host stopped and wrapper exit0. Installed0.2 remains unchanged. Windows source gate398tests passed before one final startup recovery regression was added; its definitive gate is still pending. No release/install acceptance claim. -- 2.54.0 From 82fc5e5f8a15467696d9eaa8ac240f8f7fcc8b01 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 31 Oct 2026 18:04:47 -0500 Subject: [PATCH 28/35] docs(release): finalize coordinated realtime candidate metadata Prepare0.3.0-rc.1 notes for native printingAPI2.0, outbound realtime delivery and coordinated server-first upgrade. Pin the merged protocol contract and preserve the distinction between source/release and installed acceptance. Validation: changedMarkdown, diff and actionlint checks pass. Application, schema and test behavior are unchanged. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 10 +++++++--- README.md | 12 ++++++------ docs/REALTIME.md | 6 +++--- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ca4a8e..7acd140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: QRun-IO/qqq-print-protocol - ref: df3729ac632d4a34a3fb8423acef465042a624fc + ref: f3cbc02e9b0fb4fdfefaa59d976c04df747cc589 path: qqq-print-protocol persist-credentials: false - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db2c7b..41ee5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -## 0.3.0-rc.1 — unreleased +## 0.3.0-rc.1 — 2026-10-31 + +Upgrade the server before the matching Windows connector. Native printing API +2.0 requires synchronized authenticated WSS; old native clients cannot claim +without it, and there is no polling fallback. Wire payloads remain v1. - Add client-initiated realtime WebSocket delivery under native printing API 2.0, with current QQQ session and record permissions revalidated during connection use. @@ -8,8 +12,8 @@ over the socket; native claims require acknowledged current settings and state. - Stream retained documents independently from controls and expose document, concurrency, timing, admission and retention batch limits in native admin forms. -- Preserve durable job/command identities across reconnects. Deployment and actual - Windows realtime acceptance remain pending until the candidate gates complete. +- Preserve durable job/command identities across reconnects. Installed Windows + realtime and physical-output acceptance remain separate from source/test gates. ## 0.2.0-rc.1 — 2026-10-31 diff --git a/README.md b/README.md index 8f6490c..bf5ffba 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # QQQ Printing -Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. The real-time transport described here is unreleased source; published 0.2.0-rc.1 uses the earlier polling transport. +Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Release candidate **0.3.0-rc.1** requires native printing API **2.0**, with no job/control polling fallback. Upgrade the server before the matching Windows connector. -**Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. The current published release is [**0.2.0-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.2.0-rc.1). The tagged build passed 110 tests and zero Checkstyle violations. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with verified build attestations for all three assets. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR. +**Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. Current release candidate: [**0.3.0-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.3.0-rc.1). The previous [**0.2.0-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.2.0-rc.1) tagged build passed 110 tests and zero Checkstyle violations. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with build attestations for all three assets. Verify the exact downloaded release; publication does not establish installed or physical-output acceptance. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR. 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. @@ -21,7 +21,7 @@ See the [real-time host integration](docs/REALTIME.md), [screen walkthrough](doc ## Build and verify -Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `df3729ac632d4a34a3fb8423acef465042a624fc`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). +Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `f3cbc02e9b0fb4fdfefaa59d976c04df747cc589`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). ```powershell .\mvnw.cmd verify @@ -64,14 +64,14 @@ Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator The protocol-only `PrintingDemo` and synthetic, mock-authenticated `PrintingUiDemo` remain separate. Do not point the synthetic demo at real paired printer data. The local host retains database document storage until an actual dedicated S3 binding is configured in a deployment-owned application; it does not claim a remote cloud deployment. -Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The previously accepted 0.1.4-rc.1 release passed 73 tests and zero Checkstyle violations; the user confirmed all three Query diagnostic outputs good. Production S3/IAM and remote deployment remain separate qualification work. The 0.2.0 controls/authentication release passed 25 real client/native QQQ interoperability checks; installed Windows acceptance remains in progress. +Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The previously accepted 0.1.4-rc.1 release passed 73 tests and zero Checkstyle violations; the user confirmed all three Query diagnostic outputs good. Production S3/IAM and remote deployment remain separate qualification work. The 0.2.0 controls/authentication release passed 25 real client/native QQQ interoperability checks; the user later confirmed installed Letter PDF and Zebra ZPL output, the Connected footer and both printers. These results do not qualify installed or physical behavior for 0.3. ## Add to a QQQ application For a source checkout, `mvnw.cmd install` (or `bash ./mvnw install`) installs the verified library into your local Maven repository. For a GitHub release, download the JAR, matching POM and `SHA256SUMS`, verify the checksums, then install those artifacts locally or through your organization's artifact repository. Example using the release files in the current directory: ```powershell -.\mvnw.cmd install:install-file '-Dfile=qbit-printing-0.1.4-rc.1.jar' '-DpomFile=qbit-printing-0.1.4-rc.1.pom' +.\mvnw.cmd install:install-file '-Dfile=qbit-printing-0.3.0-rc.1.jar' '-DpomFile=qbit-printing-0.3.0-rc.1.pom' ``` After the artifact is available in your configured repository, add: @@ -80,7 +80,7 @@ After the artifact is available in your configured repository, add: com.kingsrook.qbits qbit-printing - 0.1.4-rc.1 + 0.3.0-rc.1 ``` diff --git a/docs/REALTIME.md b/docs/REALTIME.md index 2288a20..ef4cbdb 100644 --- a/docs/REALTIME.md +++ b/docs/REALTIME.md @@ -1,8 +1,8 @@ # Real-time printing host integration -This source implements printing extension **2.0.0** (wire envelope v1). It is not -part of the published 0.2.0-rc.1 artifacts. Upgrade server and Windows connector -together; a real-time connector rejects a server that advertises only extension +Release candidate [0.3.0-rc.1](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.3.0-rc.1) +implements printing extension **2.0.0** (wire envelope v1). Upgrade the server +before the matching Windows connector; a real-time connector rejects a server that advertises only extension 1.x. There is no timed job/control polling fallback. ## Connection and authorization -- 2.54.0 From ea3ba0d6988d252c863d42bd8fee8cecf46c5f86 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 06:14:48 -0600 Subject: [PATCH 29/35] feat(printing): print records of host tables as record sheets (#18) PrintingRecordProcesses.register(instance, table) adds a Print process per host table: each selected record is rendered as a one-page record sheet and queued as its own PDF job, job ids returned in printingJobIds. Also fixes selection actions whose first step is a form losing the record selection over the UI API (the prepare steps now keep it), which affected the existing test-page action. Refs QRun-IO/qqq#1366 Signed-off-by: James Maes Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 14 + pom.xml | 2 +- .../qbits/printing/PrintingProcesses.java | 18 + .../printing/PrintingRecordDocuments.java | 205 ++++++++++ .../printing/PrintingRecordProcesses.java | 352 ++++++++++++++++++ .../printing/PrintingRecordProcessesTest.java | 241 ++++++++++++ 6 files changed, 831 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingRecordProcessesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ee5e7..f49dc53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased (0.3.1-SNAPSHOT) + +- Add record printing: `PrintingRecordProcesses.register(instance, table)` adds a + **Print** process (`printingPrintRecords_{table}`) to a host table's records and + selections. It asks for an approved, exposed PDF printer and a copy count, shows + the standard review, then renders each selected record as a Letter record sheet + (`PrintingRecordDocuments`: heading, visible non-binary fields with display + values, wrapped and continued across pages) and queues one PDF job per record. + The queued job IDs are returned in the process value `printingJobIds`. +- Fix selection actions whose first step is a form (**Print a test page**, + **Print a test label** on selected printers, and **Print**): over the UI API the + record selection reaches only the init call, so the review answered "No records + were selected". The prepare step now keeps the selection as `queryFilterJson`. + ## 0.3.0-rc.1 — 2026-10-31 Upgrade the server before the matching Windows connector. Native printing API diff --git a/pom.xml b/pom.xml index 4d3bbcf..12117aa 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.kingsrookqbit-build-parent2.0.0 com.kingsrook.qbits qbit-printing - 0.3.0-rc.1 + 0.3.1-SNAPSHOT QQQ Printing Apache License, Version 2.0https://www.apache.org/licenses/LICENSE-2.0 diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java index 3d55382..c35e8e4 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java @@ -376,6 +376,24 @@ public class PrintingProcesses implements BackendStep currentService(); String request = input.getValueString(TEST_REQUEST_ID); output.addValue(TEST_REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request); + keepSelection(input, output); + } + } + + + + /*************************************************************************** + ** A selection action whose first step is a form loses the init request's + ** record selection: the UI API passes it only to the init call (as the + ** callback filter) and the extract step, which would store it, runs after + ** the form. Keep it as the extract step's own queryFilterJson value. + ***************************************************************************/ + static void keepSelection(RunBackendStepInput input, RunBackendStepOutput output) + { + String kept = input.getValueString("queryFilterJson"); + if((kept == null || kept.isBlank()) && input.getCallback() != null && input.getCallback().getQueryFilter() != null) + { + output.addValue("queryFilterJson", com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(input.getCallback().getQueryFilter())); } } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java new file mode 100644 index 0000000..730a3ea --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java @@ -0,0 +1,205 @@ +/* + * 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.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType0Font; + + +/******************************************************************************* + ** A plain Letter "record sheet": a heading, a subtitle and one label/value line + ** per field, wrapped and continued across pages. It is the generic document a + ** record print renders when the host supplies no template of its own. + *******************************************************************************/ +public final class PrintingRecordDocuments +{ + static final int MAX_LINES = 2000; + private static final float MARGIN = 54; + private static final int TITLE_SIZE = 16; + private static final int BODY_SIZE = 10; + private static final float LEADING = 14; + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private PrintingRecordDocuments() + { + } + + + + /*************************************************************************** + ** Render one record as a PDF. Field order is the map's order; values are the + ** display text the caller already formatted (null values print blank). + ***************************************************************************/ + public static byte[] pdf(String title, String subtitle, Map fields) throws QException + { + try(PDDocument document = new PDDocument(); InputStream fontStream = fontStream()) + { + PDType0Font font = PDType0Font.load(document, fontStream, true); + float width = PDRectangle.LETTER.getWidth() - 2 * MARGIN; + int columns = Math.max(20, (int) (width / (font.getStringWidth("0") / 1000 * BODY_SIZE))); + int labelWidth = Math.min(28, Math.max(8, fields.keySet().stream().mapToInt(String::length).max().orElse(8) + 1)); + List lines = new ArrayList<>(); + for(Map.Entry field : fields.entrySet()) + { + List wrapped = wrap(field.getValue() == null ? "" : field.getValue(), columns - labelWidth - 1); + for(int i = 0; i < wrapped.size() && lines.size() < MAX_LINES; i++) + { + String label = i == 0 ? truncate(field.getKey(), labelWidth - 1) : ""; + lines.add(String.format("%-" + labelWidth + "s %s", label, wrapped.get(i))); + } + } + int index = 0; + int pageNumber = 0; + do + { + pageNumber++; + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try(PDPageContentStream content = new PDPageContentStream(document, page)) + { + float y = PDRectangle.LETTER.getHeight() - MARGIN; + text(content, font, TITLE_SIZE, MARGIN, y, truncate(title, (int) (width / (font.getStringWidth("0") / 1000 * TITLE_SIZE)))); + y -= LEADING + 6; + text(content, font, BODY_SIZE, MARGIN, y, truncate(subtitle + (pageNumber > 1 ? " (continued, page " + pageNumber + ")" : ""), columns)); + y -= 6; + content.moveTo(MARGIN, y); + content.lineTo(MARGIN + width, y); + content.stroke(); + y -= LEADING + 4; + while(index < lines.size() && y > MARGIN) + { + text(content, font, BODY_SIZE, MARGIN, y, lines.get(index++)); + y -= LEADING; + } + } + } + while(index < lines.size()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + document.save(output); + return (output.toByteArray()); + } + catch(IOException exception) + { + throw new QException("Could not render the record document", exception); + } + } + + + + /*************************************************************************** + ** Break text at spaces (or hard, for long words) into lines of at most + ** width characters; embedded line breaks start new lines. + ***************************************************************************/ + static List wrap(String text, int width) + { + List lines = new ArrayList<>(); + for(String paragraph : text.replace("\r\n", "\n").replace('\r', '\n').replace('\t', ' ').split("\n", -1)) + { + String rest = paragraph.strip(); + if(rest.isEmpty()) + { + lines.add(""); + continue; + } + while(rest.length() > width) + { + int cut = rest.lastIndexOf(' ', width); + if(cut <= 0) + { + cut = width; + } + lines.add(rest.substring(0, cut).stripTrailing()); + rest = rest.substring(cut).stripLeading(); + } + lines.add(rest); + } + return (lines); + } + + + + /*************************************************************************** + ** Shorten to at most length characters, marking the cut. + ***************************************************************************/ + static String truncate(String text, int length) + { + String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' '); + return (value.length() <= length ? value : value.substring(0, Math.max(0, length - 1)) + "~"); + } + + + + /*************************************************************************** + ** Draw one line, replacing characters the bundled font cannot encode. + ***************************************************************************/ + private static void text(PDPageContentStream content, PDType0Font font, int size, float x, float y, String text) throws IOException + { + StringBuilder safe = new StringBuilder(); + text.codePoints().forEach(codePoint -> + { + String character = new String(Character.toChars(codePoint)); + try + { + font.encode(character); + safe.append(character); + } + catch(IllegalArgumentException | IOException exception) + { + safe.append('?'); + } + }); + content.beginText(); + content.setFont(font, size); + content.newLineAtOffset(x, y); + content.showText(safe.toString()); + content.endText(); + } + + + + /*************************************************************************** + ** The licensed font shipped for the diagnostic documents. + ***************************************************************************/ + private static InputStream fontStream() throws IOException + { + InputStream stream = PrintingRecordDocuments.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE); + if(stream == null) + { + throw new IOException("Bundled document font is missing"); + } + return (stream); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java new file mode 100644 index 0000000..3824f89 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java @@ -0,0 +1,352 @@ +/* + * 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.io.Serializable; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import org.json.JSONObject; + + +/******************************************************************************* + ** "Print" on the records of a host table: choose an approved, exposed PDF + ** printer and a copy count, review the selection, then each selected record is + ** rendered as a record sheet (PrintingRecordDocuments) and queued as its own + ** print job through the ordinary durable queue. The queued job IDs are returned + ** in the process value printingJobIds (comma separated) so clients can follow + ** each job's status in Print jobs. + ** + ** Register per table with register(instance, tableName); the process is named + ** printingPrintRecords_{tableName} (processName(tableName)). + *******************************************************************************/ +public final class PrintingRecordProcesses +{ + public static final String PREFIX = "printingPrintRecords_"; + public static final String PRINTER_FIELD = "printingPrinterId"; + public static final String COPIES_FIELD = "printingCopies"; + public static final String JOB_IDS = "printingJobIds"; + static final String REQUEST_ID = "printingRecordRequestId"; + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private PrintingRecordProcesses() + { + } + + + + /*************************************************************************** + ** The record-print process name of a table. + ***************************************************************************/ + public static String processName(String tableName) + { + return (PREFIX + tableName); + } + + + + /*************************************************************************** + ** Add the Print action to a table's records and selections. + ***************************************************************************/ + public static void register(QInstance instance, String tableName) throws QException + { + QTableMetaData table = instance.getTable(tableName); + if(table == null) + { + throw new QException("Cannot add printing to unknown table " + tableName); + } + Map values = new HashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, tableName); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, tableName); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "Each selected record will be printed as its own job on the chosen printer after you continue."); + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values) + .withName(processName(tableName)).withLabel("Print").withTableName(tableName).withMinInputRecords(1) + .withPermissionRules(PrintingProcesses.permissionRules()); + process.withStep(0, new QFrontendStepMetaData().withName("printer").withLabel("Choose a printer") + .withFormFields(List.of(printerField(), new QFieldMetaData(COPIES_FIELD, QFieldType.INTEGER).withLabel("Copies").withIsRequired(true).withDefaultValue(1))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))); + process.withStep(0, new QBackendStepMetaData().withName("preparePrint").withCode(new QCodeReference(Prepare.class))); + instance.addProcess(process); + } + + + + /*************************************************************************** + ** Only approved printers that the device currently exposes and that take PDF. + ***************************************************************************/ + static QFieldMetaData printerField() + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1)) + .withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1)) + .withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, "PDF")); + return (new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true) + .withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter)); + } + + + + /*************************************************************************** + ** The chosen printer, re-read with the caller's permissions and checked. + ***************************************************************************/ + static QRecord printer(RunBackendStepInput input) throws QException + { + String id = input.getValueString(PRINTER_FIELD); + if(id == null || id.isBlank()) + { + throw new QException("Choose a printer"); + } + QRecord printer = PrintingProcesses.resolvePrinter(id, null); + if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available")) + || printer.getValueString("formats") == null || !new org.json.JSONArray(printer.getValueString("formats")).toList().contains("PDF")) + { + throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its device, approved, and support PDF"); + } + return (printer); + } + + + + /*************************************************************************** + ** 1 when unset; 1 to 100 otherwise (the protocol's copies range). + ***************************************************************************/ + static int copies(RunBackendStepInput input) throws QException + { + Integer copies = input.getValueInteger(COPIES_FIELD); + if(copies == null) + { + return (1); + } + if(copies < 1 || copies > 100) + { + throw new QException("Copies must be between 1 and 100"); + } + return (copies); + } + + + + /*************************************************************************** + ** Render one record (display values, visible non-binary fields) and queue it. + ***************************************************************************/ + static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request) throws QException + { + Serializable primaryKey = selected.getValue(table.getPrimaryKeyField()); + GetInput get = new GetInput(); + get.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + get.setTableName(table.getName()); + get.setPrimaryKey(primaryKey); + get.setShouldGenerateDisplayValues(true); + get.setShouldTranslatePossibleValues(true); + QRecord record = new GetAction().execute(get).getRecord(); + if(record == null) + { + throw new QException("Record " + primaryKey + " of " + table.getLabel() + " is not accessible"); + } + Map fields = new LinkedHashMap<>(); + for(QFieldMetaData field : table.getFields().values()) + { + if(Boolean.TRUE.equals(field.getIsHidden()) || field.getType() == QFieldType.BLOB) + { + continue; + } + String display = record.getDisplayValue(field.getName()); + Serializable raw = record.getValue(field.getName()); + fields.put(field.getLabel() == null ? field.getName() : field.getLabel(), display != null ? display : raw == null ? null : String.valueOf(raw)); + } + String label = record.getRecordLabel() != null ? record.getRecordLabel() : String.valueOf(primaryKey); + String tableLabel = table.getLabel() == null ? table.getName() : table.getLabel(); + String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getFullName(); + String subtitle = tableLabel + " " + primaryKey + " - printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user); + byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, fields); + try + { + JSONObject job = PrintingProcesses.currentService().submit(new JSONObject() + .put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id")) + .put("title", PrintingRecordDocuments.truncate(tableLabel + ": " + label, 200)).put("format", "PDF") + .put("contentBase64", Base64.getEncoder().encodeToString(bytes)) + .put("idempotencyKey", "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id")) + .put("options", new JSONObject().put("copies", copies).put("scale", "FIT"))); + return (job.getString("jobId")); + } + catch(java.sql.SQLException | PrintingException exception) + { + throw new QException("Could not queue the print job: " + exception.getMessage(), exception); + } + } + + + + /*************************************************************************** + ** One request identity per process run, so a replayed load does not print twice. + ***************************************************************************/ + public static class Prepare implements BackendStep + { + /*************************************************************************** + ** Require printing access before any form is shown. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + PrintingProcesses.currentService(); + String request = input.getValueString(REQUEST_ID); + output.addValue(REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request); + PrintingProcesses.keepSelection(input, output); + } + } + + + + /*************************************************************************** + ** Review: the printer is still usable; the selected records pass through. + ***************************************************************************/ + public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private final ProcessSummaryLine ready = new ProcessSummaryLine(Status.OK, 0, null) + .withSingularFutureMessage("record will be printed").withPluralFutureMessage("records will be printed"); + + + + /*************************************************************************** + ** Check the printer and copies once per page; keep the records. + ***************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + printer(input); + copies(input); + QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE)); + for(QRecord record : input.getRecords()) + { + ready.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField())); + output.getRecords().add(record); + } + } + + + + /*************************************************************************** + ** Standard review summary. + ***************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + return (new ArrayList<>(List.of(ready))); + } + } + + + + /*************************************************************************** + ** Execute: render and queue each record; report the job IDs. + ***************************************************************************/ + public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final ProcessSummaryLine done = new ProcessSummaryLine(Status.OK, 0, null) + .withSingularPastMessage("record was sent to the printer").withPluralPastMessage("records were sent to the printer"); + private final List jobIds = new ArrayList<>(); + + + + /*************************************************************************** + ** Resolve the printer again (access may have changed since review). + ***************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + QRecord printer = printer(input); + int copies = copies(input); + String request = input.getValueString(REQUEST_ID); + if(request == null || request.isBlank()) + { + throw new QException("Restart Print to obtain a safe request identity"); + } + QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE)); + for(QRecord record : input.getRecords()) + { + jobIds.add(submit(table, record, printer, copies, request)); + done.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField())); + output.getRecords().add(record); + } + output.addValue(JOB_IDS, String.join(",", jobIds)); + } + + + + /*************************************************************************** + ** Result summary: what was sent, and the job IDs to follow. + ***************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(List.of(done)); + if(!jobIds.isEmpty()) + { + lines.add(new ProcessSummaryLine(Status.INFO, null, "Print job" + (jobIds.size() == 1 ? " " : "s ") + String.join(", ", jobIds) + " queued; follow them in Print jobs.")); + } + return (lines); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRecordProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRecordProcessesTest.java new file mode 100644 index 0000000..7d61618 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRecordProcessesTest.java @@ -0,0 +1,241 @@ +/* + * 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.io.Serializable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Record printing: the per-table Print process and the record sheet document. + *******************************************************************************/ +class PrintingRecordProcessesTest +{ + /*************************************************************************** + ** Clear the shared memory store and context between tests. + ***************************************************************************/ + @AfterEach + void clear() + { + MemoryRecordStore.getInstance().reset(); + QContext.clear(); + } + + + + /*************************************************************************** + ** Printer form, review, then one queued PDF job per selected record. + ***************************************************************************/ + @Test + void printsEachSelectedRecordOnceAndReturnsTheJobIds() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixtureWithPeople(); + String process = PrintingRecordProcesses.processName("person"); + assertEquals("printingPrintRecords_person", process); + QFrontendStepMetaData printerStep = (QFrontendStepMetaData) fixture.instance().getProcess(process).getStep("printer"); + assertEquals(PrintingTables.PRINTER, printerStep.getFormField(PrintingRecordProcesses.PRINTER_FIELD).getPossibleValueSourceName()); + + var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withValue("recordIds", "1,2")); + assertFalse(form.getException().isPresent(), () -> form.getException().toString()); + assertEquals("printer", form.getProcessState().getNextStepName().orElseThrow()); + var review = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID()) + .withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()).withValue(PrintingRecordProcesses.COPIES_FIELD, 2)); + assertFalse(review.getException().isPresent(), () -> review.getException().toString()); + assertEquals(0, fixture.service().jobs().length(), "review must not print"); + var done = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(review.getProcessUUID()) + .withStartAfterStep(review.getProcessState().getNextStepName().orElseThrow()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)); + assertFalse(done.getException().isPresent(), () -> done.getException().toString()); + + JSONArray jobs = fixture.service().jobs(); + assertEquals(2, jobs.length()); + String ids = String.valueOf(done.getValues().get(PrintingRecordProcesses.JOB_IDS)); + assertEquals(2, ids.split(",").length); + for(int i = 0; i < jobs.length(); i++) + { + JSONObject job = jobs.getJSONObject(i); + assertTrue(ids.contains(job.getString("jobId"))); + assertEquals("PDF", job.getString("format")); + assertTrue(job.getString("title").startsWith("Person: ")); + assertEquals(2, job.getJSONObject("options").getInt("copies")); + fixture.service().claimForJob(fixture.device(), job.getString("jobId")); + byte[] content = fixture.service().content(fixture.device(), job.getString("jobId")); + try(PDDocument document = PDDocument.load(content)) + { + String text = new PDFTextStripper().getText(document); + assertTrue(text.contains("Person:")); + assertTrue(text.contains("First name")); + assertTrue(text.contains("Ada") || text.contains("Grace")); + assertFalse(text.contains("Secret"), "hidden fields are not printed"); + } + } + } + + + + /*************************************************************************** + ** The UI API passes the selection only to init (as the callback filter); + ** the selection must survive the printer form that comes before review. + ***************************************************************************/ + @Test + void keepsTheInitSelectionAcrossThePrinterForm() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixtureWithPeople(); + String process = PrintingRecordProcesses.processName("person"); + var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withCallback(new QProcessCallback() + { + @Override + public QQueryFilter getQueryFilter() + { + return (new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, List.of(2)))); + } + + + + @Override + public Map getFieldValues(List fields) + { + return (Map.of()); + } + })); + var review = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID()) + .withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())); + new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(review.getProcessUUID()) + .withStartAfterStep(review.getProcessState().getNextStepName().orElseThrow()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)); + JSONArray jobs = fixture.service().jobs(); + assertEquals(1, jobs.length()); + assertTrue(jobs.getJSONObject(0).getString("title").contains("Grace")); + } + + + + /*************************************************************************** + ** A printer that is not approved (or not exposed) is refused at review. + ***************************************************************************/ + @Test + void refusesAPrinterThatIsNotApproved() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixtureWithPeople(); + String process = PrintingRecordProcesses.processName("person"); + var device = fixture.service(); + device.enablePrinter(fixture.device(), "laser", false); + var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withValue("recordIds", "1")); + QException refused = assertThrows(QException.class, () -> new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID()) + .withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()))); + assertTrue(refused.getMessage().contains("approved"), refused::getMessage); + assertEquals(0, fixture.service().jobs().length()); + } + + + + /*************************************************************************** + ** Unknown tables are refused at registration. + ***************************************************************************/ + @Test + void registerRefusesAnUnknownTable() throws Exception + { + PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture(); + assertThrows(QException.class, () -> PrintingRecordProcesses.register(fixture.instance(), "nope")); + } + + + + /*************************************************************************** + ** Long values wrap, long documents continue on more pages, unencodable + ** characters are replaced rather than failing the render. + ***************************************************************************/ + @Test + void recordSheetWrapsAndContinuesAcrossPages() throws Exception + { + Map fields = new LinkedHashMap<>(); + fields.put("Notes", "word ".repeat(400)); + for(int i = 0; i < 80; i++) + { + fields.put("Field " + i, "value " + i + " 中文"); + } + fields.put("Empty", null); + byte[] bytes = PrintingRecordDocuments.pdf("Person: Ada Lovelace", "Person 1", fields); + try(PDDocument document = PDDocument.load(bytes)) + { + assertTrue(document.getNumberOfPages() > 1); + String text = new PDFTextStripper().getText(document); + assertTrue(text.contains("Field 79")); + assertTrue(text.contains("continued, page 2")); + } + assertEquals(List.of("abc", "def"), PrintingRecordDocuments.wrap("abc def", 4)); + assertEquals(List.of("abcd", "ef"), PrintingRecordDocuments.wrap("abcdef", 4)); + assertEquals("ab~", PrintingRecordDocuments.truncate("abcdef", 3)); + } + + + + /*************************************************************************** + ** The process fixture plus a memory "person" table with two records. + ***************************************************************************/ + private static PrintingProcessesTest.Fixture fixtureWithPeople() throws Exception + { + PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture(); + QInstance instance = fixture.instance(); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + instance.addTable(new QTableMetaData().withName("person").withLabel("Person").withBackendName("memory").withPrimaryKeyField("id") + .withRecordLabelFormat("%s %s").withRecordLabelFields(List.of("firstName", "lastName")) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("Id")) + .withField(new QFieldMetaData("firstName", QFieldType.STRING).withLabel("First name")) + .withField(new QFieldMetaData("lastName", QFieldType.STRING).withLabel("Last name")) + .withField(new QFieldMetaData("secret", QFieldType.STRING).withLabel("Secret").withIsHidden(true))); + PrintingRecordProcesses.register(instance, "person"); + new InsertAction().execute(new InsertInput("person").withRecords(List.of( + new QRecord().withValue("id", 1).withValue("firstName", "Ada").withValue("lastName", "Lovelace").withValue("secret", "x"), + new QRecord().withValue("id", 2).withValue("firstName", "Grace").withValue("lastName", "Hopper")))); + return (fixture); + } +} -- 2.54.0 From 2b2ad3346a1ab6f0cc51765d0db674615f91f7dd Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 07:00:08 -0600 Subject: [PATCH 30/35] feat(printing): print any table's records, table lists and text pages (#20) PrintingRecordProcesses.registerAll adds Print to every readable table (opt-out per table); record sheets are built from table metadata; printingPrintTableList prints a grid's columns, widths, filter and sort for all rows (1,000 at a time up to 10,000, truncation reported) or the current page with repeated headers and page footers; printingPrintText prints monospace text pages with a shrinking font. Version 0.4.0-SNAPSHOT, extension 2.1.0 (additive). The new processes' inputs are editable so user values are accepted over the UI API. Refs QRun-IO/qqq#1366 Signed-off-by: James Maes Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 29 +- README.md | 2 + pom.xml | 2 +- .../qbits/printing/PrintingLayout.java | 430 ++++++++++++++++++ .../qbits/printing/PrintingListProcesses.java | 425 +++++++++++++++++ .../qbits/printing/PrintingQBitProducer.java | 1 + .../printing/PrintingRecordDocuments.java | 173 ++++--- .../printing/PrintingRecordProcesses.java | 193 +++++++- .../printing/PrintingTableDocuments.java | 124 +++++ .../qbits/printing/PrintingTableSettings.java | 91 ++++ .../qbits/printing/PrintingTextDocuments.java | 102 +++++ .../qbits/printing/PrintingUiApi.java | 6 +- .../resources/openapi/qqq-v1/printing.yaml | 4 +- .../printing/PrintingListProcessesTest.java | 357 +++++++++++++++ .../printing/PrintingRecordProcessesTest.java | 3 +- .../printing/PrintingUiApiContractTest.java | 2 +- 16 files changed, 1824 insertions(+), 120 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingTableSettings.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f49dc53..469ae87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,33 @@ # Changelog -## Unreleased (0.3.1-SNAPSHOT) +## Unreleased (0.4.0-SNAPSHOT) + +Printing extension **2.1.0** (additive; the agent contract is unchanged). + +- Print anything: `PrintingRecordProcesses.registerAll(instance)` adds **Print** to + every table that can be read one record at a time (call it after the host's + tables are in the instance). Hidden tables, tables without a primary key or + `TABLE_GET`, and tables carrying `PrintingTableSettings().withPrintable(false)` + are skipped. +- The record sheet now follows the table's metadata: field sections in tier order + with their labels, remaining fields under "Other fields", and a summary of each + child list (association): its label, row count and first ten rows. +- New `printingPrintTableList`: a table list (grid) of any readable table with the + caller's columns (in order) and widths, filter and sort, either the whole result + (`scope` `ALL`, the default: read a thousand rows at a time up to `maxRows`, + 10,000 by default and at most; a larger result prints its first rows and says + so in the header and in `printingMessage`) or the current page (`PAGE`, the + filter's skip/limit). Letter or A4; orientation `AUTO` (landscape when the + columns need it), portrait or landscape. The header repeats on every page; the + page header carries the table, the rows printed of the total, the filter text + and the sort, and the footer the table, time, user and "Page n of m". + `printingRowCount` reports the matching rows before printing. +- New `printingPrintText`: any text (a capture of a terminal screen) as a + monospace PDF, wrapped and broken across pages. +- All documents share one page writer with "Page n of m" footers and a running + title on later pages. Table and record reads enforce the user's permissions. + +## 0.3.1-SNAPSHOT - Add record printing: `PrintingRecordProcesses.register(instance, table)` adds a **Print** process (`printingPrintRecords_{table}`) to a host table's records and diff --git a/README.md b/README.md index bf5ffba..592daad 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ JSONObject job = printing.submit(new JSONObject() String printJobId = job.getString("jobId"); ``` +For terminal and web clients the QBit also offers ready-made print processes: `PrintingRecordProcesses.registerAll(instance)` (after all host tables are added) puts **Print** on every readable table, rendering a record sheet from the table's sections and child lists; `printingPrintTableList` prints a grid of any table (columns, filter, sort, page or all rows, paper and orientation); `printingPrintText` prints text such as a screen capture. Opt a table out with `PrintingTableSettings().withPrintable(false)`. Each returns the queued job id in `printingJobIds`. + The returned ID identifies durable queue acceptance. Check job status asynchronously. The [protocol v1 specification](https://github.com/QRun-IO/qqq-print-protocol/blob/f977bd01b62ceb7539190fd2861809537fcf504c/README.md) defines HTTP endpoints, JSON fields, integrity checks and lifecycle semantics. For durable QQQ jobs register `PrintEnqueueEffectTarget` as an `IDEMPOTENT_EXTERNAL` target in `DurableJobsMetaData`, then emit the submit-request payload. The target replaces the payload idempotency key with the durable effect key. Its SENT result means accepted into the queue, not physical output. This implementation uses its own JDBC transaction; do not register it as LOCAL_TX. Authorize the destination in the emitting process before writing the effect. QQQ's durable dispatcher runs with a `QSystemUserSession` and does not propagate the user's tenant keys. For that trusted outbox path only, the target resolves the service configured on the dispatcher's current QQQ instance; that service fixes the tenant and rejects devices belonging to another tenant. The payload cannot choose a service or tenant. A cached target resolves the current instance on every call. Inline user calls retain the `printing_tenant` scope check, and native management processes (including enrollment) still require both administrator permission and matching tenant scope. Do not expose direct effect emission through an arbitrary user-supplied target/payload endpoint. diff --git a/pom.xml b/pom.xml index 12117aa..08789ea 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.kingsrookqbit-build-parent2.0.0 com.kingsrook.qbits qbit-printing - 0.3.1-SNAPSHOT + 0.4.0-SNAPSHOT QQQ Printing Apache License, Version 2.0https://www.apache.org/licenses/LICENSE-2.0 diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java b/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java new file mode 100644 index 0000000..fd5723a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java @@ -0,0 +1,430 @@ +/* + * 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.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType0Font; + + +/******************************************************************************* + ** A small monospace page writer shared by the record, table-list and text + ** documents: a heading on the first page, a running title on later pages, + ** lines that break onto new pages (optionally repeating a header), and + ** "Page n of m" footers. Characters the bundled font cannot encode print as ?. + *******************************************************************************/ +final class PrintingLayout implements AutoCloseable +{ + static final float MARGIN = 40; + static final int MAX_PAGES = 500; + + private final PDDocument document = new PDDocument(); + private final PDType0Font font; + private final PDRectangle size; + private final String title; + private PDPageContentStream content; + private float y; + private List repeatHeader = List.of(); + private int repeatSize = 8; + private String footer = ""; + private float leading = 1.35f; + + + + /*************************************************************************** + ** Page size LETTER or A4, portrait or landscape; the first page carries + ** the title and subtitle. + ***************************************************************************/ + PrintingLayout(String pageSize, boolean landscape, String title, String subtitle) throws QException + { + try(InputStream stream = PrintingLayout.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE)) + { + if(stream == null) + { + throw new IOException("Bundled document font is missing"); + } + font = PDType0Font.load(document, stream, true); + PDRectangle base = "A4".equals(pageSize == null ? null : pageSize.toUpperCase(Locale.ROOT)) ? PDRectangle.A4 : PDRectangle.LETTER; + size = landscape ? new PDRectangle(base.getHeight(), base.getWidth()) : base; + this.title = title == null ? "" : title; + newPage(); + line(this.title, 15); + y -= 4; + if(subtitle != null && !subtitle.isBlank()) + { + line(subtitle, 9); + } + y -= 4; + rule(); + y -= 6; + } + catch(IOException exception) + { + close(); + throw new QException("Could not start the print document", exception); + } + } + + + + /*************************************************************************** + ** How many monospace characters fit across the page at a font size. + ***************************************************************************/ + int columns(float fontSize) + { + return (Math.max(20, (int) ((size.getWidth() - 2 * MARGIN) / (0.6f * fontSize)))); + } + + + + /*************************************************************************** + ** Line spacing as a multiple of the font size (1.35 by default; text + ** captures use less so box-drawing characters join up). + ***************************************************************************/ + void leading(float factor) + { + leading = factor; + } + + + + /*************************************************************************** + ** Text at the bottom left of every page (who printed it and when). + ***************************************************************************/ + void footer(String text) + { + footer = text == null ? "" : text; + } + + + + /*************************************************************************** + ** Lines repeated at the top of every following page (a table header). + ***************************************************************************/ + void repeatOnNewPages(List lines, int fontSize) + { + repeatHeader = List.copyOf(lines); + repeatSize = fontSize; + } + + + + /*************************************************************************** + ** One line at a font size; breaks the page first when it does not fit. + ***************************************************************************/ + void line(String text, float fontSize) throws IOException + { + float step = fontSize * leading; + if(y - step < MARGIN + 14) + { + newPage(); + for(String header : repeatHeader) + { + draw(header, repeatSize, repeatSize * leading); + } + } + draw(text, fontSize, step); + } + + + + /*************************************************************************** + ** Keep at least this many lines together (a heading with its first rows). + ***************************************************************************/ + void keep(int lines, int fontSize) throws IOException + { + if(y - lines * fontSize * 1.35f < MARGIN + 14) + { + newPage(); + } + } + + + + /*************************************************************************** + ** Vertical space. + ***************************************************************************/ + void gap(float points) + { + y -= points; + } + + + + /*************************************************************************** + ** A thin rule across the page. + ***************************************************************************/ + void rule() throws IOException + { + content.moveTo(MARGIN, y); + content.lineTo(size.getWidth() - MARGIN, y); + content.stroke(); + y -= 4; + } + + + + /*************************************************************************** + ** Footers on every page, then the PDF bytes. + ***************************************************************************/ + byte[] finish() throws QException + { + try + { + content.close(); + content = null; + int total = document.getNumberOfPages(); + for(int i = 0; i < total; i++) + { + PDPage page = document.getPage(i); + try(PDPageContentStream footer = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)) + { + String text = "Page " + (i + 1) + " of " + total; + footer.beginText(); + footer.setFont(font, 8); + footer.newLineAtOffset(size.getWidth() - MARGIN - text.length() * 4.8f, MARGIN - 14); + footer.showText(text); + footer.endText(); + if(!this.footer.isEmpty()) + { + footer.beginText(); + footer.setFont(font, 8); + footer.newLineAtOffset(MARGIN, MARGIN - 14); + footer.showText(safe(PrintingRecordDocuments.truncate(this.footer, Math.max(10, columns(8) - text.length() - 2)))); + footer.endText(); + } + } + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + document.save(output); + return (output.toByteArray()); + } + catch(IOException exception) + { + throw new QException("Could not render the print document", exception); + } + } + + + + /*************************************************************************** + ** Release the document. + ***************************************************************************/ + @Override + public void close() + { + try + { + if(content != null) + { + content.close(); + } + document.close(); + } + catch(IOException exception) + { + // nothing more to release + } + } + + + + /*************************************************************************** + ** Start a page; later pages show the running title. + ***************************************************************************/ + private void newPage() throws IOException + { + if(document.getNumberOfPages() >= MAX_PAGES) + { + throw new IOException("The document would exceed " + MAX_PAGES + " pages; print fewer rows"); + } + if(content != null) + { + content.close(); + } + PDPage page = new PDPage(size); + document.addPage(page); + content = new PDPageContentStream(document, page); + y = size.getHeight() - MARGIN; + if(document.getNumberOfPages() > 1) + { + draw(PrintingRecordDocuments.truncate(title, columns(8)) + " (continued)", 8, 12); + y -= 4; + } + } + + + + /*************************************************************************** + ** Draw one line at the current position. + ***************************************************************************/ + private void draw(String text, float fontSize, float step) throws IOException + { + y -= step; + content.beginText(); + content.setFont(font, fontSize); + content.newLineAtOffset(MARGIN, y); + content.showText(safe(PrintingRecordDocuments.truncate(text, columns(fontSize)))); + content.endText(); + } + + + + /*************************************************************************** + ** Replace characters the font cannot encode, and control characters. + ***************************************************************************/ + private String safe(String text) + { + StringBuilder out = new StringBuilder(); + text.codePoints().forEach(codePoint -> + { + String character = new String(Character.toChars(codePoint)); + if(Character.isISOControl(codePoint)) + { + out.append(' '); + return; + } + try + { + font.encode(character); + out.append(character); + } + catch(IllegalArgumentException | IOException exception) + { + out.append('?'); + } + }); + return (out.toString()); + } + + + + /*************************************************************************** + ** Width-limited columns for a table: natural widths shrunk to the line. + ***************************************************************************/ + static int[] columnWidths(List headers, List> rows, int available) + { + return (fit(naturalWidths(headers, rows), available)); + } + + + + /*************************************************************************** + ** Each column's natural width: the header or the 90th percentile of its + ** cells, between 4 and 60 characters. + ***************************************************************************/ + static int[] naturalWidths(List headers, List> rows) + { + int count = headers.size(); + int[] widths = new int[count]; + for(int c = 0; c < count; c++) + { + List lengths = new ArrayList<>(); + for(List row : rows) + { + lengths.add(c < row.size() && row.get(c) != null ? row.get(c).length() : 0); + } + lengths.sort(null); + int typical = lengths.isEmpty() ? 0 : lengths.get(Math.max(0, (int) Math.ceil(lengths.size() * 0.9) - 1)); + widths[c] = Math.max(4, Math.min(60, Math.max(headers.get(c).length(), typical))); + } + return (widths); + } + + + + /*************************************************************************** + ** The width a row of these columns takes, with two-space gaps. + ***************************************************************************/ + static int lineWidth(int[] widths) + { + return (sum(widths) + Math.max(0, widths.length - 1) * 2); + } + + + + /*************************************************************************** + ** Shrink widths (widest first, never below 4) until a row fits. + ***************************************************************************/ + static int[] fit(int[] start, int available) + { + int[] widths = start.clone(); + while(lineWidth(widths) > available) + { + int widest = 0; + for(int c = 1; c < widths.length; c++) + { + if(widths[c] > widths[widest]) + { + widest = c; + } + } + if(widths[widest] <= 4) + { + break; + } + widths[widest]--; + } + return (widths); + } + + + + /*************************************************************************** + ** Sum of widths. + ***************************************************************************/ + private static int sum(int[] values) + { + int total = 0; + for(int value : values) + { + total += value; + } + return (total); + } + + + + /*************************************************************************** + ** One table row laid out in fixed-width columns. + ***************************************************************************/ + static String row(List cells, int[] widths) + { + StringBuilder out = new StringBuilder(); + for(int c = 0; c < widths.length; c++) + { + String cell = c < cells.size() && cells.get(c) != null ? cells.get(c).replace('\n', ' ').replace('\r', ' ') : ""; + cell = PrintingRecordDocuments.truncate(cell, widths[c]); + out.append(c == 0 ? "" : " ").append(String.format("%-" + widths[c] + "s", cell)); + } + return (out.toString().stripTrailing()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java new file mode 100644 index 0000000..82bd24e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java @@ -0,0 +1,425 @@ +/* + * 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.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + + +/******************************************************************************* + ** Two print processes that are not bound to one table: + ** + ** - printingPrintTableList: a table list (grid) of any readable table. Values: + ** tableName, columns (comma separated, in order; default the visible + ** fields), filterJSON (a QQueryFilter with orderBys; its skip/limit are the + ** page), scope PAGE (the filter's skip/limit) or ALL (the whole result, + ** read a thousand rows at a time, up to maxRows: default and most 10,000; + ** a larger result prints its first rows and says so), columnWidths (the + ** grid's widths in characters, comma separated, same order), pageSize + ** LETTER/A4, orientation PORTRAIT/LANDSCAPE/AUTO (landscape when the + ** columns need it; the default), title, filterDescription (the filter as + ** the user sees it), then the printer and copies. + ** - printingPrintText: any text as a monospace PDF (a capture of a terminal + ** screen). Values: title, text, subtitle, pageSize, orientation, printer, + ** copies. + ** + ** Both ask for the printer on an options form (prefilled from the init + ** values), queue one PDF job and return its id in printingJobIds. Reading the + ** table enforces the user's table and record permissions. + *******************************************************************************/ +public final class PrintingListProcesses +{ + public static final String TABLE_LIST = "printingPrintTableList"; + public static final String TEXT = "printingPrintText"; + public static final int MAX_ROWS = 10_000; + public static final int DEFAULT_MAX_ROWS = MAX_ROWS; + static final int QUERY_PAGE = 1000; + static final String REQUEST_ID = "printingListRequestId"; + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private PrintingListProcesses() + { + } + + + + /*************************************************************************** + ** Register both processes and their choice lists. + ***************************************************************************/ + public static void register(QInstance instance) + { + choices(instance, "printing_pageSize", List.of("LETTER", "A4"), List.of("Letter", "A4")); + choices(instance, "printing_orientation", List.of("AUTO", "PORTRAIT", "LANDSCAPE"), List.of("Automatic (landscape when the columns need it)", "Portrait", "Landscape")); + choices(instance, "printing_listScope", List.of("ALL", "PAGE"), List.of("All rows (current filter and sort)", "The current page")); + instance.addProcess(process(TABLE_LIST, "Print a table list", List.of( + new QFieldMetaData("tableName", QFieldType.STRING).withLabel("Table").withIsRequired(true), + new QFieldMetaData("scope", QFieldType.STRING).withLabel("Rows").withPossibleValueSourceName("printing_listScope").withDefaultValue("ALL"), + new QFieldMetaData("maxRows", QFieldType.INTEGER).withLabel("Most rows (all rows)").withDefaultValue(DEFAULT_MAX_ROWS), + new QFieldMetaData("orientation", QFieldType.STRING).withLabel("Orientation").withPossibleValueSourceName("printing_orientation").withDefaultValue("AUTO")))); + instance.addProcess(process(TEXT, "Print text", List.of( + new QFieldMetaData("title", QFieldType.STRING).withLabel("Title"), + new QFieldMetaData("orientation", QFieldType.STRING).withLabel("Orientation").withPossibleValueSourceName("printing_orientation").withDefaultValue("PORTRAIT")))); + } + + + + /*************************************************************************** + ** prepare, options (printer and layout), execute, result. + ***************************************************************************/ + private static QProcessMetaData process(String name, String label, List extra) + { + List fields = new ArrayList<>(extra); + fields.add(PrintingRecordProcesses.printerField()); + fields.add(new QFieldMetaData(PrintingRecordProcesses.COPIES_FIELD, QFieldType.INTEGER).withLabel("Copies").withIsRequired(true).withDefaultValue(1)); + fields.add(new QFieldMetaData("pageSize", QFieldType.STRING).withLabel("Paper").withPossibleValueSourceName("printing_pageSize").withDefaultValue("LETTER")); + return (new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules()) + .withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))) + .withStep(new QFrontendStepMetaData().withName("options").withLabel("Choose a printer").withFormFields(fields) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))) + .withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(Execute.class))) + .withStep(new QFrontendStepMetaData().withName("result").withLabel("Result") + .withViewFields(List.of(new QFieldMetaData("result", QFieldType.STRING).withLabel("Result"))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM)))); + } + + + + /*************************************************************************** + ** A fixed enum choice list. + ***************************************************************************/ + private static void choices(QInstance instance, String name, List ids, List labels) + { + ArrayList> values = new ArrayList<>(); + for(int i = 0; i < ids.size(); i++) + { + values.add(new QPossibleValue<>(ids.get(i), labels.get(i))); + } + instance.addPossibleValueSource(new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.ENUM).withEnumValues(values)); + } + + + + /*************************************************************************** + ** The table a table list reads, checked readable. + ***************************************************************************/ + static QTableMetaData table(RunBackendStepInput input) throws QException + { + String name = input.getValueString("tableName"); + QTableMetaData table = name == null ? null : QContext.getQInstance().getTable(name); + if(table == null) + { + throw new QException("Choose a table to print (unknown table " + name + ")"); + } + PrintingTableSettings settings = PrintingTableSettings.of(table); + if(settings != null && !settings.getPrintable()) + { + throw new QException(table.getLabel() + " is not printable"); + } + return (table); + } + + + + /*************************************************************************** + ** The filter from filterJSON (or everything), with the page or the row limit. + ***************************************************************************/ + static QQueryFilter filter(RunBackendStepInput input) throws QException + { + String json = input.getValueString("filterJSON"); + QQueryFilter filter; + try + { + filter = json == null || json.isBlank() ? new QQueryFilter() : JsonUtils.toObject(json, QQueryFilter.class); + } + catch(Exception exception) + { + throw new QException("The filter is not valid: " + exception.getMessage(), exception); + } + if("ALL".equalsIgnoreCase(input.getValueString("scope"))) + { + Integer max = input.getValueInteger("maxRows"); + int limit = max == null ? DEFAULT_MAX_ROWS : max; + if(limit < 1 || limit > MAX_ROWS) + { + throw new QException("Most rows must be between 1 and " + MAX_ROWS); + } + filter.setSkip(0); + filter.setLimit(limit); + } + else + { + if(filter.getLimit() == null || filter.getLimit() > MAX_ROWS) + { + filter.setLimit(filter.getLimit() == null ? 50 : MAX_ROWS); + } + } + return (filter); + } + + + + /*************************************************************************** + ** The columns to print: given (unknown names refused), else visible fields. + ***************************************************************************/ + static List columns(QTableMetaData table, String given) throws QException + { + List out = new ArrayList<>(); + if(given == null || given.isBlank()) + { + table.getFields().values().stream().filter(PrintingRecordProcesses::printable).forEach(out::add); + return (out); + } + for(String name : Arrays.stream(given.split(",")).map(String::trim).filter(n -> !n.isEmpty()).toList()) + { + QFieldMetaData field = table.getFields().get(name); + if(field == null) + { + throw new QException("Table " + table.getName() + " has no field " + name); + } + if(PrintingRecordProcesses.printable(field)) + { + out.add(field); + } + } + if(out.isEmpty()) + { + throw new QException("None of the columns can be printed"); + } + return (out); + } + + + + /*************************************************************************** + ** The rows to print: the page as given, or the whole result read a page at + ** a time up to the filter's limit (the print limit). + ***************************************************************************/ + static List read(QTableMetaData table, QQueryFilter filter, boolean all) throws QException + { + List out = new ArrayList<>(); + int limit = filter.getLimit() == null ? 50 : filter.getLimit(); + int skip = filter.getSkip() == null ? 0 : filter.getSkip(); + while(out.size() < limit) + { + int size = all ? Math.min(QUERY_PAGE, limit - out.size()) : limit; + QueryInput query = new QueryInput(); + query.setTableName(table.getName()); + query.setFilter(filter.clone().withSkip(skip + out.size()).withLimit(size)); + query.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + query.setShouldGenerateDisplayValues(true); + query.setShouldTranslatePossibleValues(true); + List page = new QueryAction().execute(query).getRecords(); + out.addAll(page); + if(!all || page.size() < size) + { + break; + } + } + return (out); + } + + + + /*************************************************************************** + ** The sort as labels: "Last name, First name descending". + ***************************************************************************/ + static String sortText(QTableMetaData table, QQueryFilter filter) + { + if(filter.getOrderBys() == null) + { + return (""); + } + List parts = new ArrayList<>(); + for(var orderBy : filter.getOrderBys()) + { + String name = orderBy.getFieldName(); + QFieldMetaData field = name == null ? null : table.getFields().get(name); + parts.add((field == null || field.getLabel() == null ? name : field.getLabel()) + (orderBy.getIsAscending() ? "" : " descending")); + } + return (String.join(", ", parts)); + } + + + + /*************************************************************************** + ** The grid's column widths ("12,30,8"), or null. + ***************************************************************************/ + static int[] widths(String text) + { + if(text == null || text.isBlank()) + { + return (null); + } + try + { + return (Arrays.stream(text.split(",")).map(String::trim).mapToInt(Integer::parseInt).toArray()); + } + catch(NumberFormatException exception) + { + return (null); + } + } + + + + /*************************************************************************** + ** AUTO unless PORTRAIT or LANDSCAPE was chosen. + ***************************************************************************/ + static String orientation(RunBackendStepInput input) + { + String value = input.getValueString("orientation"); + return (value == null || value.isBlank() ? "AUTO" : value.toUpperCase(Locale.ROOT)); + } + + + + /*************************************************************************** + ** Request identity, and for a table list the number of rows it would print. + ***************************************************************************/ + public static class Prepare implements BackendStep + { + /*************************************************************************** + ** Check access early so the form is not shown to someone who may not print. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + PrintingProcesses.currentService(); + String request = input.getValueString(REQUEST_ID); + output.addValue(REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request); + if(TABLE_LIST.equals(input.getProcessName())) + { + QTableMetaData table = table(input); + CountInput count = new CountInput(); + count.setTableName(table.getName()); + count.setFilter(filter(input).clone().withSkip(null).withLimit(null)); + count.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + output.addValue("printingRowCount", new CountAction().execute(count).getCount()); + if(input.getValueString("title") == null) + { + output.addValue("title", table.getLabel() == null ? table.getName() : table.getLabel()); + } + } + } + } + + + + /*************************************************************************** + ** Render and queue. + ***************************************************************************/ + public static class Execute implements BackendStep + { + /*************************************************************************** + ** One PDF job on the chosen printer. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + QRecord printer = PrintingRecordProcesses.printer(input); + int copies = PrintingRecordProcesses.copies(input); + String request = input.getValueString(REQUEST_ID); + if(request == null || request.isBlank()) + { + throw new QException("Restart the print to obtain a safe request identity"); + } + String pageSize = input.getValueString("pageSize") == null ? "LETTER" : input.getValueString("pageSize").toUpperCase(Locale.ROOT); + String title; + byte[] bytes; + if(TABLE_LIST.equals(input.getProcessName())) + { + QTableMetaData table = table(input); + List columns = columns(table, input.getValueString("columns")); + QQueryFilter filter = filter(input); + boolean all = "ALL".equalsIgnoreCase(input.getValueString("scope")); + List records = read(table, filter, all); + List> rows = new ArrayList<>(); + for(QRecord record : records) + { + List row = new ArrayList<>(); + columns.forEach(field -> row.add(PrintingRecordProcesses.text(record, field))); + rows.add(row); + } + title = input.getValueString("title") != null ? input.getValueString("title") : table.getLabel(); + Integer total = input.getValueInteger("printingRowCount"); + int skip = filter.getSkip() == null ? 0 : filter.getSkip(); + String scope = all ? (total != null && total > records.size() ? "first " + records.size() + " of " + total + " rows (print limit " + filter.getLimit() + ")" : "all " + records.size() + " rows") + : "rows " + (records.isEmpty() ? 0 : skip + 1) + "-" + (skip + records.size()) + (total != null ? " of " + total : ""); + String described = input.getValueString("filterDescription"); + String sort = sortText(table, filter); + String subtitle = scope + " - " + (described != null && !described.isBlank() ? "filter " + described : "no filter") + (sort.isEmpty() ? "" : " - sort " + sort); + bytes = PrintingTableDocuments.pdf(title, subtitle, (table.getLabel() == null ? table.getName() : table.getLabel()) + " - " + PrintingRecordProcesses.printedBy(), + columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), rows, widths(input.getValueString("columnWidths")), pageSize, orientation(input)); + if(all && total != null && total > records.size()) + { + output.addValue("printingTruncated", true); + output.addValue("printingMessage", "Printed the first " + records.size() + " of " + total + " rows: the print limit is " + filter.getLimit() + " rows. Narrow the filter to print the rest."); + } + } + else + { + title = input.getValueString("title") == null || input.getValueString("title").isBlank() ? "Screen" : input.getValueString("title"); + String subtitle = input.getValueString("subtitle"); + bytes = PrintingTextDocuments.pdf(title, (subtitle == null || subtitle.isBlank() ? "" : subtitle + " - ") + PrintingRecordProcesses.printedBy(), input.getValueString("text"), pageSize, "LANDSCAPE".equalsIgnoreCase(input.getValueString("orientation"))); + } + String job = PrintingRecordProcesses.queue(printer, title, bytes, copies, "list-print:" + request + ":" + input.getProcessName() + ":" + printer.getValueString("id")); + output.addValue(PrintingRecordProcesses.JOB_IDS, job); + output.addValue("result", "Queued print job " + job + ". Check Print jobs for its status." + (output.getValue("printingMessage") != null ? " " + output.getValue("printingMessage") : "")); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java index 053e8a8..af3f693 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java @@ -173,6 +173,7 @@ public class PrintingQBitProducer implements QBitProducer QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs).withChild(commands); app.withPermissionRules(PrintingProcesses.permissionRules()); PrintingProcesses.register(instance, app); + PrintingListProcesses.register(instance); PrintingDeviceProcesses.register(instance); PrintingUiApi.register(instance); PrintingRetentionProcesses.register(instance, config); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java index 730a3ea..29b7fc6 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java @@ -21,32 +21,25 @@ package com.kingsrook.qbits.printing; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Map; import com.kingsrook.qqq.backend.core.exceptions.QException; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDPageContentStream; -import org.apache.pdfbox.pdmodel.common.PDRectangle; -import org.apache.pdfbox.pdmodel.font.PDType0Font; /******************************************************************************* - ** A plain Letter "record sheet": a heading, a subtitle and one label/value line - ** per field, wrapped and continued across pages. It is the generic document a - ** record print renders when the host supplies no template of its own. + ** A Letter "record sheet": a heading and subtitle, then each section of the + ** record (label/value lines, wrapped and continued across pages), then a + ** summary of each child list (its label, how many rows, and the first rows as + ** a small table). It is the generic document a record print renders when the + ** host supplies no template of its own. *******************************************************************************/ public final class PrintingRecordDocuments { - static final int MAX_LINES = 2000; - private static final float MARGIN = 54; - private static final int TITLE_SIZE = 16; + static final int MAX_LINES = 4000; private static final int BODY_SIZE = 10; - private static final float LEADING = 14; + private static final int CHILD_SIZE = 8; @@ -60,60 +53,91 @@ public final class PrintingRecordDocuments /*************************************************************************** - ** Render one record as a PDF. Field order is the map's order; values are the - ** display text the caller already formatted (null values print blank). + ** One section: a label (blank for none) and label/value lines in order. + ***************************************************************************/ + public record Section(String label, Map fields) + { + } + + + + /*************************************************************************** + ** One child list: its label, total row count, column headings and the + ** first rows (display text). + ***************************************************************************/ + public record ChildList(String label, int total, List columns, List> rows) + { + } + + + + /*************************************************************************** + ** One section without children (the original record sheet). ***************************************************************************/ public static byte[] pdf(String title, String subtitle, Map fields) throws QException { - try(PDDocument document = new PDDocument(); InputStream fontStream = fontStream()) + return (pdf(title, subtitle, List.of(new Section("", fields)), List.of())); + } + + + + /*************************************************************************** + ** Render sections and child list summaries as a PDF. + ***************************************************************************/ + public static byte[] pdf(String title, String subtitle, List
sections, List children) throws QException + { + try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle)) { - PDType0Font font = PDType0Font.load(document, fontStream, true); - float width = PDRectangle.LETTER.getWidth() - 2 * MARGIN; - int columns = Math.max(20, (int) (width / (font.getStringWidth("0") / 1000 * BODY_SIZE))); - int labelWidth = Math.min(28, Math.max(8, fields.keySet().stream().mapToInt(String::length).max().orElse(8) + 1)); - List lines = new ArrayList<>(); - for(Map.Entry field : fields.entrySet()) + int columns = layout.columns(BODY_SIZE); + int labelWidth = Math.min(28, Math.max(8, sections.stream().flatMap(s -> s.fields().keySet().stream()).mapToInt(String::length).max().orElse(8) + 1)); + int lines = 0; + for(Section section : sections) { - List wrapped = wrap(field.getValue() == null ? "" : field.getValue(), columns - labelWidth - 1); - for(int i = 0; i < wrapped.size() && lines.size() < MAX_LINES; i++) + if(section.fields().isEmpty()) { - String label = i == 0 ? truncate(field.getKey(), labelWidth - 1) : ""; - lines.add(String.format("%-" + labelWidth + "s %s", label, wrapped.get(i))); + continue; } - } - int index = 0; - int pageNumber = 0; - do - { - pageNumber++; - PDPage page = new PDPage(PDRectangle.LETTER); - document.addPage(page); - try(PDPageContentStream content = new PDPageContentStream(document, page)) + if(section.label() != null && !section.label().isBlank()) { - float y = PDRectangle.LETTER.getHeight() - MARGIN; - text(content, font, TITLE_SIZE, MARGIN, y, truncate(title, (int) (width / (font.getStringWidth("0") / 1000 * TITLE_SIZE)))); - y -= LEADING + 6; - text(content, font, BODY_SIZE, MARGIN, y, truncate(subtitle + (pageNumber > 1 ? " (continued, page " + pageNumber + ")" : ""), columns)); - y -= 6; - content.moveTo(MARGIN, y); - content.lineTo(MARGIN + width, y); - content.stroke(); - y -= LEADING + 4; - while(index < lines.size() && y > MARGIN) + layout.gap(4); + layout.keep(3, BODY_SIZE); + layout.line(section.label(), BODY_SIZE + 1); + } + for(Map.Entry field : section.fields().entrySet()) + { + List wrapped = wrap(field.getValue() == null ? "" : field.getValue(), columns - labelWidth - 1); + for(int i = 0; i < wrapped.size() && lines++ < MAX_LINES; i++) { - text(content, font, BODY_SIZE, MARGIN, y, lines.get(index++)); - y -= LEADING; + layout.line(String.format("%-" + labelWidth + "s %s", i == 0 ? truncate(field.getKey(), labelWidth - 1) : "", wrapped.get(i)), BODY_SIZE); } } } - while(index < lines.size()); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - document.save(output); - return (output.toByteArray()); + for(ChildList child : children) + { + layout.gap(8); + layout.keep(4, CHILD_SIZE); + layout.line(child.label() + " (" + child.total() + ")", BODY_SIZE + 1); + if(child.rows().isEmpty()) + { + layout.line("(none)", CHILD_SIZE); + continue; + } + int[] widths = PrintingLayout.columnWidths(child.columns(), child.rows(), layout.columns(CHILD_SIZE)); + layout.line(PrintingLayout.row(child.columns(), widths), CHILD_SIZE); + for(List row : child.rows()) + { + layout.line(PrintingLayout.row(row, widths), CHILD_SIZE); + } + if(child.total() > child.rows().size()) + { + layout.line("... and " + (child.total() - child.rows().size()) + " more", CHILD_SIZE); + } + } + return (layout.finish()); } catch(IOException exception) { - throw new QException("Could not render the record document", exception); + throw new QException("Could not render the record document: " + exception.getMessage(), exception); } } @@ -159,47 +183,4 @@ public final class PrintingRecordDocuments String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' '); return (value.length() <= length ? value : value.substring(0, Math.max(0, length - 1)) + "~"); } - - - - /*************************************************************************** - ** Draw one line, replacing characters the bundled font cannot encode. - ***************************************************************************/ - private static void text(PDPageContentStream content, PDType0Font font, int size, float x, float y, String text) throws IOException - { - StringBuilder safe = new StringBuilder(); - text.codePoints().forEach(codePoint -> - { - String character = new String(Character.toChars(codePoint)); - try - { - font.encode(character); - safe.append(character); - } - catch(IllegalArgumentException | IOException exception) - { - safe.append('?'); - } - }); - content.beginText(); - content.setFont(font, size); - content.newLineAtOffset(x, y); - content.showText(safe.toString()); - content.endText(); - } - - - - /*************************************************************************** - ** The licensed font shipped for the diagnostic documents. - ***************************************************************************/ - private static InputStream fontStream() throws IOException - { - InputStream stream = PrintingRecordDocuments.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE); - if(stream == null) - { - throw new IOException("Bundled document font is missing"); - } - return (stream); - } } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java index 3824f89..4ed48dd 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java @@ -81,6 +81,7 @@ public final class PrintingRecordProcesses public static final String COPIES_FIELD = "printingCopies"; public static final String JOB_IDS = "printingJobIds"; static final String REQUEST_ID = "printingRecordRequestId"; + static final int CHILD_ROWS = 10; @@ -131,6 +132,33 @@ public final class PrintingRecordProcesses + /*************************************************************************** + ** Add Print to every table of the instance that can be read one record at + ** a time: skips hidden tables, tables without a primary key or without + ** TABLE_GET, tables that already have Print, and tables whose + ** PrintingTableSettings say printable false. Call it after every table is + ** in the instance. Returns the tables that got Print. + ***************************************************************************/ + public static List registerAll(QInstance instance) throws QException + { + List added = new ArrayList<>(); + for(QTableMetaData table : new ArrayList<>(instance.getTables().values())) + { + PrintingTableSettings settings = PrintingTableSettings.of(table); + boolean readable = table.isCapabilityEnabled(instance.getBackendForTable(table.getName()), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_GET); + if(Boolean.TRUE.equals(table.getIsHidden()) || table.getPrimaryKeyField() == null || !readable + || (settings != null && !settings.getPrintable()) || instance.getProcess(processName(table.getName())) != null) + { + continue; + } + register(instance, table.getName()); + added.add(table.getName()); + } + return (added); + } + + + /*************************************************************************** ** Only approved printers that the device currently exposes and that take PDF. ***************************************************************************/ @@ -202,29 +230,164 @@ public final class PrintingRecordProcesses { throw new QException("Record " + primaryKey + " of " + table.getLabel() + " is not accessible"); } - Map fields = new LinkedHashMap<>(); - for(QFieldMetaData field : table.getFields().values()) - { - if(Boolean.TRUE.equals(field.getIsHidden()) || field.getType() == QFieldType.BLOB) - { - continue; - } - String display = record.getDisplayValue(field.getName()); - Serializable raw = record.getValue(field.getName()); - fields.put(field.getLabel() == null ? field.getName() : field.getLabel(), display != null ? display : raw == null ? null : String.valueOf(raw)); - } String label = record.getRecordLabel() != null ? record.getRecordLabel() : String.valueOf(primaryKey); String tableLabel = table.getLabel() == null ? table.getName() : table.getLabel(); + String subtitle = tableLabel + " " + primaryKey + " - " + printedBy(); + byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, sections(table, record), children(table, record)); + return (queue(printer, tableLabel + ": " + label, bytes, copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id"))); + } + + + + /*************************************************************************** + ** "printed by " for subtitles. + ***************************************************************************/ + static String printedBy() + { String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getFullName(); - String subtitle = tableLabel + " " + primaryKey + " - printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user); - byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, fields); + return ("printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user)); + } + + + + /*************************************************************************** + ** Display text of one field of a record (display value, else raw value). + ***************************************************************************/ + static String text(QRecord record, QFieldMetaData field) + { + String display = record.getDisplayValue(field.getName()); + Serializable raw = record.getValue(field.getName()); + return (display != null ? display : raw == null ? null : String.valueOf(raw)); + } + + + + /*************************************************************************** + ** Whether a field belongs on paper: visible and not binary. + ***************************************************************************/ + static boolean printable(QFieldMetaData field) + { + return (field != null && !Boolean.TRUE.equals(field.getIsHidden()) && field.getType() != QFieldType.BLOB); + } + + + + /*************************************************************************** + ** The record's field sections in tier order (T1, T2, T3), as the record + ** view shows them; fields in no section follow under "Other fields". A + ** table without sections prints one unlabeled section of every field. + ***************************************************************************/ + static List sections(QTableMetaData table, QRecord record) + { + List out = new ArrayList<>(); + java.util.Set placed = new java.util.HashSet<>(); + List sections = new ArrayList<>(table.getSections() == null ? List.of() : table.getSections()); + sections.removeIf(section -> Boolean.TRUE.equals(section.getIsHidden()) || section.getFieldNames() == null || section.getFieldNames().isEmpty()); + sections.sort(java.util.Comparator.comparing(section -> section.getTier() == null ? 9 : section.getTier().ordinal())); + for(var section : sections) + { + Map fields = new LinkedHashMap<>(); + for(String name : section.getFieldNames()) + { + QFieldMetaData field = table.getFields().get(name); + if(printable(field) && placed.add(name)) + { + fields.put(field.getLabel() == null ? name : field.getLabel(), text(record, field)); + } + } + out.add(new PrintingRecordDocuments.Section(sections.size() == 1 ? "" : section.getLabel(), fields)); + } + Map rest = new LinkedHashMap<>(); + for(QFieldMetaData field : table.getFields().values()) + { + if(printable(field) && !placed.contains(field.getName())) + { + rest.put(field.getLabel() == null ? field.getName() : field.getLabel(), text(record, field)); + } + } + if(!rest.isEmpty()) + { + out.add(new PrintingRecordDocuments.Section(out.isEmpty() ? "" : "Other fields", rest)); + } + return (out); + } + + + + /*************************************************************************** + ** Each association of the table: its label, how many child rows, and the + ** first rows with up to five visible columns. Children the user may not + ** read, or that fail to load, are left out rather than failing the print. + ***************************************************************************/ + static List children(QTableMetaData table, QRecord record) + { + List out = new ArrayList<>(); + for(var association : table.getAssociations() == null ? List.of() : table.getAssociations()) + { + try + { + var join = QContext.getQInstance().getJoin(association.getJoinName()); + QTableMetaData child = QContext.getQInstance().getTable(association.getAssociatedTableName()); + if(join == null || child == null || join.getJoinOns() == null || join.getJoinOns().isEmpty()) + { + continue; + } + boolean forward = table.getName().equals(join.getLeftTable()); + QQueryFilter filter = new QQueryFilter(); + for(var on : join.getJoinOns()) + { + Serializable value = record.getValue(forward ? on.getLeftField() : on.getRightField()); + filter.withCriteria(new QFilterCriteria(forward ? on.getRightField() : on.getLeftField(), QCriteriaOperator.EQUALS, value)); + } + var count = new com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput(); + count.setTableName(child.getName()); + count.setFilter(filter); + count.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + Integer total = new com.kingsrook.qqq.backend.core.actions.tables.CountAction().execute(count).getCount(); + var query = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(); + query.setTableName(child.getName()); + query.setFilter(filter.clone().withLimit(CHILD_ROWS)); + query.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + query.setShouldGenerateDisplayValues(true); + query.setShouldTranslatePossibleValues(true); + List rows = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query).getRecords(); + java.util.Set joinFields = new java.util.HashSet<>(); + join.getJoinOns().forEach(on -> joinFields.add(forward ? on.getRightField() : on.getLeftField())); + List columns = child.getFields().values().stream() + .filter(field -> printable(field) && !joinFields.contains(field.getName())).limit(5).toList(); + List> cells = new ArrayList<>(); + for(QRecord row : rows) + { + List line = new ArrayList<>(); + columns.forEach(field -> line.add(text(row, field))); + cells.add(line); + } + String label = association.getName().equals(child.getName()) && child.getLabel() != null ? child.getLabel() : com.kingsrook.qqq.backend.core.instances.QInstanceEnricher.nameToLabel(association.getName()); + out.add(new PrintingRecordDocuments.ChildList(label, total == null ? rows.size() : total, + columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), cells)); + } + catch(Exception exception) + { + // a child list the user may not read (or that cannot load) is left out of the sheet + } + } + return (out); + } + + + + /*************************************************************************** + ** Queue one PDF on a printer through the durable queue; returns the job id. + ***************************************************************************/ + static String queue(QRecord printer, String title, byte[] bytes, int copies, String idempotencyKey) throws QException + { try { JSONObject job = PrintingProcesses.currentService().submit(new JSONObject() .put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id")) - .put("title", PrintingRecordDocuments.truncate(tableLabel + ": " + label, 200)).put("format", "PDF") + .put("title", PrintingRecordDocuments.truncate(title, 200)).put("format", "PDF") .put("contentBase64", Base64.getEncoder().encodeToString(bytes)) - .put("idempotencyKey", "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id")) + .put("idempotencyKey", idempotencyKey) .put("options", new JSONObject().put("copies", copies).put("scale", "FIT"))); return (job.getString("jobId")); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java new file mode 100644 index 0000000..2a45b56 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java @@ -0,0 +1,124 @@ +/* + * 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.io.IOException; +import java.util.List; +import com.kingsrook.qqq.backend.core.exceptions.QException; + + +/******************************************************************************* + ** A table list: a grid of rows under a repeated column header, in fixed-width + ** monospace columns sized from the content and shrunk to fit the page. + *******************************************************************************/ +public final class PrintingTableDocuments +{ + static final int FONT_SIZE = 8; + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private PrintingTableDocuments() + { + } + + + + /*************************************************************************** + ** Render columns and rows (display text) on LETTER or A4, portrait or + ** landscape, without preferred widths or a footer. + ***************************************************************************/ + public static byte[] pdf(String title, String subtitle, List columns, List> rows, String pageSize, boolean landscape) throws QException + { + return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT")); + } + + + + /*************************************************************************** + ** Render a table list. Preferred widths (the grid's, in characters) are + ** the starting column widths, else the content decides; either way they + ** shrink to fit the page. Orientation AUTO turns the page landscape when + ** the columns do not fit portrait. The header repeats on every page; the + ** footer text goes at the bottom left of every page. + ***************************************************************************/ + public static byte[] pdf(String title, String subtitle, String footer, List columns, List> rows, int[] preferredWidths, String pageSize, String orientation) throws QException + { + if(columns.isEmpty()) + { + throw new QException("A table list needs at least one column"); + } + int[] natural = PrintingLayout.naturalWidths(columns, rows); + if(preferredWidths != null && preferredWidths.length == columns.size()) + { + for(int c = 0; c < natural.length; c++) + { + natural[c] = Math.max(4, Math.min(60, preferredWidths[c])); + } + } + boolean landscape = isLandscape(orientation, natural, pageSize); + try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle)) + { + layout.footer(footer); + int[] widths = PrintingLayout.fit(natural, layout.columns(FONT_SIZE)); + String header = PrintingLayout.row(columns, widths); + String rule = "-".repeat(Math.min(layout.columns(FONT_SIZE), Math.max(1, header.length()))); + layout.line(header, FONT_SIZE); + layout.line(rule, FONT_SIZE); + layout.repeatOnNewPages(List.of(header, rule), FONT_SIZE); + for(List row : rows) + { + layout.line(PrintingLayout.row(row, widths), FONT_SIZE); + } + layout.repeatOnNewPages(List.of(), FONT_SIZE); + layout.gap(4); + layout.line(rows.size() + (rows.size() == 1 ? " row" : " rows"), FONT_SIZE); + return (layout.finish()); + } + catch(IOException exception) + { + throw new QException("Could not render the table list: " + exception.getMessage(), exception); + } + } + + + + /*************************************************************************** + ** PORTRAIT, LANDSCAPE, or AUTO: landscape when the natural widths do not + ** fit across a portrait page. + ***************************************************************************/ + static boolean isLandscape(String orientation, int[] natural, String pageSize) + { + if("LANDSCAPE".equalsIgnoreCase(orientation)) + { + return (true); + } + if(!"AUTO".equalsIgnoreCase(orientation)) + { + return (false); + } + float width = ("A4".equalsIgnoreCase(pageSize) ? 595.28f : 612f) - 2 * PrintingLayout.MARGIN; + return (PrintingLayout.lineWidth(natural) > (int) (width / (0.6f * FONT_SIZE))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTableSettings.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTableSettings.java new file mode 100644 index 0000000..914ed81 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTableSettings.java @@ -0,0 +1,91 @@ +/* + * 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 com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** Per-table printing metadata. A table carrying it with printable false gets + ** no Print process from PrintingRecordProcesses.registerAll: + ** {@code table.withSupplementalMetaData(new PrintingTableSettings().withPrintable(false))}. + *******************************************************************************/ +public class PrintingTableSettings extends QSupplementalTableMetaData +{ + public static final String TYPE = "printing"; + + private boolean printable = true; + + + + /*************************************************************************** + ** The settings of a table, or null when it has none. + ***************************************************************************/ + public static PrintingTableSettings of(QTableMetaData table) + { + return (table.getSupplementalMetaData() == null ? null : (PrintingTableSettings) table.getSupplementalMetaData(TYPE)); + } + + + + /*************************************************************************** + ** Supplemental metadata type. + ***************************************************************************/ + @Override + public String getType() + { + return (TYPE); + } + + + + /*************************************************************************** + ** Whether the table's records and lists may be printed (default true). + ***************************************************************************/ + public boolean getPrintable() + { + return (printable); + } + + + + /*************************************************************************** + ** Fluent setter. + ***************************************************************************/ + public PrintingTableSettings withPrintable(boolean value) + { + printable = value; + return (this); + } + + + + /*************************************************************************** + ** No mutable references beyond the flag, which the base clone copies. + ***************************************************************************/ + @Override + protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone) + { + return (clone); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java new file mode 100644 index 0000000..5e2f83a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java @@ -0,0 +1,102 @@ +/* + * 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.io.IOException; +import com.kingsrook.qqq.backend.core.exceptions.QException; + + +/******************************************************************************* + ** Plain text as a PDF: monospace, long lines hard-wrapped, broken across + ** pages. Used to print a capture of a terminal screen (or any text). + *******************************************************************************/ +public final class PrintingTextDocuments +{ + static final int FONT_SIZE = 8; + static final float MIN_FONT_SIZE = 5.5f; + static final int MAX_CHARACTERS = 2_000_000; + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private PrintingTextDocuments() + { + } + + + + /*************************************************************************** + ** Render text on LETTER or A4, portrait or landscape. + ***************************************************************************/ + public static byte[] pdf(String title, String subtitle, String text, String pageSize, boolean landscape) throws QException + { + if(text == null || text.isEmpty()) + { + throw new QException("There is no text to print"); + } + if(text.length() > MAX_CHARACTERS) + { + throw new QException("The text is too long to print (at most " + MAX_CHARACTERS + " characters)"); + } + try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle)) + { + String[] lines = text.replace("\r\n", "\n").replace('\r', '\n').replace("\t", " ").split("\n", -1); + float size = fontSize(lines, layout.columns(1) * 1f); + int width = layout.columns(size); + layout.leading(1.2f); + for(String line : lines) + { + String rest = line.stripTrailing(); + do + { + layout.line(rest.length() > width ? rest.substring(0, width) : rest, size); + rest = rest.length() > width ? rest.substring(width) : ""; + } + while(!rest.isEmpty()); + } + return (layout.finish()); + } + catch(IOException exception) + { + throw new QException("Could not render the text: " + exception.getMessage(), exception); + } + } + + + + /*************************************************************************** + ** The font size (8 down to 5.5 points) at which the widest line fits, so a + ** screen capture keeps its layout; wider text still wraps at 5.5. + ***************************************************************************/ + static float fontSize(String[] lines, float charactersAtOnePoint) + { + int widest = 1; + for(String line : lines) + { + widest = Math.max(widest, line.stripTrailing().length()); + } + float fits = charactersAtOnePoint / widest; + return (Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java index 8fbbdce..a5ed708 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java @@ -49,11 +49,11 @@ public final class PrintingUiApi MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension()); } - /** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims. */ + /** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes. */ public static MiddlewareSpecExtension extension() { - return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.0.0") - .withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1.") + return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.1.0") + .withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes.") .withSpec(Session.class) .withSpec(Bind.class) .withSpec(Enroll.class) diff --git a/src/main/resources/openapi/qqq-v1/printing.yaml b/src/main/resources/openapi/qqq-v1/printing.yaml index aea1287..33ce14d 100644 --- a/src/main/resources/openapi/qqq-v1/printing.yaml +++ b/src/main/resources/openapi/qqq-v1/printing.yaml @@ -605,9 +605,9 @@ info: contact: email: "contact@kingsrook.com" description: "Native current-user printing with secondary device proof, device-control-v1\ - \ and realtime-v1." + \ and realtime-v1; record, table-list and text printing processes." title: "QQQ Middleware API extension: printing" - version: "2.0.0" + version: "2.1.0" openapi: "3.0.3" paths: /qqq/v1/qbit/printing/session: diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java new file mode 100644 index 0000000..c716a82 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java @@ -0,0 +1,357 @@ +/* + * 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.io.Serializable; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Association; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.json.JSONArray; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Print anything: Print on every table (registerAll, opt-out), the metadata + ** record sheet with sections and child lists, the table list and text prints. + *******************************************************************************/ +class PrintingListProcessesTest +{ + /*************************************************************************** + ** Clear the memory store and context. + ***************************************************************************/ + @AfterEach + void clear() + { + MemoryRecordStore.getInstance().reset(); + QContext.clear(); + } + + + + /*************************************************************************** + ** registerAll adds Print to readable tables and honors the opt-out. + ***************************************************************************/ + @Test + void registerAllAddsPrintToEveryTableButHiddenAndOptedOut() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixture(); + List added = PrintingRecordProcesses.registerAll(fixture.instance()); + assertTrue(added.contains("pet"), added::toString); + assertFalse(added.contains("person"), "person already had Print"); + assertFalse(added.contains("hiddenThing")); + assertFalse(added.contains("secretThing")); + assertTrue(fixture.instance().getProcess("printingPrintRecords_pet") != null); + assertTrue(fixture.instance().getProcess("printingPrintRecords_secretThing") == null); + assertEquals(List.of(), PrintingRecordProcesses.registerAll(fixture.instance()), "a second call adds nothing"); + assertTrue(fixture.instance().getProcess(PrintingListProcesses.TABLE_LIST) != null); + assertTrue(fixture.instance().getProcess(PrintingListProcesses.TEXT) != null); + } + + + + /*************************************************************************** + ** The record sheet follows the sections in tier order and summarizes child lists. + ***************************************************************************/ + @Test + void recordSheetHasSectionsAndChildLists() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixture(); + String process = PrintingRecordProcesses.processName("person"); + var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withValue("recordIds", "1")); + var review = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID()) + .withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())); + new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(review.getProcessUUID()) + .withStartAfterStep(review.getProcessState().getNextStepName().orElseThrow()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)); + String text = jobText(fixture, 0); + assertTrue(text.indexOf("Identity") < text.indexOf("Contact"), text); + assertTrue(text.contains("Email"), text); + assertTrue(text.contains("ada@example.test"), text); + assertTrue(text.contains("Other fields"), text); + assertTrue(text.contains("Pets (2)"), text); + assertTrue(text.contains("Rex") && text.contains("Tom"), text); + assertFalse(text.contains("Secret"), text); + } + + + + /*************************************************************************** + ** All rows, in the given column order and sort, with the count. + ***************************************************************************/ + @Test + void tableListOfAllRowsInColumnOrder() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixture(); + RunProcessOutput output = run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "columns", "lastName,firstName", "scope", "ALL", + "filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"lastName\",\"isAscending\":true}]}", "orientation", "PORTRAIT", + PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())); + assertEquals(1, String.valueOf(output.getValues().get(PrintingRecordProcesses.JOB_IDS)).split(",").length); + String text = jobText(fixture, 0); + assertTrue(text.contains("Last name"), text); + assertTrue(text.indexOf("Last name") < text.indexOf("First name"), text); + assertTrue(text.indexOf("Hopper") < text.indexOf("Lovelace"), text); + assertTrue(text.contains("all 2 rows"), text); + assertTrue(text.contains("sort Last name"), text); + assertTrue(text.contains("Person - printed "), "the footer names the table, time and user"); + assertTrue(text.contains("2 rows"), text); + assertTrue(fixture.service().jobs().getJSONObject(0).getString("title").startsWith("Person")); + } + + + + /*************************************************************************** + ** The current page only: the filter's skip and limit. + ***************************************************************************/ + @Test + void tableListOfThePageOnly() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixture(); + run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "scope", "PAGE", "title", "People page 2", + "filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":true}],\"skip\":1,\"limit\":1}", + PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())); + String text = jobText(fixture, 0); + assertTrue(text.contains("People page 2"), text); + assertTrue(text.contains("rows 2-2 of 2"), text); + assertTrue(text.contains("Grace") && !text.contains("Lovelace"), text); + assertTrue(text.contains("Email"), "default columns are the visible fields"); + assertFalse(text.contains("Secret"), text); + } + + + + /*************************************************************************** + ** All rows of a result larger than the limit: the first rows, read a page + ** at a time, with a message saying how many were left out; the grid's + ** widths are used and AUTO turns wide lists landscape. + ***************************************************************************/ + @Test + void tableListOfAllRowsBeyondTheLimitSaysSo() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixture(); + List more = new java.util.ArrayList<>(); + for(int i = 3; i <= 2500; i++) + { + more.add(new QRecord().withValue("id", i).withValue("firstName", "First" + i).withValue("lastName", "Last" + i).withValue("email", "p" + i + "@example.test").withValue("note", "note " + i)); + } + new InsertAction().execute(new InsertInput("person").withRecords(more)); + RunProcessOutput output = run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "scope", "ALL", "maxRows", 2100, + "columnWidths", "4,40,40,40,40", "filterDescription", "id > 0", PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())); + assertEquals(true, output.getValues().get("printingTruncated")); + assertTrue(String.valueOf(output.getValues().get("printingMessage")).contains("first 2100 of 2500 rows"), () -> String.valueOf(output.getValues())); + String job = fixture.service().jobs().getJSONObject(0).getString("jobId"); + fixture.service().claimForJob(fixture.device(), job); + try(PDDocument document = PDDocument.load(fixture.service().content(fixture.device(), job))) + { + assertTrue(document.getPage(0).getMediaBox().getWidth() > document.getPage(0).getMediaBox().getHeight(), "AUTO chose landscape"); + assertTrue(document.getNumberOfPages() > 30, "pages: " + document.getNumberOfPages()); + String text = new PDFTextStripper().getText(document); + assertTrue(text.contains("first 2100 of 2500 rows (print limit 2100)"), text.substring(0, 400)); + assertTrue(text.contains("filter id > 0")); + assertTrue(text.contains("2100 rows")); + assertTrue(text.contains("First2100") && !text.contains("First2101 "), "rows stop at the limit"); + PDFTextStripper last = new PDFTextStripper(); + last.setStartPage(document.getNumberOfPages()); + String lastPage = last.getText(document); + assertTrue(lastPage.contains("Id") && lastPage.contains("First name"), "the header repeats on every page"); + } + } + + + + /*************************************************************************** + ** Unknown columns, opted-out tables and too many rows are refused. + ***************************************************************************/ + @Test + void tableListRefusals() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixture(); + assertThrows(QException.class, () -> run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "columns", "nope", PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()))); + assertThrows(QException.class, () -> run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "secretThing", PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()))); + assertThrows(QException.class, () -> run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "scope", "ALL", "maxRows", 999999, PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()))); + assertEquals(0, fixture.service().jobs().length()); + } + + + + /*************************************************************************** + ** Text prints monospace, wraps long lines and breaks pages. + ***************************************************************************/ + @Test + void textPrintsAScreenCapture() throws Exception + { + PrintingProcessesTest.Fixture fixture = fixture(); + StringBuilder screen = new StringBuilder("+-- Dashboard --+\n| sales 1,234 |\n" + "x".repeat(300) + "\n"); + for(int i = 0; i < 150; i++) + { + screen.append("line ").append(i).append('\n'); + } + run(PrintingListProcesses.TEXT, Map.of("title", "Dashboard", "subtitle", "qqq ui", "text", screen.toString(), PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())); + JSONArray jobs = fixture.service().jobs(); + assertEquals("Dashboard", jobs.getJSONObject(0).getString("title")); + fixture.service().claimForJob(fixture.device(), jobs.getJSONObject(0).getString("jobId")); + try(PDDocument document = PDDocument.load(fixture.service().content(fixture.device(), jobs.getJSONObject(0).getString("jobId")))) + { + assertTrue(document.getNumberOfPages() > 1); + String text = new PDFTextStripper().getText(document); + assertTrue(text.contains("| sales 1,234 |"), text); + assertTrue(text.contains("line 149"), text); + } + assertThrows(QException.class, () -> run(PrintingListProcesses.TEXT, Map.of("text", "", PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()))); + } + + + + /*************************************************************************** + ** A 150-column terminal capture keeps its lines on a landscape page (the + ** font shrinks to fit); much wider text still wraps at the smallest size. + ***************************************************************************/ + @Test + void wideScreenCaptureFitsWithoutWrapping() throws Exception + { + String wide = "| " + "y".repeat(146) + " |"; + try(PDDocument document = PDDocument.load(PrintingTextDocuments.pdf("Screen", "", wide + "\n" + wide, "LETTER", true))) + { + assertTrue(new PDFTextStripper().getText(document).contains(wide)); + } + assertEquals(8f, PrintingTextDocuments.fontSize(new String[] {"short"}, 1000), 0.01); + assertEquals(PrintingTextDocuments.MIN_FONT_SIZE, PrintingTextDocuments.fontSize(new String[] {"z".repeat(5000)}, 1000), 0.01); + } + + + + /*************************************************************************** + ** Column widths shrink widest-first to fit, never below four. + ***************************************************************************/ + @Test + void columnWidthsFitTheLine() + { + int[] widths = PrintingLayout.columnWidths(List.of("Id", "Name", "Notes"), List.of(List.of("1", "Ada", "x".repeat(200))), 40); + assertTrue(widths[0] + widths[1] + widths[2] + 4 <= 40); + assertEquals(4, widths[0]); + assertArrayEquals(new int[] {4, 4}, PrintingLayout.columnWidths(List.of("A", "B"), List.of(), 3)); + assertEquals("1 Ada", PrintingLayout.row(List.of("1", "Ada"), new int[] {4, 4})); + } + + + + /*************************************************************************** + ** Run a process straight through with values. + ***************************************************************************/ + private static RunProcessOutput run(String process, Map values) throws QException + { + RunProcessInput input = new RunProcessInput().withProcessName(process).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + // as the UI API runs it: user input, so server-configured inputs would be refused + input.setInputSource(com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource.USER); + values.forEach(input::withValue); + RunProcessOutput output = new RunProcessAction().execute(input); + if(output.getException().isPresent()) + { + throw new QException(output.getException().get()); + } + return (output); + } + + + + /*************************************************************************** + ** The text of the n-th newest job's PDF. + ***************************************************************************/ + private static String jobText(PrintingProcessesTest.Fixture fixture, int index) throws Exception + { + String job = fixture.service().jobs().getJSONObject(index).getString("jobId"); + fixture.service().claimForJob(fixture.device(), job); + try(PDDocument document = PDDocument.load(fixture.service().content(fixture.device(), job))) + { + return (new PDFTextStripper().getText(document)); + } + } + + + + /*************************************************************************** + ** The process fixture plus person (sections, pets), pet, a hidden and an + ** opted-out table, on the memory backend. + ***************************************************************************/ + private static PrintingProcessesTest.Fixture fixture() throws Exception + { + PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture(); + QInstance instance = fixture.instance(); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + instance.addTable(new QTableMetaData().withName("person").withLabel("Person").withBackendName("memory").withPrimaryKeyField("id") + .withRecordLabelFormat("%s %s").withRecordLabelFields(List.of("firstName", "lastName")) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("Id")) + .withField(new QFieldMetaData("firstName", QFieldType.STRING).withLabel("First name")) + .withField(new QFieldMetaData("lastName", QFieldType.STRING).withLabel("Last name")) + .withField(new QFieldMetaData("email", QFieldType.STRING).withLabel("Email")) + .withField(new QFieldMetaData("note", QFieldType.STRING).withLabel("Note")) + .withField(new QFieldMetaData("secret", QFieldType.STRING).withLabel("Secret").withIsHidden(true)) + .withSection(new QFieldSection("contact", "Contact", null, Tier.T2, List.of("email"))) + .withSection(new QFieldSection("identity", "Identity", null, Tier.T1, List.of("id", "firstName", "lastName"))) + .withAssociation(new Association().withName("pets").withAssociatedTableName("pet").withJoinName("personPet"))); + instance.addTable(new QTableMetaData().withName("pet").withLabel("Pet").withBackendName("memory").withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("Id")) + .withField(new QFieldMetaData("personId", QFieldType.INTEGER).withLabel("Owner")) + .withField(new QFieldMetaData("name", QFieldType.STRING).withLabel("Name"))); + instance.addJoin(new QJoinMetaData().withName("personPet").withLeftTable("person").withRightTable("pet").withType(JoinType.ONE_TO_MANY).withJoinOn(new JoinOn("id", "personId"))); + instance.addTable(new QTableMetaData().withName("hiddenThing").withBackendName("memory").withPrimaryKeyField("id").withIsHidden(true) + .withField(new QFieldMetaData("id", QFieldType.INTEGER))); + instance.addTable(new QTableMetaData().withName("secretThing").withLabel("Secret thing").withBackendName("memory").withPrimaryKeyField("id") + .withSupplementalMetaData(new PrintingTableSettings().withPrintable(false)) + .withField(new QFieldMetaData("id", QFieldType.INTEGER))); + PrintingRecordProcesses.register(instance, "person"); + new InsertAction().execute(new InsertInput("person").withRecords(List.of( + new QRecord().withValue("id", 1).withValue("firstName", "Ada").withValue("lastName", "Lovelace").withValue("email", "ada@example.test").withValue("note", "n").withValue("secret", "x"), + new QRecord().withValue("id", 2).withValue("firstName", "Grace").withValue("lastName", "Hopper").withValue("email", "grace@example.test")))); + new InsertAction().execute(new InsertInput("pet").withRecords(List.of( + new QRecord().withValue("id", 1).withValue("personId", 1).withValue("name", "Rex"), + new QRecord().withValue("id", 2).withValue("personId", 1).withValue("name", "Tom"), + new QRecord().withValue("id", 3).withValue("personId", 2).withValue("name", "Kit")))); + return (fixture); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRecordProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRecordProcessesTest.java index 7d61618..37fb1dc 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingRecordProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRecordProcessesTest.java @@ -209,7 +209,8 @@ class PrintingRecordProcessesTest assertTrue(document.getNumberOfPages() > 1); String text = new PDFTextStripper().getText(document); assertTrue(text.contains("Field 79")); - assertTrue(text.contains("continued, page 2")); + assertTrue(text.contains("(continued)")); + assertTrue(text.contains("Page 2 of ")); } assertEquals(List.of("abc", "def"), PrintingRecordDocuments.wrap("abc def", 4)); assertEquals(List.of("abcd", "ef"), PrintingRecordDocuments.wrap("abcdef", 4)); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java index 9b3b6f2..c31bef0 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java @@ -61,7 +61,7 @@ class PrintingUiApiContractTest var model = version.generateExtensionOpenAPIModel("qqq", extension); assertEquals(17, model.getPaths().size()); assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/"))); - assertEquals("2.0.0", extension.getSemanticVersion()); + assertEquals("2.1.0", extension.getSemanticVersion()); assertTrue(generated.contains("X-QQQ-Device-Token")); assertTrue(generated.contains("application/octet-stream")); assertTrue(generated.contains("principalId")); -- 2.54.0 From 3592decba8087439aff5d1b75a9726c72f460cb9 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 10:12:08 -0600 Subject: [PATCH 31/35] feat(dev): add isolated Docker printing and provider qualification (#21) * feat(dev): add isolated Docker printing and provider qualification Provide owned local QQQ, MinIO and optional Authentik development fixtures with pinned inputs, protected synthetic state and ownership-checked cleanup. Exercise real Core/Java WebSocket jobs, S3 retention/isolation and opt-in native PKCE/provider renewal and revocation. Add cross-platform script gates and hosted Docker smoke CI. Include the opt-in 10000-job pooled-SQL/reopen regression and scoped evidence. The experimental API1.9 source remains explicitly unreleased and separate from the released runtime dependency set. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes * fix(dev): report early workload failures before server waiting Preserve bounded client diagnostics privately and emit only allowlisted stage and exception classifications. Early failed clients no longer hide behind the server completion timeout; ownership-checked cleanup remains in finally. Five actual-entrypoint regression cases pass; all33script tests and parser/analyzer checks pass. This exposes the still-unknown hosted client failure without changing production behavior. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes * fix(dev): accept compatible realtime printing API minor versions Use production Core discovery's strict stable SemVer validation and the realtime major2 compatibility gate instead of requiring exactly2.0.0. Protected main now advertises compatible2.1.0; the old fixture rejected it before login. Pinned-Core build reproduced2.1.0 rejection before the fix and passes all9positive/negative HTTP discovery cases after it. Docker builds run these checks. No production client, server, dependency or runtime change. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --------- Signed-off-by: James Maes Co-authored-by: Codex (GPT-6) --- .github/workflows/development.yml | 64 +++ README.md | 6 + dev/AUTHENTIK.md | 115 ++++ dev/Dockerfile.client | 16 + dev/Dockerfile.server | 23 + dev/Invoke-Authentik.ps1 | 394 ++++++++++++++ dev/Invoke-Dev.ps1 | 499 ++++++++++++++++++ dev/Invoke-NativeAuth.ps1 | 107 ++++ dev/README.md | 133 +++++ dev/Test-DevelopmentHost.ps1 | 137 +++++ dev/client/DiscoveryChecks.cs | 58 ++ dev/client/Program.cs | 147 ++++++ dev/client/Workload.csproj | 10 + dev/client/WorkloadDiscovery.cs | 16 + dev/compose.authentik.yaml | 77 +++ dev/compose.native-auth.yaml | 75 +++ dev/compose.yaml | 105 ++++ dev/java/DevHost.java | 125 +++++ dev/java/S3PrintingQualification.java | 200 +++++++ dev/java/WorkloadFixture.java | 92 ++++ dev/log4j2.xml | 9 + dev/native-auth/Caddyfile | 20 + dev/native-auth/Dockerfile.client | 17 + dev/native-auth/Dockerfile.server | 26 + dev/native-auth/README.md | 82 +++ dev/native-auth/client/CallbackReceiver.cs | 51 ++ dev/native-auth/client/FixtureHttp.cs | 84 +++ dev/native-auth/client/NativeAuth.csproj | 12 + dev/native-auth/client/Program.cs | 236 +++++++++ dev/native-auth/client/ProviderFlow.cs | 92 ++++ dev/native-auth/client/ProviderSetup.cs | 181 +++++++ dev/native-auth/copy-root.sh | 14 + dev/native-auth/java/NativeAuthHost.java | 204 +++++++ dev/native-auth/start-client.sh | 8 + dev/native-auth/start-server.sh | 7 + dev/run-tests.sh | 7 + dev/tests/Authentik.Tests.ps1 | 68 +++ dev/tests/Lifecycle.Tests.ps1 | 126 +++++ dev/tests/NativeAuth.Tests.ps1 | 68 +++ dev/tests/ProviderDiagnostics.Tests.ps1 | 48 ++ dev/tests/WorkloadFailure.Tests.ps1 | 60 +++ docs/QUALIFICATION-2026-11-01.md | 104 ++++ docs/SESSION-STATE.md | 56 ++ docs/TODO.md | 23 +- .../qbits/printing/PrintingWorkloadTest.java | 255 +++++++++ 45 files changed, 4256 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/development.yml create mode 100644 dev/AUTHENTIK.md create mode 100644 dev/Dockerfile.client create mode 100644 dev/Dockerfile.server create mode 100644 dev/Invoke-Authentik.ps1 create mode 100644 dev/Invoke-Dev.ps1 create mode 100644 dev/Invoke-NativeAuth.ps1 create mode 100644 dev/README.md create mode 100644 dev/Test-DevelopmentHost.ps1 create mode 100644 dev/client/DiscoveryChecks.cs create mode 100644 dev/client/Program.cs create mode 100644 dev/client/Workload.csproj create mode 100644 dev/client/WorkloadDiscovery.cs create mode 100644 dev/compose.authentik.yaml create mode 100644 dev/compose.native-auth.yaml create mode 100644 dev/compose.yaml create mode 100644 dev/java/DevHost.java create mode 100644 dev/java/S3PrintingQualification.java create mode 100644 dev/java/WorkloadFixture.java create mode 100644 dev/log4j2.xml create mode 100644 dev/native-auth/Caddyfile create mode 100644 dev/native-auth/Dockerfile.client create mode 100644 dev/native-auth/Dockerfile.server create mode 100644 dev/native-auth/README.md create mode 100644 dev/native-auth/client/CallbackReceiver.cs create mode 100644 dev/native-auth/client/FixtureHttp.cs create mode 100644 dev/native-auth/client/NativeAuth.csproj create mode 100644 dev/native-auth/client/Program.cs create mode 100644 dev/native-auth/client/ProviderFlow.cs create mode 100644 dev/native-auth/client/ProviderSetup.cs create mode 100644 dev/native-auth/copy-root.sh create mode 100644 dev/native-auth/java/NativeAuthHost.java create mode 100644 dev/native-auth/start-client.sh create mode 100644 dev/native-auth/start-server.sh create mode 100644 dev/run-tests.sh create mode 100644 dev/tests/Authentik.Tests.ps1 create mode 100644 dev/tests/Lifecycle.Tests.ps1 create mode 100644 dev/tests/NativeAuth.Tests.ps1 create mode 100644 dev/tests/ProviderDiagnostics.Tests.ps1 create mode 100644 dev/tests/WorkloadFailure.Tests.ps1 create mode 100644 docs/QUALIFICATION-2026-11-01.md create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingWorkloadTest.java diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml new file mode 100644 index 0000000..a18c4cb --- /dev/null +++ b/.github/workflows/development.yml @@ -0,0 +1,64 @@ +name: development + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + inputs: + native-auth: + description: Run the opt-in, unpublished native-auth candidate qualification + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: development-${{ github.ref }} + cancel-in-progress: false + +jobs: + scripts: + name: development-scripts (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Analyze and test development scripts + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force + Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 + Import-Module Pester -RequiredVersion 5.7.1 + $issues = @(Invoke-ScriptAnalyzer -Path dev -Recurse -Severity Warning,Error) + if ($issues.Count) { $issues | Format-Table; throw 'Development script analysis failed.' } + $configuration = New-PesterConfiguration + $configuration.Run.Path = './dev/tests' + $configuration.Run.Exit = $true + $configuration.TestRegistry.Enabled = $false + Invoke-Pester -Configuration $configuration + + docker: + name: development-docker + runs-on: ubuntu-24.04 + timeout-minutes: 50 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Run disposable S3 and network printing smoke tests + shell: pwsh + run: ./dev/Invoke-Dev.ps1 -Action Test + - name: Run opt-in real native provider qualification + if: github.event_name == 'workflow_dispatch' && inputs.native-auth + shell: pwsh + run: ./dev/Invoke-Dev.ps1 -Action Test -ExperimentalNativeAuth diff --git a/README.md b/README.md index 592daad..db3e486 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ flowchart LR See the [real-time host integration](docs/REALTIME.md), [screen walkthrough](docs/UI-WALKTHROUGH.md), [storage and retention](docs/STORAGE-AND-RETENTION.md), [operations and troubleshooting](docs/OPERATIONS.md), [contributing](CONTRIBUTING.md), [security reporting](SECURITY.md), [changelog](CHANGELOG.md), and [Apache-2.0 license](LICENSE). +## Local development + +Start an isolated native QQQ application, MinIO and optional Authentik with the +[Docker development commands](dev/README.md). The default test runs synthetic +Java/.NET WebSocket jobs and S3 checks without an installed connector or printer. + ## Build and verify Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `f3cbc02e9b0fb4fdfefaa59d976c04df747cc589`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11). diff --git a/dev/AUTHENTIK.md b/dev/AUTHENTIK.md new file mode 100644 index 0000000..642f91e --- /dev/null +++ b/dev/AUTHENTIK.md @@ -0,0 +1,115 @@ +# Local Authentik reference + +Run the optional real-provider checks with Docker Compose and PowerShell 7.4+. +The pinned fixture images use Linux/amd64; other hosts need Docker's corresponding +Linux-container support or emulation: + +```powershell +pwsh ./dev/Invoke-Dev.ps1 -Action Test -Authentik +``` + +The command creates a fresh, owned development project, runs the normal QQQ and +MinIO smoke test plus the Authentik reference checks, then removes that project's +containers and volumes. It does not operate the installed Windows connector, +existing Docker stacks or physical printers. It needs no cloud account. + +For an environment that remains available for development, use `-Action Start` +with an explicit `-StateDirectory` outside the checkout. Use that same directory +with `-Action Test -Authentik`, then `-Action Stop` when finished. Stop destroys +only that owned project's synthetic databases and objects; retained private +reports are separate. See the [development command](Invoke-Dev.ps1) for port +overrides. Authentik defaults to `http://127.0.0.1:19010`; +its database port is not published. The QQQ host remains on standard TABLE +authentication. + +## What the automated check establishes + +- Real Authentik 2026.8.5 starts with a separate PostgreSQL 16 Alpine database. +- A synthetic confidential OAuth provider/application and development user are + configured through Authentik's API. The callback list is explicit and grants + are limited to authorization code and refresh. Access/refresh validity is + two/ten minutes for a future bounded renewal test. +- Actual provider discovery has the expected issuer, same-origin endpoints and + S256 capability. A valid state/S256 request with the registered callback + continues to the provider login flow without following it. Changing only that + callback to an unregistered redirect is rejected before any user login. +- The current QQQ RC.20 host advertises UI API 1.8 without `nativeSessionLogin`. + That expected absence is recorded as a native-client prerequisite failure, + not silently replaced by bearer authentication or an unpublished core. + +`auth-result.json` distinguishes those checks from browser login, renewal and +the Windows callback, which are **not run**. Provider setup/discovery is not an +OIDC user-session integration test. No password login dialog is automated. + +## Credentials and ownership + +The common entrypoint creates a protected state directory outside the checkout. +`Invoke-Authentik.ps1 -Action Initialize` appends only its `AUTHENTIK_*` settings +to `compose.env`, preserving the QQQ/MinIO settings. It records synthetic +credentials in private `auth-secrets.json`; neither file belongs in Git, a bug +report or terminal output. Bootstrap uses a Django password hash. The temporary +bootstrap API token remains confined to this disposable project and disappears +with its database at cleanup. Do not reuse these accounts outside the fixture. + +The Docker administrator can inspect container configuration. The scripts do not +print credentials or provider error bodies, follow redirects with an API token, +or request raw container logs. HTTP reads have a 15-second cancellation deadline +and a 512 KiB response limit. Startup has a bounded readiness loop. Initializer +replays retain credentials; conflicting or partial state is refused. + +Compose pins cached/public image digests, not floating tags: + + + +| Component | Image digest | +| --- | --- | +| Authentik server/worker 2026.8.5 | `ghcr.io/goauthentik/server@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5` | +| PostgreSQL 16 Alpine | `postgres@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea` | + + + +There is no Redis requirement for this Authentik version, Docker socket mount, +outpost management, shared volume or automatic restart policy. Container and +volume labels identify the exact generated Compose project. The common cleanup +checks those labels before removing resources; it never performs a global prune. + +## Separate native-session qualification + +The Windows native path requires the centrally defined API 1.9 typed +`AUTHORIZATION_CODE_PKCE_SESSION` grant and trusted HTTPS on both QQQ and provider +origins. RC.20 does not supply that grant, and this HTTP development profile does +not weaken the client's HTTPS rules. The synthetic reference callback is only a +provider configuration value; no callback receiver or QQQ OAuth adapter is +claimed here. The TABLE host and installed pairing are unchanged. + +The separate [experimental native profile](native-auth/README.md) builds an exact +public, unreleased QQQ candidate inside Docker and uses private container-only +HTTPS trust. On 2026-11-01 it passed actual provider login, native permission +denials, logout replay rejection, renewal after expiry and provider revocation +with the production Core client. Its synthetic HTTP driver and HTTPS callback +receiver do not qualify the installed Windows browser UI or private URI callback. + +Production adoption still requires a compatible published central artifact +tuple. Provider refresh tokens stay on the server. The short lifetime test does +not prove months or years of unattended use. Do not route the private Windows +callback to a live companion whose connection must remain intact. + +The setup follows the official [2026.8 Compose definition](https://goauthentik.io/version/2026.8/lifecycle/container/compose.yml), +[bootstrap contract](https://docs.goauthentik.io/install-config/automated-install/) +and [OAuth provider contract](https://docs.goauthentik.io/add-secure-apps/providers/oauth2/). +The repository supplies the repeatable local setup; provider/browser acceptance +is reported separately from TABLE authentication and printing workload tests. + +## Initializer regression tests + +With Pester 5 available, run `dev/tests/Authentik.Tests.ps1`. They use temporary +files only: no Docker, HTTP, installed service or registry access is required. +Disable Pester's unrelated registry fixture on Windows: + +```powershell +$configuration = New-PesterConfiguration +$configuration.Run.Path = './dev/tests/Authentik.Tests.ps1' +$configuration.Run.Exit = $true +$configuration.TestRegistry.Enabled = $false +Invoke-Pester -Configuration $configuration +``` diff --git a/dev/Dockerfile.client b/dev/Dockerfile.client new file mode 100644 index 0000000..be450c7 --- /dev/null +++ b/dev/Dockerfile.client @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b AS build +# Exact reviewed published Core source; no workstation DLL or mutable branch. +ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fca71fb6 https://github.com/QRun-IO/qqq-print-agent/archive/346fc285198908c47a2ee9db7b9adb53d5e88c3c.tar.gz /tmp/client.tar.gz +RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz +RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo +RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/ +WORKDIR /fixture +COPY dev/client . +RUN dotnet build -c Release --nologo -o /out +RUN dotnet /out/Workload.dll --check-discovery +# SDK image is reused deliberately: one pinned image, no hidden host SDK dependency. +FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b +WORKDIR /app +COPY --from=build /out . +ENV DOTNET_EnableDiagnostics=0 +ENTRYPOINT ["dotnet","/app/Workload.dll"] diff --git a/dev/Dockerfile.server b/dev/Dockerfile.server new file mode 100644 index 0000000..67c329e --- /dev/null +++ b/dev/Dockerfile.server @@ -0,0 +1,23 @@ +FROM maven:3.9.11-eclipse-temurin-21@sha256:6fdc855a6ed81d288ca7ca37ac6ff5e9308b612485c0801d70b25a858c83d237 AS build +WORKDIR /source +COPY pom.xml mvnw ./ +COPY .mvn .mvn +COPY checkstyle checkstyle +COPY src src +COPY LICENSE NOTICE ./ +RUN chmod +x mvnw && ./mvnw -B -Pui-demo -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime +COPY dev/java /fixture-src +RUN mkdir -p /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes /fixture-src/*.java + +FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0 +ARG SOURCE_COMMIT=unknown +ENV PRINTING_SOURCE_COMMIT=$SOURCE_COMMIT +WORKDIR /app +COPY --from=build /source/target/classes classes +COPY --from=build /source/target/runtime lib +COPY --from=build /fixture-classes fixture +COPY dev/log4j2.xml /app/log4j2.xml +COPY dev/run-tests.sh /app/run-tests.sh +RUN chmod 0555 /app/run-tests.sh +ENTRYPOINT ["java","-Xmx768m","-Dlog4j.configurationFile=/app/log4j2.xml","-cp","/app/classes:/app/lib/*:/app/fixture"] +CMD ["com.kingsrook.qbits.printing.DevHost"] diff --git a/dev/Invoke-Authentik.ps1 b/dev/Invoke-Authentik.ps1 new file mode 100644 index 0000000..a399915 --- /dev/null +++ b/dev/Invoke-Authentik.ps1 @@ -0,0 +1,394 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS +Prepares or checks the optional, owned Authentik development fixture. +.DESCRIPTION +Initialize only writes private state. Test configures a fresh synthetic provider +and checks its real HTTP discovery and rejection boundary; it never automates a +user login or changes QQQ authentication. The common development entrypoint owns +all Docker lifecycle operations. Provider response bodies and secrets are never +printed, including on errors. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('Initialize', 'Test')][string] $Action, + [Parameter(Mandatory)][string] $StateDirectory, + [Parameter(Mandatory)][ValidatePattern('^qbit-printing-dev-[a-z0-9-]{8,48}$')][string] $ProjectName, + [ValidateRange(1024, 65535)][int] $Port = 19010, + [ValidateRange(10, 300)][int] $ReadyTimeoutSeconds = 180 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +<# .SYNOPSIS Generates a private random value without placing it on a command line. #> +function Get-FixtureSecret +{ + [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_') +} + +<# .SYNOPSIS Limits the helper to an existing state directory outside the checkout. #> +function Get-FixtureState +{ + param([string] $Directory) + $state = [IO.Path]::GetFullPath($Directory) + $repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) + $comparison = if($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } + if($state.Equals($repository, $comparison) -or $state.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, $comparison)) + { + throw 'Development secrets must be outside the source checkout.' + } + if(-not (Test-Path -LiteralPath $state -PathType Container) -or -not (Test-Path -LiteralPath (Join-Path $state 'compose.env') -PathType Leaf)) + { + throw 'The common development entrypoint must create protected state and compose.env first.' + } + $ancestor = [IO.DirectoryInfo]::new($state) + while($null -ne $ancestor) + { + if(($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) + { + throw 'Development state cannot use a symbolic link or reparse point.' + } + $ancestor = $ancestor.Parent + } + return $state +} + +<# .SYNOPSIS Reads private configuration without echoing malformed JSON or credential values. #> +function Get-FixtureConfiguration +{ + param([string] $Path) + try + { + $value = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable + foreach($field in @('project', 'port', 'adminPassword', 'bootstrapToken', 'clientId', 'clientSecret', 'username', 'userPassword')) + { + if(-not $value.ContainsKey($field) -or $null -eq $value[$field]) { throw 'Missing field.' } + } + return $value + } + catch { throw 'The private Authentik configuration is unreadable or incomplete; its contents are withheld.' } +} + +<# .SYNOPSIS Performs bounded same-origin HTTP without redirects or response-body diagnostics. #> +function Invoke-FixtureHttp +{ + param( + [Parameter(Mandatory)][Net.Http.HttpClient] $Client, + [Parameter(Mandatory)][string] $Path, + [ValidateSet('GET', 'POST')][string] $Method = 'GET', + [object] $Body, + [string] $Token, + [int[]] $ExpectedStatus = @(200) + ) + if(-not $Path.StartsWith('/', [StringComparison]::Ordinal) -or $Path.StartsWith('//', [StringComparison]::Ordinal)) + { + throw 'Only an absolute path on the fixture origin is permitted.' + } + $request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $Path) + $timeout = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(15)) + $response = $null + $status = 0 + try + { + if($Token) + { + $request.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token) + } + if($null -ne $Body) + { + $request.Content = [Net.Http.StringContent]::new(($Body | ConvertTo-Json -Depth 12 -Compress), [Text.Encoding]::UTF8, 'application/json') + } + $response = $Client.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $timeout.Token).GetAwaiter().GetResult() + $status = [int]$response.StatusCode + if($status -notin $ExpectedStatus) + { + throw "Fixture HTTP status $status was not expected. Response content is withheld." + } + $stream = $response.Content.ReadAsStreamAsync($timeout.Token).GetAwaiter().GetResult() + $bytes = [IO.MemoryStream]::new() + try + { + $buffer = [byte[]]::new(8192) + while(($read = $stream.ReadAsync($buffer, 0, $buffer.Length, $timeout.Token).GetAwaiter().GetResult()) -gt 0) + { + if($bytes.Length + $read -gt 524288) + { + throw 'Fixture response exceeded the 512 KiB limit.' + } + $bytes.Write($buffer, 0, $read) + } + $text = [Text.Encoding]::UTF8.GetString($bytes.ToArray()) + $data = if($text.Length -gt 0 -and $response.Content.Headers.ContentType.MediaType -eq 'application/json') + { + ConvertFrom-Json -InputObject $text -AsHashtable + } + else { $null } + return @{ Status = $status; Data = $data; Location = $response.Headers.Location } + } + finally + { + $bytes.Dispose() + $stream.Dispose() + } + } + catch + { + # The failing API may contain passwords/tokens in its error response. + $operation = [uri]::new($Client.BaseAddress, $Path).AbsolutePath + throw "Fixture $Method $operation failed (HTTP $status; 0 means no response). Response content and authentication values are withheld." + } + finally + { + if($null -ne $response) { $response.Dispose() } + $request.Dispose() + $timeout.Dispose() + } +} + +<# .SYNOPSIS Requires one exact default object rather than choosing an arbitrary provider resource. #> +function Get-FixtureObject +{ + param([Net.Http.HttpClient] $Client, [string] $Path, [string] $Token) + $page = (Invoke-FixtureHttp -Client $Client -Path $Path -Token $Token).Data + if($null -eq $page -or $page.results.Count -ne 1) + { + throw 'A required Authentik default object is missing or ambiguous.' + } + return $page.results[0] +} + +$state = Get-FixtureState -Directory $StateDirectory +$envPath = Join-Path $state 'compose.env' +$secretPath = Join-Path $state 'auth-secrets.json' +foreach($path in @($envPath, $secretPath)) +{ + if((Test-Path -LiteralPath $path) -and ((Get-Item -LiteralPath $path -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) + { + throw 'Development configuration cannot use a symbolic link or reparse point.' + } +} +if($Action -eq 'Initialize') +{ + if(Test-Path -LiteralPath $secretPath) + { + $existing = Get-FixtureConfiguration -Path $secretPath + if($existing.project -ne $ProjectName -or $existing.port -ne $Port) + { + throw 'Existing Authentik state belongs to a different project or port.' + } + $existingEnvironment = Get-Content -LiteralPath $envPath -Raw + foreach($key in @('AUTHENTIK_HTTP_PORT', 'AUTHENTIK_POSTGRES_PASSWORD', 'AUTHENTIK_SECRET_KEY', 'AUTHENTIK_BOOTSTRAP_PASSWORD_HASH', 'AUTHENTIK_BOOTSTRAP_TOKEN')) + { + if([regex]::Matches($existingEnvironment, '(?m)^' + $key + '=').Count -ne 1) + { + throw 'The private Authentik environment is incomplete or ambiguous; use a fresh owned state directory.' + } + } + return + } + $contents = Get-Content -LiteralPath $envPath -Raw + if($contents -match '(?m)^AUTHENTIK_') + { + throw 'Partial Authentik initialization exists. Use a fresh owned state directory.' + } + $adminPassword = Get-FixtureSecret + $salt = Get-FixtureSecret + $passwordHash = [Convert]::ToBase64String([Security.Cryptography.Rfc2898DeriveBytes]::Pbkdf2($adminPassword, [Text.Encoding]::UTF8.GetBytes($salt), 1000000, [Security.Cryptography.HashAlgorithmName]::SHA256, 32)) + $secrets = [ordered]@{ + project = $ProjectName + port = $Port + adminPassword = $adminPassword + bootstrapToken = Get-FixtureSecret + clientId = $ProjectName + clientSecret = Get-FixtureSecret + username = 'printing-developer' + userPassword = Get-FixtureSecret + } + $secrets | ConvertTo-Json | Set-Content -LiteralPath $secretPath -Encoding utf8 + $values = [ordered]@{ + AUTHENTIK_HTTP_PORT = $Port + AUTHENTIK_POSTGRES_PASSWORD = Get-FixtureSecret + AUTHENTIK_SECRET_KEY = Get-FixtureSecret + AUTHENTIK_BOOTSTRAP_PASSWORD_HASH = 'pbkdf2_sha256$1000000$' + $salt + '$' + $passwordHash + AUTHENTIK_BOOTSTRAP_TOKEN = $secrets.bootstrapToken + } + # Single quotes preserve the dollar signs in Django's password hash. + $lines = @('') + @($values.GetEnumerator() | ForEach-Object { $_.Key + "='" + $_.Value + "'" }) + Add-Content -LiteralPath $envPath -Value $lines -Encoding utf8 + if(-not $IsWindows) + { + [IO.File]::SetUnixFileMode($secretPath, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite) + } + return +} + +$secrets = Get-FixtureConfiguration -Path $secretPath +if($secrets.project -cne $ProjectName -or $secrets.port -ne $Port -or $secrets.clientId -cne $ProjectName -or $secrets.username -cne 'printing-developer') +{ + throw 'Authentik state does not match the owned development project.' +} +$handler = [Net.Http.HttpClientHandler]::new() +$handler.AllowAutoRedirect = $false +$handler.UseProxy = $false +$handler.UseCookies = $false +$client = [Net.Http.HttpClient]::new($handler) +$client.BaseAddress = [uri]"http://127.0.0.1:$Port" +$client.Timeout = [Threading.Timeout]::InfiniteTimeSpan +try +{ + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($ReadyTimeoutSeconds) + $ready = $false + do + { + try + { + $authorization = Get-FixtureObject -Client $client -Path '/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent' -Token $secrets.bootstrapToken + $invalidation = Get-FixtureObject -Client $client -Path '/api/v3/flows/instances/?slug=default-provider-invalidation-flow' -Token $secrets.bootstrapToken + $ready = $true + } + catch + { + if([DateTimeOffset]::UtcNow -ge $deadline) { throw 'Authentik did not become ready before the bounded startup deadline.' } + Start-Sleep -Seconds 1 + } + } while(-not $ready) + + $mappingIds = @(foreach($scope in @('openid', 'profile', 'email', 'offline_access')) + { + (Get-FixtureObject -Client $client -Path "/api/v3/propertymappings/provider/scope/?scope_name=$scope" -Token $secrets.bootstrapToken).pk + }) + $providerPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/providers/oauth2/?client_id=' + $secrets.clientId) -Token $secrets.bootstrapToken).Data + $callback = "http://127.0.0.1:$Port/reference-callback/" + if($providerPage.results.Count -eq 0) + { + $provider = (Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/providers/oauth2/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{ + name = $ProjectName + authorization_flow = $authorization.pk + invalidation_flow = $invalidation.pk + client_type = 'confidential' + grant_types = @('authorization_code', 'refresh_token') + client_id = $secrets.clientId + client_secret = $secrets.clientSecret + access_code_validity = 'minutes=1' + access_token_validity = 'minutes=2' + refresh_token_validity = 'minutes=10' + redirect_uris = @(@{ matching_mode = 'strict'; url = $callback }) + property_mappings = $mappingIds + issuer_mode = 'per_provider' + }).Data + } + elseif($providerPage.results.Count -eq 1) + { + $provider = $providerPage.results[0] + if($provider.name -ne $ProjectName) { throw 'The fixture provider identity changed.' } + } + else { throw 'The fixture provider identity is ambiguous.' } + if($provider.redirect_uris.Count -ne 1 -or $provider.redirect_uris[0].matching_mode -cne 'strict' -or + $provider.redirect_uris[0].url -cne $callback -or $provider.client_type -cne 'confidential' -or + $provider.access_token_validity -cne 'minutes=2' -or $provider.refresh_token_validity -cne 'minutes=10' -or + (@($provider.grant_types | Sort-Object) -join ',') -cne 'authorization_code,refresh_token' -or + @($mappingIds | Where-Object { $_ -notin $provider.property_mappings }).Count -ne 0) + { + throw 'The existing provider configuration differs from the bounded reference settings.' + } + + $applicationPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/core/applications/?slug=' + $ProjectName) -Token $secrets.bootstrapToken).Data + if($applicationPage.results.Count -eq 0) + { + $null = Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/core/applications/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{ name = $ProjectName; slug = $ProjectName; provider = $provider.pk; policy_engine_mode = 'all' } + } + elseif($applicationPage.results.Count -ne 1 -or $applicationPage.results[0].provider -ne $provider.pk) + { + throw 'The fixture application does not match its provider.' + } + $userPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/core/users/?username=' + $secrets.username) -Token $secrets.bootstrapToken).Data + if($userPage.results.Count -eq 0) + { + $user = (Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/core/users/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{ username = $secrets.username; name = 'Printing development user'; email = 'printing-developer@example.invalid'; is_active = $true; type = 'internal' }).Data + } + elseif($userPage.results.Count -eq 1) + { + $user = $userPage.results[0] + if($user.name -cne 'Printing development user') { throw 'The synthetic user identity changed.' } + } + else { throw 'The synthetic user identity is ambiguous.' } + # Reusing the same generated value also recovers an interrupted first setup. + $null = Invoke-FixtureHttp -Client $client -Method POST -Path ("/api/v3/core/users/$($user.pk)/set_password/") -Token $secrets.bootstrapToken -ExpectedStatus @(204) -Body @{ password = $secrets.userPassword } + + $issuer = "http://127.0.0.1:$Port/application/o/$ProjectName/" + $discovery = (Invoke-FixtureHttp -Client $client -Path "/application/o/$ProjectName/.well-known/openid-configuration").Data + if($discovery.issuer -cne $issuer -or 'S256' -notin $discovery.code_challenge_methods_supported) + { + throw 'The real provider discovery does not match the fixture issuer and S256 capability.' + } + foreach($endpoint in @('authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri')) + { + $uri = [uri]$discovery[$endpoint] + if($uri.GetLeftPart([UriPartial]::Authority) -cne "http://127.0.0.1:$Port") + { + throw 'Provider discovery returned an endpoint outside the fixture origin.' + } + } + $verifier = Get-FixtureSecret + $challenge = [Convert]::ToBase64String([Security.Cryptography.SHA256]::HashData([Text.Encoding]::ASCII.GetBytes($verifier))).TrimEnd('=').Replace('+', '-').Replace('/', '_') + $stateValue = Get-FixtureSecret + $authorize = "/application/o/authorize/?client_id=$($secrets.clientId)&response_type=code&scope=openid%20profile%20email%20offline_access&state=$stateValue&code_challenge=$challenge&code_challenge_method=S256&prompt=login&redirect_uri=" + $accepted = Invoke-FixtureHttp -Client $client -Path ($authorize + [uri]::EscapeDataString($callback)) -ExpectedStatus @(302, 303) + if($null -eq $accepted.Location) + { + throw 'The registered redirect did not continue to the real provider login flow.' + } + $login = [uri]::new($client.BaseAddress, $accepted.Location) + if($login.GetLeftPart([UriPartial]::Authority) -cne "http://127.0.0.1:$Port" -or -not $login.AbsolutePath.StartsWith('/if/flow/', [StringComparison]::Ordinal)) + { + throw 'The registered redirect did not continue to the same-origin provider login flow.' + } + # Change only the callback: a generic malformed authorization request is not proof. + $invalidRedirect = [uri]::EscapeDataString("http://127.0.0.1:$Port/not-registered/") + $null = Invoke-FixtureHttp -Client $client -Path ($authorize + $invalidRedirect) -ExpectedStatus @(400) + $qqqPortMatch = [regex]::Match((Get-Content -LiteralPath $envPath -Raw), '(?m)^QQQ_HTTP_PORT=[''"]?(\d+)[''"]?\s*$') + if(-not $qqqPortMatch.Success) { throw 'The common environment has no QQQ HTTP port.' } + $qqqHandler = [Net.Http.HttpClientHandler]::new() + $qqqHandler.AllowAutoRedirect = $false + $qqqHandler.UseProxy = $false + $qqqClient = [Net.Http.HttpClient]::new($qqqHandler) + try + { + $qqqClient.BaseAddress = [uri]("http://127.0.0.1:" + $qqqPortMatch.Groups[1].Value) + $qqqClient.Timeout = [Threading.Timeout]::InfiniteTimeSpan + $qqqClient.DefaultRequestHeaders.Add('X-QQQ-Client', 'api/printing-dev-reference') + $version = (Invoke-FixtureHttp -Client $qqqClient -Path '/qqq/v1/version').Data + } + finally { $qqqClient.Dispose() } + if($version.apiVersion -ne '1.8.0' -or 'nativeSessionLogin' -in $version.features) + { + throw 'The core capability baseline changed; review the Authentik qualification scope before proceeding.' + } + [ordered]@{ + status = 'PASS' + observedUtc = [DateTimeOffset]::UtcNow.ToString('O') + project = $ProjectName + providerOrigin = "http://127.0.0.1:$Port" + providerVersion = '2026.8.5' + discovery = 'PASS' + registeredRedirect = 'LOGIN_REQUIRED' + unregisteredRedirect = 'REJECTED' + qqqApiVersion = $version.apiVersion + nativeSessionLogin = 'UNAVAILABLE_EXPECTED' + browserLogin = 'NOT_RUN_OPERATOR_REQUIRED' + renewal = 'NOT_RUN' + windowsCallback = 'NOT_RUN_REQUIRES_QUALIFIED_CORE_AND_HTTPS' + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-result.json') -Encoding utf8 + Write-Output 'Authentik reference discovery and redirect rejection passed; QQQ native browser login remains unavailable on RC.20.' +} +catch +{ + @{ status = 'FAILED'; observedUtc = [DateTimeOffset]::UtcNow.ToString('O'); project = $ProjectName; message = 'Reference checks failed; no browser or native-login success is claimed.' } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-result.json') -Encoding utf8 + throw +} +finally +{ + $client.Dispose() +} diff --git a/dev/Invoke-Dev.ps1 b/dev/Invoke-Dev.ps1 new file mode 100644 index 0000000..ac51a7c --- /dev/null +++ b/dev/Invoke-Dev.ps1 @@ -0,0 +1,499 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS +Starts, tests, or removes one owned local Docker printing environment. +.DESCRIPTION +Uses synthetic accounts and documents. Never imports installed device credentials, +contacts printers, changes host trust, or prunes shared Docker resources. +#> +[CmdletBinding()] +param( + [ValidateSet('Start', 'Test', 'Stop')][string]$Action = 'Test', + [string]$StateDirectory, + [switch]$Authentik, + [switch]$ExperimentalNativeAuth, + [ValidateSet(40, 10000)][int]$Workload = 40, + [ValidateRange(1024, 65535)][int]$QqqPort = 19081, + [ValidateRange(1024, 65535)][int]$MinioPort = 19095, + [ValidateRange(1024, 65535)][int]$AuthentikPort = 19010 +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$ephemeral = $Action -eq 'Test' -and [string]::IsNullOrWhiteSpace($StateDirectory) +if ($ExperimentalNativeAuth) +{ + if ($Action -ne 'Test') + { + throw 'Experimental native authentication is a disposable Test-only profile.' + } + $Authentik = $true +} + +function Assert-StateLocation([string]$Path) +{ + <# .SYNOPSIS Rejects links before any private state is read or written. #> + $ancestor = [IO.DirectoryInfo]::new($Path) + while ($null -ne $ancestor) + { + if (![string]::IsNullOrEmpty($ancestor.LinkTarget) -or ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) + { + throw 'Development state cannot use a symbolic link or reparse point.' + } + $ancestor = $ancestor.Parent + } + foreach ($name in @('owner.json', 'compose.env', 'compose.yaml', 'compose.authentik.yaml', 'compose.native-auth.yaml', 'auth-secrets.json', 'qqq-result.json', 'auth-result.json', 'native-input.json', 'native-auth-result.json', 'native-auth-server-errors.json')) + { + $file = [IO.FileInfo]::new((Join-Path $Path $name)) + if (![string]::IsNullOrEmpty($file.LinkTarget) -or ($file.Exists -and ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) + { + throw 'Development state files cannot use symbolic links or reparse points.' + } + } +} +function Get-RecordedAuthentikPort +{ + <# .SYNOPSIS Restores the existing environment port without reading it from caller defaults. #> + $lines = @(Get-Content -LiteralPath $script:envFile | Where-Object { $_.StartsWith('AUTHENTIK_HTTP_PORT=', [StringComparison]::Ordinal) }) + if ($lines.Count -ne 1 -or $lines[0] -cnotmatch '^AUTHENTIK_HTTP_PORT=([0-9]{4,5})$') + { + throw 'Recorded Authentik port is missing or invalid.' + } + $port = [int]$Matches[1] + if ($port -lt 1024 -or $port -gt 65535) + { + throw 'Recorded Authentik port is outside the supported range.' + } + return $port +} +function Get-FixtureSecret +{ + <# .SYNOPSIS Generates only disposable local credentials. #> + return [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) +} +function Protect-State([string]$Path) +{ + <# .SYNOPSIS Restricts the generated state to its user and local administrators. #> + if ($IsWindows) + { + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User, + [Security.Principal.SecurityIdentifier]::new('S-1-5-18'), [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))) + { + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) + } + Set-Acl -LiteralPath $Path -AclObject $acl + } + else + { + [IO.File]::SetUnixFileMode($Path, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute) + } +} +function Set-FixtureEnvironment +{ + [CmdletBinding(SupportsShouldProcess)] + param([string]$Name, [string]$Value) + <# .SYNOPSIS Updates one safe dotenv field without displaying secret values. #> + if ($Name -cnotmatch '^[A-Z_]+$' -or $Value -match '[\r\n]') + { + throw 'Unsafe generated environment field.' + } + if (!$PSCmdlet.ShouldProcess($Name, 'Write protected fixture setting')) + { + return + } + $lines = @() + if (Test-Path -LiteralPath $script:envFile) + { + $lines = @(Get-Content -LiteralPath $script:envFile | Where-Object { !$_.StartsWith($Name + '=', [StringComparison]::Ordinal) }) + } + [IO.File]::WriteAllLines($script:envFile, @($lines) + @($Name + '=' + $Value)) +} +function Invoke-Compose([string[]]$Arguments) +{ + <# .SYNOPSIS Runs only this recorded Compose project. #> + & docker compose @script:composeArguments @Arguments + if ($LASTEXITCODE -ne 0) + { + throw 'Owned Docker Compose operation failed; inspect local command output.' + } +} +function Assert-OwnedResource +{ + <# .SYNOPSIS Refuses cleanup if any project resource lacks the ownership label. #> + $ids = @(& docker ps -aq --filter "label=com.docker.compose.project=$script:project") + if ($LASTEXITCODE -ne 0) + { + throw 'Cannot inspect owned Docker containers.' + } + foreach ($id in $ids) + { + $label = & docker inspect $id --format '{{index .Config.Labels "io.qrun.printing.dev"}}' + if ($LASTEXITCODE -ne 0 -or $label -cne $script:project) + { + throw 'Container ownership mismatch; cleanup refused.' + } + } + $networks = @(& docker network ls -q --filter "label=com.docker.compose.project=$script:project") + if ($LASTEXITCODE -ne 0) + { + throw 'Cannot inspect owned Docker networks.' + } + foreach ($network in $networks) + { + $label = & docker network inspect $network --format '{{index .Labels "io.qrun.printing.dev"}}' + if ($LASTEXITCODE -ne 0 -or $label -cne $script:project) + { + throw 'Network ownership mismatch; cleanup refused.' + } + } + $volumes = @(& docker volume ls -q --filter "label=com.docker.compose.project=$script:project") + if ($LASTEXITCODE -ne 0) + { + throw 'Cannot inspect owned Docker volumes.' + } + foreach ($volume in $volumes) + { + $label = & docker volume inspect $volume --format '{{index .Labels "io.qrun.printing.dev"}}' + if ($LASTEXITCODE -ne 0 -or $label -cne $script:project) + { + throw 'Volume ownership mismatch; cleanup refused.' + } + } +} +function Stop-OwnedEnvironment +{ + [CmdletBinding(SupportsShouldProcess)] + param() + <# .SYNOPSIS Removes only this environment's containers, network and synthetic volumes. #> + if (!$PSCmdlet.ShouldProcess($script:project, 'Remove this owned development environment and its synthetic data')) + { + return + } + Assert-OwnedResource + Invoke-Compose @('--profile', 'test', '--profile', 'auth', '--profile', 'native-auth', 'down', '--remove-orphans', '--volumes', '--timeout', '30') +} +function ConvertTo-ProviderErrorSummary([string[]]$Lines) +{ + <# .SYNOPSIS Retains only exception types and bounded source-frame coordinates. #> + $types = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $frames = [Collections.Generic.List[object]]::new() + $warnings = @{invalidClientIdentifier = $false; nonApplicableAuthenticationFlow = $false; failedToResolveApplication = $false} + foreach ($line in ($Lines | Select-Object -Last 1000)) + { + if ($line.Length -gt 65536) { continue } + $warnings.invalidClientIdentifier = $warnings.invalidClientIdentifier -or $line.Contains('Invalid client identifier', [StringComparison]::Ordinal) + $warnings.nonApplicableAuthenticationFlow = $warnings.nonApplicableAuthenticationFlow -or $line.Contains('Non-applicable authentication flow', [StringComparison]::Ordinal) + $warnings.failedToResolveApplication = $warnings.failedToResolveApplication -or $line.Contains('failed to resolve application', [StringComparison]::Ordinal) + try { $record = ConvertFrom-Json -InputObject $line -AsHashtable -ErrorAction Stop } + catch { continue } + if ($record -isnot [Collections.IDictionary]) { continue } + foreach ($exception in @($record['exception'])) + { + if ($exception -isnot [Collections.IDictionary]) { continue } + $type = [string]$exception['exc_type'] + if ($types.Count -lt 16 -and ($type -cmatch '^[A-Za-z_][A-Za-z0-9_.]{0,119}(Error|Exception)$' -or $type -cin @('PermissionDenied', 'NotAuthenticated', 'AuthenticationFailed', 'Throttled'))) { [void]$types.Add($type) } + foreach ($frame in @($exception['frames'])) + { + if ($frames.Count -ge 12 -or $frame -isnot [Collections.IDictionary]) { continue } + $filename = [string]$frame['filename'] + $function = [string]$frame['name'] + $lineNumber = 0 + if ($filename.Length -le 300 -and $filename -cmatch '^/authentik/[A-Za-z0-9_./-]+\.py$' -and + $function -cmatch '^[A-Za-z0-9_<>.]{1,120}$' -and + [int]::TryParse([string]$frame['lineno'], [ref]$lineNumber) -and $lineNumber -gt 0) + { + $frames.Add(@{filename = $filename; line = $lineNumber; function = $function}) + } + } + } + } + return @{exceptionTypes = @($types | Sort-Object); frames = @($frames.ToArray()); warnings = $warnings} +} +function Save-ProviderErrorSummary +{ + <# .SYNOPSIS Reads bounded logs only from the exact owned synthetic provider. #> + $ids = @(& docker ps -aq --filter "label=com.docker.compose.project=$script:project" --filter 'label=com.docker.compose.service=authentik-server') + if ($LASTEXITCODE -ne 0 -or $ids.Count -ne 1 -or $ids[0] -cnotmatch '^[a-f0-9]{12,64}$') { return } + $label = & docker inspect $ids[0] --format '{{index .Config.Labels "io.qrun.printing.dev"}}' + if ($LASTEXITCODE -ne 0 -or $label -cne $script:project) { throw 'Provider diagnostic ownership mismatch.' } + $lines = @(& docker logs --tail 1000 $ids[0] 2>&1 | ForEach-Object { $_.ToString() }) + if ($LASTEXITCODE -ne 0) { return } + $summary = ConvertTo-ProviderErrorSummary $lines + $lines = $null + Assert-StateLocation $StateDirectory + $summary | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $StateDirectory 'native-auth-server-errors.json') +} +function Wait-OwnedContainer([string]$Id, [int]$Seconds) +{ + <# .SYNOPSIS Observes bounded completion without accessing application data. #> + $deadline = [DateTime]::UtcNow.AddSeconds($Seconds) + do + { + $state = & docker inspect $Id --format '{{.State.Running}}|{{.State.ExitCode}}' + if ($LASTEXITCODE -ne 0) + { + throw 'Owned fixture container disappeared.' + } + if ($state.StartsWith('false|', [StringComparison]::Ordinal)) + { + return [int]$state.Split('|')[1] + } + Start-Sleep -Seconds 1 + } while ([DateTime]::UtcNow -lt $deadline) + throw 'Bounded fixture deadline expired; owned resources will be cleaned.' +} +function Assert-WorkloadClientSucceeded([string]$Id, [int]$ExitCode, [string]$Run, [string]$Reports) +{ + <# .SYNOPSIS Preserves private client evidence and reports only fixed failure classifications. #> + if ($ExitCode -eq 0) { return } + $stage = 'unknown' + $failureType = 'UnknownFailure' + try + { + $path = Join-Path $Reports 'result.json' + & docker cp "${Id}:/work/$Run/result.json" $path 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $path -PathType Leaf) -and (Get-Item -LiteralPath $path).Length -le 65536) + { + $result = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -AsHashtable + if ($result -is [Collections.IDictionary] -and $result['success'] -ceq $false) + { + if ($result['stage'] -is [string] -and $result['stage'] -cin @('input', 'native_login_and_channels', 'workload', 'logout')) + { + $stage = $result['stage'] + } + if ($result['error'] -is [string] -and $result['error'] -cin @('QqqSessionException', 'HttpRequestException', 'IOException', 'InvalidDataException', 'JsonException', 'OperationCanceledException', 'TaskCanceledException', 'UnauthorizedAccessException', 'WebSocketException', 'TimeoutException', 'InvalidOperationException')) + { + $failureType = $result['error'] + } + } + } + } + catch + { + # Missing or malformed diagnostics must not hide the failed client or expose raw exception text. + $stage = 'unknown' + $failureType = 'UnknownFailure' + } + throw "Workload client failed at $stage ($failureType); see protected results." +} + +if ([string]::IsNullOrWhiteSpace($StateDirectory)) +{ + if ($Action -eq 'Stop') + { + throw 'Stop requires the state directory printed by Start.' + } + $base = if ($IsWindows) + { + Join-Path $env:LOCALAPPDATA 'QQQ/PrintingDevelopment' + } + else + { + Join-Path $HOME '.local/state/qqq-printing-development' + } + $StateDirectory = Join-Path $base ('environment-' + [Guid]::NewGuid().ToString('N')) +} +$StateDirectory = [IO.Path]::GetFullPath($StateDirectory) +if ($StateDirectory.StartsWith($sourceRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) +{ + throw 'Keep secret state outside the repository.' +} +Assert-StateLocation $StateDirectory +$ownerFile = Join-Path $StateDirectory 'owner.json' +$script:envFile = Join-Path $StateDirectory 'compose.env' +$createdState = $false +if (Test-Path -LiteralPath $StateDirectory) +{ + if (!(Test-Path -LiteralPath $ownerFile)) + { + throw 'Existing state directory lacks this tool ownership record.' + } + $owner = Get-Content -LiteralPath $ownerFile -Raw | ConvertFrom-Json + if ($owner.sourceRoot -cne $sourceRoot -or $owner.project -cnotmatch '^qbit-printing-dev-[a-f0-9]{32}$') + { + throw 'State ownership mismatch.' + } + $script:project = $owner.project + $Authentik = [bool]$owner.authentik + $savedNativeProfile = $null -ne $owner.PSObject.Properties['experimentalNativeAuth'] -and [bool]$owner.experimentalNativeAuth + if ($ExperimentalNativeAuth -and !$savedNativeProfile) + { + throw 'An existing standard environment cannot be repurposed for experimental native authentication.' + } + $ExperimentalNativeAuth = $savedNativeProfile + if ($ExperimentalNativeAuth -and $Action -eq 'Start') + { + throw 'Experimental native authentication is a disposable Test-only profile.' + } +} +else +{ + if ($Action -eq 'Stop') + { + throw 'Owned state directory does not exist.' + } + [IO.Directory]::CreateDirectory($StateDirectory) | Out-Null + Protect-State $StateDirectory + $createdState = $true + $script:project = 'qbit-printing-dev-' + [Guid]::NewGuid().ToString('N') + $commit = (& git -C $sourceRoot rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$') + { + throw 'Cannot record source identity.' + } + foreach ($field in @{PRINTING_DEV_PROJECT = $project; DEV_SOURCE_DIR = $sourceRoot.Replace('\', '/'); SOURCE_COMMIT = $commit; QQQ_HTTP_PORT = "$QqqPort"; MINIO_HTTP_PORT = "$MinioPort"; MINIO_ROOT_USER = 'printing-development'; MINIO_ROOT_PASSWORD = (Get-FixtureSecret); QQQ_DATABASE_PASSWORD = (Get-FixtureSecret); QQQ_ADMIN_PASSWORD = (Get-FixtureSecret); QQQ_MANAGEMENT_TOKEN = (Get-FixtureSecret) }.GetEnumerator()) + { + Set-FixtureEnvironment $field.Key $field.Value + } + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'compose.yaml') -Destination (Join-Path $StateDirectory 'compose.yaml') + if ($Authentik) + { + & (Join-Path $PSScriptRoot 'Invoke-Authentik.ps1') -Action Initialize -StateDirectory $StateDirectory -ProjectName $project -Port $AuthentikPort + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'compose.authentik.yaml') -Destination (Join-Path $StateDirectory 'compose.authentik.yaml') + } + if ($ExperimentalNativeAuth) + { + & (Join-Path $PSScriptRoot 'Invoke-NativeAuth.ps1') -Action Initialize -StateDirectory $StateDirectory -ProjectName $project + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'compose.native-auth.yaml') -Destination (Join-Path $StateDirectory 'compose.native-auth.yaml') + } + @{project = $project; sourceRoot = $sourceRoot; sourceCommit = $commit; authentik = [bool]$Authentik; experimentalNativeAuth = [bool]$ExperimentalNativeAuth; createdAt = [DateTime]::UtcNow.ToString('O') } | ConvertTo-Json | Set-Content -LiteralPath $ownerFile +} +$script:composeArguments = @('--project-name', $project, '--env-file', $envFile, '-f', (Join-Path $StateDirectory 'compose.yaml')) +if ($Authentik) +{ + $script:composeArguments += @('-f', (Join-Path $StateDirectory 'compose.authentik.yaml')) +} +if ($ExperimentalNativeAuth) +{ + $script:composeArguments += @('-f', (Join-Path $StateDirectory 'compose.native-auth.yaml')) +} +if ($Action -eq 'Stop') +{ + Stop-OwnedEnvironment + Write-Output "Stopped owned development environment. Reports remain in $StateDirectory" + return +} + +if ($ExperimentalNativeAuth) +{ + $nativePassed = $false + try + { + Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'build', 'native-qqq', 'native-client') + Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'up', '-d', 'authentik-server', 'authentik-worker', 'native-tls') + Invoke-Compose @('exec', '-T', 'native-tls', '/bin/sh', '/etc/caddy/copy-root.sh') + Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'up', '-d', 'native-qqq') + Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'run', '--rm', '--use-aliases', 'native-client') + & (Join-Path $PSScriptRoot 'Invoke-NativeAuth.ps1') -Action VerifyResult -StateDirectory $StateDirectory -ProjectName $project + $nativePassed = $true + } + finally + { + try + { + if (!$nativePassed) { Save-ProviderErrorSummary } + } + finally { Stop-OwnedEnvironment } + Write-Output "Protected experimental native-auth result: $(Join-Path $StateDirectory 'native-auth-result.json')" + } + return +} + +if ($Action -eq 'Start') +{ + $services = @('minio', 'qqq') + $profiles = @() + if ($Authentik) + { + $profiles = @('--profile', 'auth'); $services += @('authentik-server', 'authentik-worker') + } + try + { + Invoke-Compose ($profiles + @('up', '-d', '--build') + $services); & (Join-Path $PSScriptRoot 'Test-DevelopmentHost.ps1') -StateDirectory $StateDirectory + } + catch + { + if ($createdState) + { + Stop-OwnedEnvironment + }; throw + } + Write-Output "Owned environment: $StateDirectory" + Write-Output 'Read QQQ_ADMIN_PASSWORD in its protected compose.env; username is developer. No password is logged.' + return +} + +$testRun = 'run-' + [Guid]::NewGuid().ToString('N') +Set-FixtureEnvironment 'TEST_RUN' $testRun +Set-FixtureEnvironment 'WORKLOAD_MODE' $(if ($Workload -eq 10000) + { + 'burst' + } + else + { + 'idle' + }) +$reports = Join-Path $StateDirectory $testRun +[IO.Directory]::CreateDirectory($reports) | Out-Null +$passed = $false +try +{ + if ($Authentik) + { + Invoke-Compose @('--profile', 'auth', 'up', '-d', '--build', 'qqq', 'authentik-server', 'authentik-worker') + & (Join-Path $PSScriptRoot 'Test-DevelopmentHost.ps1') -StateDirectory $StateDirectory + & (Join-Path $PSScriptRoot 'Invoke-Authentik.ps1') -Action Test -StateDirectory $StateDirectory -ProjectName $project -Port (Get-RecordedAuthentikPort) + } + Invoke-Compose @('--profile', 'test', 'up', '-d', '--build', 'test-client') + $client = (& docker compose @composeArguments ps -a -q test-client).Trim() + $server = (& docker compose @composeArguments ps -a -q test-server).Trim() + if ($client -cnotmatch '^[a-f0-9]{64}$' -or $server -cnotmatch '^[a-f0-9]{64}$') + { + throw 'Owned fixture identity missing.' + } + $clientExit = Wait-OwnedContainer $client 1200 + Assert-WorkloadClientSucceeded -Id $client -ExitCode $clientExit -Run $testRun -Reports $reports + $serverExit = Wait-OwnedContainer $server 45 + foreach ($file in @('result.json', 'server-result.json', 'stopped.json', 's3/results.json', 's3/completed-checks.json')) + { + $destination = Join-Path $reports $file.Replace('/', '-') + & docker cp "${server}:/work/$testRun/$file" $destination + if ($LASTEXITCODE -ne 0) + { + throw 'Required sanitized fixture result missing.' + } + } + if ($clientExit -ne 0 -or $serverExit -ne 0) + { + throw 'Fixture failed; see protected results.' + } + $clientResult = Get-Content (Join-Path $reports 'result.json') -Raw | ConvertFrom-Json + $serverResult = Get-Content (Join-Path $reports 'server-result.json') -Raw | ConvertFrom-Json + $s3Result = Get-Content (Join-Path $reports 's3-results.json') -Raw | ConvertFrom-Json + if (!$clientResult.success -or !$serverResult.success -or !$s3Result.passed -or $clientResult.jobs -ne $Workload -or @($s3Result.checks).Count -ne 65) + { + throw 'Result invariants failed.' + } + $passed = $true +} +finally +{ + & docker compose @composeArguments logs --no-color --no-log-prefix test-server test-client 2>&1 | Set-Content -LiteralPath (Join-Path $reports 'fixture.log') + if ($ephemeral) + { + Stop-OwnedEnvironment + } + else + { + Assert-OwnedResource + Invoke-Compose @('--profile', 'test', 'rm', '--stop', '--force', 'test-client', 'test-server', 'minio-test') + } + @{passed = $passed; workload = $Workload; physicalPrinting = $false; tlsQualified = $false; finishedAt = [DateTime]::UtcNow.ToString('O') } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $reports 'execution.json') + Write-Output "Protected developer-test results: $reports" +} +if (!$passed) +{ + throw 'Development qualification did not pass.' +} diff --git a/dev/Invoke-NativeAuth.ps1 b/dev/Invoke-NativeAuth.ps1 new file mode 100644 index 0000000..e37b8cd --- /dev/null +++ b/dev/Invoke-NativeAuth.ps1 @@ -0,0 +1,107 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS +Prepares or verifies the opt-in, unpublished native-session Docker qualification. +.DESCRIPTION +Docker lifecycle belongs to Invoke-Dev.ps1. This helper creates only protected +synthetic input and reads a bounded sanitized report. It never installs trust, +uses host Maven caches, starts a browser, or operates the installed connector. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('Initialize', 'VerifyResult')][string] $Action, + [Parameter(Mandatory)][string] $StateDirectory, + [Parameter(Mandatory)][ValidatePattern('^qbit-printing-dev-[a-z0-9-]{8,48}$')][string] $ProjectName +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$coreSource = 'ecf0f550a0357cec6387c2b54efca128a405a629' +$clientSource = '346fc285198908c47a2ee9db7b9adb53d5e88c3c' +$state = [IO.Path]::GetFullPath($StateDirectory) +$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$comparison = if($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } +if($state.Equals($repository, $comparison) -or $state.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, $comparison)) +{ + throw 'Experimental credentials must be outside the checkout.' +} +$envFile = Join-Path $state 'compose.env' +$inputFile = Join-Path $state 'native-input.json' +$resultFile = Join-Path $state 'native-auth-result.json' +if(-not (Test-Path -LiteralPath $envFile -PathType Leaf)) +{ + throw 'The common entrypoint must initialize protected owned state first.' +} + +if($Action -eq 'VerifyResult') +{ + if(-not (Test-Path -LiteralPath $resultFile -PathType Leaf) -or (Get-Item -LiteralPath $resultFile).Length -gt 65536) + { + throw 'The experimental result is missing or exceeds its bounded size.' + } + try { $report = Get-Content -LiteralPath $resultFile -Raw | ConvertFrom-Json -AsHashtable } + catch { throw 'The experimental result is malformed; its contents are withheld.' } + $required = @('untrusted-tls-refused', 'typed-native-grant', 'real-provider-login', 'wrong-origin-cookie-denied', 'permission-denied-user', 'logout-replay-denied', 'server-refresh-after-expiry', 'provider-revocation-denied') + if($report.status -cne 'PASS' -or $report.scope -cne 'DEVELOPMENT_ONLY_UNRELEASED' -or + $report.coreSource -cne $coreSource -or $report.clientSource -cne $clientSource -or + $report.project -cne $ProjectName -or @($required | Where-Object { $_ -notin $report.checks }).Count -ne 0) + { + throw 'The experimental native-session qualification did not pass its exact source and required checks.' + } + Write-Output 'Experimental native-session provider checks passed; the candidate is unpublished and no installed/browser-UI acceptance is claimed.' + return +} + +if((Test-Path -LiteralPath $inputFile) -or (Get-Content -LiteralPath $envFile -Raw) -match '(?m)^NATIVE_') +{ + throw 'Experimental native tests require a fresh owned environment; partial state is not reused.' +} +try +{ + $auth = Get-Content -LiteralPath (Join-Path $state 'auth-secrets.json') -Raw | ConvertFrom-Json -AsHashtable + if($auth.project -cne $ProjectName -or $auth.clientId -cne $ProjectName) { throw 'Wrong owner.' } +} +catch { throw 'Private Authentik bootstrap state is absent or does not match this project.' } +$random = [Security.Cryptography.RandomNumberGenerator]::GetBytes(32) +$deniedPassword = [Convert]::ToBase64String($random).TrimEnd('=').Replace('+', '-').Replace('/', '_') +$issuer = "https://auth.printing.internal/application/o/$ProjectName/" +$inputData = [ordered]@{ + project = $ProjectName + coreSource = $coreSource + clientSource = $clientSource + providerOrigin = 'https://auth.printing.internal' + qqqOrigin = 'https://qqq.printing.internal' + issuer = $issuer + callback = 'https://callback.printing.internal/oauth/callback' + adminToken = $auth.bootstrapToken + clientId = $auth.clientId + clientSecret = $auth.clientSecret + authorizedUsername = 'printing-authorized' + authorizedPassword = $auth.userPassword + deniedUsername = 'printing-denied' + deniedPassword = $deniedPassword +} +$inputData | ConvertTo-Json | Set-Content -LiteralPath $inputFile -Encoding utf8 +@{ status = 'NOT_RUN'; scope = 'DEVELOPMENT_ONLY_UNRELEASED'; project = $ProjectName } | + ConvertTo-Json | Set-Content -LiteralPath $resultFile -Encoding utf8 +$values = [ordered]@{ + NATIVE_QQQ_DATABASE_PASSWORD = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) + NATIVE_QQQ_REFRESH_KEY = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) + NATIVE_AUTH_ISSUER = $issuer + NATIVE_AUTH_CLIENT_ID = $auth.clientId + NATIVE_AUTH_CLIENT_SECRET = $auth.clientSecret + NATIVE_AUTH_INPUT_FILE = $inputFile.Replace('\', '/') + NATIVE_AUTH_RESULT_FILE = $resultFile.Replace('\', '/') +} +foreach($entry in $values.GetEnumerator()) +{ + if($entry.Value -match '[\r\n'']') { throw 'Unsupported experimental environment value.' } +} +Add-Content -LiteralPath $envFile -Value @($values.GetEnumerator() | ForEach-Object { $_.Key + "='" + $_.Value + "'" }) -Encoding utf8 +if(-not $IsWindows) +{ + foreach($file in @($inputFile, $resultFile)) + { + [IO.File]::SetUnixFileMode($file, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite) + } +} diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 0000000..6cb00d4 --- /dev/null +++ b/dev/README.md @@ -0,0 +1,133 @@ +# Local Docker development + +Use this repository to start a separate QQQ application or run synthetic printing +checks. Nothing imports an installed connector's pairing, accesses physical +printers, or needs a cloud account. Docker Compose and PowerShell 7.4+ are required; +Git identifies the checkout. Docker must support Linux/amd64 containers. The first +build downloads pinned public images, Maven dependencies and the verified Windows +Core source archive. Java and .NET run inside Docker; neither host SDK is required. + +## Start, test and stop + +From the repository root, the default command runs 65 S3 checks and 40 network +jobs, saves protected reports, and removes its containers and synthetic volumes: + +```powershell +pwsh ./dev/Invoke-Dev.ps1 -Action Test +``` + +For a persistent development application, start it and keep the state-directory +path printed by the command: + +```powershell +pwsh ./dev/Invoke-Dev.ps1 -Action Start +pwsh ./dev/Invoke-Dev.ps1 -Action Test -StateDirectory '' +pwsh ./dev/Invoke-Dev.ps1 -Action Stop -StateDirectory '' +``` + +Open `http://127.0.0.1:19081`. The standard QQQ TABLE login username is `developer`. +Read `QQQ_ADMIN_PASSWORD` from the generated, protected `compose.env` locally; +never paste that file into an issue or commit it. There is no default password. +The host provides native printing screens, settings, a dedicated MinIO document +bucket and scheduled retention. No printer is enrolled automatically in this host. +The test workload uses a separate disposable database and object store. + +Add `-Authentik` when creating an environment or running a fresh test to include +real Authentik/PostgreSQL. See [the provider checks and remaining native grant +prerequisite](AUTHENTIK.md). Provider discovery does not establish successful OIDC +user login or refresh; the current QQQ RC.20 API lacks that native-client grant. + +```powershell +pwsh ./dev/Invoke-Dev.ps1 -Action Test -Authentik +pwsh ./dev/Invoke-Dev.ps1 -Action Test -Workload 10000 +``` + +The optional 10,000-job run keeps admission limits enabled, uses eight synthetic +clients across two tenants, and reports client process memory, latency and +receipt counts. It is a bounded burst, not a day-long soak or physical throughput +claim. The default 40-job run measures idle event delivery one job at a time. + +## Isolation and cleanup + +Each environment has a unique `qbit-printing-dev-` Compose project and +matching ownership labels. All published ports bind only to `127.0.0.1`: QQQ19081, +MinIO19095 and optional Authentik19010. Override them with `-QqqPort`, `-MinioPort` +and `-AuthentikPort` when creating a new environment. Occupied ports fail startup; +the command never stops another service to free them. PostgreSQL is not published. + +State defaults to the current user's application-data directory, outside the +checkout, with private filesystem permissions. A custom `-StateDirectory` must +also be outside the checkout. It holds generated fixture credentials, frozen +Compose files, ownership records and sanitized result files. Docker administrators +can inspect container configuration; use these credentials only for this fixture. + +`Stop` validates container, network and volume ownership before removing that +project's resources. **It deletes that environment's synthetic databases and +objects.** It leaves reports and ordinary image/build caches in place and never +runs a global prune. A fresh one-command `Test` performs this cleanup in `finally`, +including on failure. Tests against an existing environment remove only their +three test containers; finish with `Stop` to remove its disposable test volume and +the persistent development environment. Do not edit its frozen ownership files. + +Images are pinned by digest in Compose/Dockerfiles. Containers have bounded CPU +and memory, no automatic restart, and no Docker socket. The default profile has +no host credential or source mounts. QQQ/test containers additionally cap process +counts. Builds copy +only explicit source inputs. The QQQ and MinIO containers share their own network +namespace so the existing loopback S3 backend can be used without relaxing +production endpoint validation. + +## What the results mean + +- The S3 fixture exercises the actual QQQ `StorageAction`/S3 backend and AWS SDK, + including a multipart-size document, hashes, tenant namespaces, immutable + binding checks, retention and unrelated-object preservation. +- The workload uses the real current Java server and pinned Windows Core client: + native TABLE authentication, outbound WebSocket notifications, event-driven + claims, document downloads and durable server receipts. A fake file printer + verifies bytes and rejects duplicate submission. It cannot produce paper. +- The fixture's client receipt journal is intentionally simple; these results do + not replace production SQLite crash-recovery tests. MinIO fixture credentials + are unrestricted within their owned store and do not qualify production IAM. +- Connections here are HTTP/WebSocket on an isolated local project. TLS, provider + browser consent/renewal, real deployment policies and hardware behavior are + separate qualifications. No silent bearer-authentication fallback is added. + +Results include `result.json`, `server-result.json`, `s3-results.json` and +`execution.json` under a run directory. The persistent host also writes +`qqq-result.json`; optional provider checks write `auth-result.json`. Treat raw +fixture logs as private even though only synthetic accounts/documents are used. + +The client Dockerfile pins commit `346fc285198908c47a2ee9db7b9adb53d5e88c3c` +and verifies its source archive SHA-256 before compilation. The Java image builds +this checkout, including local edits, and records its Git HEAD for context; a Git +HEAD alone does not certify a dirty tree as that released artifact. This tooling +is outside the production JAR and adds no application runtime dependency. + +## Script regressions + +With Pester 5 installed, the initializer and cleanup-ownership tests need no Docker +or network. Disable Pester's unrelated registry fixture on Windows: + +```powershell +$configuration = New-PesterConfiguration +$configuration.Run.Path = './dev/tests' +$configuration.Run.Exit = $true +$configuration.TestRegistry.Enabled = $false +Invoke-Pester -Configuration $configuration +``` + +## Experimental native sign-in + +The separate [native sign-in profile](native-auth/README.md) builds an explicitly +selected upstream candidate and uses container-private TLS trust. It mounts only +public Caddy configuration and specific protected fixture input/result files. +It is opt-in, Test-only, and does not change the default released QQQ dependency. + +## Continuous integration + +The development workflow runs the Pester and PowerShell analysis checks on +Windows and Ubuntu for every pull request and main-branch push. Ubuntu also runs +the real 40-job/S3 Docker smoke. The longer experimental native-auth profile is +an explicit manual workflow option; it is not silently substituted for the +released dependency baseline. diff --git a/dev/Test-DevelopmentHost.ps1 b/dev/Test-DevelopmentHost.ps1 new file mode 100644 index 0000000..26c01f4 --- /dev/null +++ b/dev/Test-DevelopmentHost.ps1 @@ -0,0 +1,137 @@ +#Requires -Version 7.4 +<# .SYNOPSIS Checks the owned local QQQ development host without enrolling or printing. #> +[CmdletBinding()] +param([Parameter(Mandatory)][string]$StateDirectory) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$state = [IO.Path]::GetFullPath($StateDirectory) +$owner = Get-Content -LiteralPath (Join-Path $state 'owner.json') -Raw | ConvertFrom-Json +if ($owner.project -cnotmatch '^qbit-printing-dev-[a-f0-9]{32}$') +{ + throw 'Expected an owned development environment.' +} +$values = @{} +foreach ($line in Get-Content -LiteralPath (Join-Path $state 'compose.env')) +{ + if ($line -match '^([A-Z_]+)=(.*)$') + { + $values[$Matches[1]] = $Matches[2] + } +} +$port = [int]$values.QQQ_HTTP_PORT +if ($port -lt 1024 -or $port -gt 65535 -or $port -in @(18081, 18086)) +{ + throw 'Expected a separate development port.' +} +$origin = "http://127.0.0.1:$port" +$handler = [Net.Http.HttpClientHandler]::new() +$handler.UseProxy = $false +$handler.AllowAutoRedirect = $false +$handler.CookieContainer = [Net.CookieContainer]::new() +$client = [Net.Http.HttpClient]::new($handler) +$client.BaseAddress = [Uri]$origin +$client.Timeout = [TimeSpan]::FromSeconds(15) +$client.MaxResponseContentBufferSize = 1048576 +$client.DefaultRequestHeaders.Add('X-QQQ-Client', 'api/printing-development-check') +$client.DefaultRequestHeaders.Add('Origin', $origin) +$loggedIn = $false + +function Invoke-DevelopmentRequest([string]$Path, [string]$Method = 'GET', [string]$Authorization) +{ + <# .SYNOPSIS Sends one bounded native request, without returning credentials. #> + $request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $Path) + if ($Method -eq 'POST') + { + $request.Content = [Net.Http.StringContent]::new('{}', [Text.Encoding]::UTF8, 'application/json') + } + if ($Authorization) + { + $request.Headers.TryAddWithoutValidation('Authorization', $Authorization) | Out-Null + } + $response = $null + try + { + $response = $client.SendAsync($request).GetAwaiter().GetResult() + if (!$response.IsSuccessStatusCode) + { + throw 'Owned native endpoint returned an unsuccessful status.' + } + return $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + } + finally + { + if ($null -ne $response) + { + $response.Dispose() + }; $request.Dispose() + } +} +try +{ + $deadline = [DateTime]::UtcNow.AddSeconds(90) + do + { + try + { + $version = (Invoke-DevelopmentRequest '/qqq/v1/version') | ConvertFrom-Json; break + } + catch [Net.Http.HttpRequestException] + { + if ([DateTime]::UtcNow -ge $deadline) + { + throw + }; Start-Sleep -Seconds 1 + } + } while ($true) + if (@($version.extensions | Where-Object { $_.name -ceq 'printing' -and $_.version -ceq '2.0.0' }).Count -ne 1) + { + throw 'Expected current printing extension.' + } + $metadata = (Invoke-DevelopmentRequest '/qqq/v1/metaData/authentication') | ConvertFrom-Json + if ($metadata.type -cne 'TABLE_BASED') + { + throw 'Development host must retain standard TABLE authentication.' + } + $basic = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('developer:' + $values.QQQ_ADMIN_PASSWORD)) + try + { + $null = Invoke-DevelopmentRequest -Path '/qqq/v1/manageSession' -Method POST -Authorization $basic + } + finally + { + $basic = $null; $values = $null + } + $cookies = $handler.CookieContainer.GetCookies([Uri]$origin) + $cookie = $cookies['sessionId']; if ($null -eq $cookie) + { + $cookie = $cookies['sessionUUID'] + } + $loggedIn = $null -ne $cookie + if (!$loggedIn -or !$cookie.HttpOnly) + { + throw 'Native HttpOnly session was not established.' + } + $identity = (Invoke-DevelopmentRequest '/qqq/v1/qbit/printing/session') | ConvertFrom-Json + if ($identity.principalId -cne 'developer' -or $identity.tenantId -cne 'development') + { + throw 'Native development identity mismatch.' + } + $html = Invoke-DevelopmentRequest '/' + if (!$html.Contains(' SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(request.Method != HttpMethod.Get || request.RequestUri?.AbsolutePath != "/qqq/v1/version" + || request.Headers.Contains("Cookie") || request.Headers.Contains("Authorization")) + { + throw new InvalidOperationException("Discovery must precede authentication."); + } + Requests++; + string json = JsonSerializer.Serialize(new + { + apiVersion = "1.8.0", + features = new[] { "version", "metaData", "processes", "processCancel", "extensions", "widgets" }, + clientChannels = new[] { "api" }, + extensions = new[] { new { name = "printing", version, pathPrefix = "/qbit/printing" } } + }); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) }); + } + } +} diff --git a/dev/client/Program.cs b/dev/client/Program.cs new file mode 100644 index 0000000..20ed3bb --- /dev/null +++ b/dev/client/Program.cs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using Qqq.PrintAgent.Core.Authentication; +using Qqq.PrintAgent.Core.Printing; +internal static class Program { + static int Completed; static readonly byte[] Expected=Encoding.ASCII.GetBytes("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS"+new string(' ',980)+"^XZ"); + static async Task Main(string[] args) { + if(args is ["--check-discovery"]) { await DiscoveryChecks.RunAsync(); return 0; } + string folder=Path.GetFullPath(args[0]); string stage="input"; + using var timeout=new CancellationTokenSource(TimeSpan.FromMinutes(18)); + List lanes=[]; long peak=0; var timer=Stopwatch.StartNew(); + try { + using JsonDocument input=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(folder,"input.json"))); + stage="native_login_and_channels"; + bool idle=input.RootElement.GetProperty("idle").GetBoolean(); int perLane=input.RootElement.GetProperty("jobsPerLane").GetInt32(); int total=perLane*8; int index=0; + foreach(var value in input.RootElement.GetProperty("lanes").EnumerateArray()) { + Uri origin=new(value.GetProperty("origin").GetString()!); + Require(origin.IsLoopback && origin.Port is not (18081 or 18086),"disposable origin"); + var client=new QqqSessionClient(origin,allowLoopbackHttp:true); + await WorkloadDiscovery.DiscoverRealtimeAsync(client,timeout.Token); + await client.GetAuthenticationMetadataAsync(timeout.Token); + await client.SignInWithPasswordAsync(value.GetProperty("username").GetString()!,value.GetProperty("password").GetString()!,timeout.Token); + await client.BindDeviceAsync(new(value.GetProperty("deviceId").GetString()!,value.GetProperty("deviceToken").GetString()!),cancellationToken:timeout.Token); + var binding=await client.GetSessionIdentityAsync(timeout.Token); + Require(binding.TenantId==value.GetProperty("tenant").GetString(),"authoritative tenant"); + var connection=await Connection.Open(client,timeout.Token); + lanes.Add(new(client,connection,Path.Combine(folder,"lane-"+index++),perLane,folder)); + } + Require(lanes.Count==8,"eight lanes"); + // Authoritative native proof check: tenant A cannot bind a real tenant B device. + var foreign=input.RootElement.GetProperty("lanes")[4]; bool denied=false; + using(var outsider=new QqqSessionClient(new Uri(input.RootElement.GetProperty("lanes")[0].GetProperty("origin").GetString()!),allowLoopbackHttp:true)) { + await outsider.DiscoverAsync(timeout.Token);await outsider.GetAuthenticationMetadataAsync(timeout.Token); + await outsider.SignInWithPasswordAsync("operator",input.RootElement.GetProperty("lanes")[0].GetProperty("password").GetString()!,timeout.Token); + try {await outsider.BindDeviceAsync(new(foreign.GetProperty("deviceId").GetString()!,foreign.GetProperty("deviceToken").GetString()!),cancellationToken:timeout.Token);}catch(QqqSessionException){denied=true;} + Require(denied,"cross tenant proof refused");await outsider.SignOutAsync(timeout.Token); + } + stage="workload"; + var tasks=lanes.Select(lane=>lane.Run(timeout.Token)).ToArray(); + await File.WriteAllTextAsync(Path.Combine(folder,"ready"),"ready",timeout.Token); + Task all=Task.WhenAll(tasks); + while(!all.IsCompleted) { + peak=Math.Max(peak,Process.GetCurrentProcess().WorkingSet64); + await Task.WhenAny(all,Task.Delay(1000,timeout.Token));timeout.Token.ThrowIfCancellationRequested(); + } + await all; + Require(lanes.Sum(l=>l.Printer.Count)==total,"exact fake submission count"); + Require(lanes.All(l=>l.Journal.Pending().Count==0),"no unacknowledged receipts"); + stage="logout";foreach(var lane in lanes)await lane.Client.SignOutAsync(timeout.Token); + double[] latencies=lanes.SelectMany(l=>l.Latencies).Order().ToArray(); + await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(new { + success=true,jobs=total,idle,tenants=2,devices=8,elapsedSeconds=timer.Elapsed.TotalSeconds,jobsPerSecond=total/timer.Elapsed.TotalSeconds, + peakClientWorkingSetBytes=peak,p50EnqueueToAckMillis=latencies[total/2],p95EnqueueToAckMillis=latencies[(int)(total*0.95)],p99EnqueueToAckMillis=latencies[(int)(total*0.99)], + realCoreWorker=true,fakePrinterBackend=true,fixtureJournal=true,serverReceiptsDurable=true,crossTenantProofRefused=denied, + submissionReplays=idle?0:400,receiptReplays=idle?0:400,notificationToClaimMillis=lanes.SelectMany(l=>l.ClaimLatency).ToArray(),notificationToDownloadCompletedMillis=lanes.SelectMany(l=>l.DownloadLatency).ToArray(),periodicJobPolling=false,transport="HTTP/WS loopback; no TLS qualification",realPrinterCalls=0, + runtime=Environment.Version.ToString(),coreVersion=typeof(PrintWorker).Assembly.GetName().Version?.ToString() + },new JsonSerializerOptions{WriteIndented=true})); + Console.WriteLine("PASS real Core/Java network lifecycles; fake printer only");return 0; + } catch(Exception error) { + await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(new{success=false,stage,error=error.GetType().Name,reason=error is InvalidDataException?error.Message:null,completed=lanes.Sum(l=>l.Printer.Count)})); + Console.Error.WriteLine("FAIL "+stage+": "+error.GetType().Name);return 1; + } finally { + foreach(var lane in lanes)await lane.DisposeAsync(); + await File.WriteAllTextAsync(Path.Combine(folder,"done"),"done"); + } + } + static void Require(bool ok,string reason){if(!ok)throw new InvalidDataException(reason);} + sealed class Lane: IAsyncDisposable { + public QqqSessionClient Client{get;} public Connection Connection{get;} + public FakePrinter Printer{get;}=new(); public Journal Journal{get;} public List Latencies{get;}=[]; + readonly PrintApiClient api;readonly PrintWorker worker; readonly int target; readonly string root; long wakeAt; public List ClaimLatency{get;}=[]; public List DownloadLatency{get;}=[]; + public Lane(QqqSessionClient client,Connection connection,string folder,int target,string root) { + Client=client;Connection=connection;this.target=target;this.root=root;Printer.OnDownload=()=>DownloadLatency.Add(Stopwatch.GetElapsedTime(wakeAt).TotalMilliseconds);Directory.CreateDirectory(folder);Journal=new(folder); + api=PrintApiClient.ForSession(client).WithRealtime(connection.Channel,connection.Settings); + worker=new PrintWorker(api,Printer,Journal,options:new PrintWorkerOptions{Settings=connection.Settings,StagingDirectory=Path.Combine(folder,"staging"),SynchronizeInventory=false,RecoverIncomplete=false,ActiveJobChanged=job=>{if(job is not null)ClaimLatency.Add(Stopwatch.GetElapsedTime(wakeAt).TotalMilliseconds);}}); + } + public async Task Run(CancellationToken token) { + using var stop=CancellationTokenSource.CreateLinkedTokenSource(token); + Task ping=Liveness(stop.Token); + try { + while(Printer.Count seen=new(StringComparer.Ordinal);public int Count=>seen.Count; public Action? OnDownload{get;set;} + public IReadOnlyList Discover()=>[new("synthetic","No physical printer","fixture","none","READY",["ZPL"])]; + public async Task PrintFileAsync(PrintJob job,string path,CancellationToken token) {OnDownload?.Invoke();return await PrintAsync(job,await File.ReadAllBytesAsync(path,token),token);} + public Task PrintAsync(PrintJob job,byte[] content,CancellationToken token) { + Require(content.AsSpan().SequenceEqual(Expected),"downloaded bytes exact");Require(seen.Add(job.JobId),"no duplicate physical action");return Task.FromResult("synthetic-"+Count); + } + } + // Fixture journal is deliberately separate from the installed Windows SQLite journal. + sealed class Journal: IJobJournal,IDisposable { + readonly Dictionary states=new(StringComparer.Ordinal);readonly Dictionary pending=new(StringComparer.Ordinal);readonly StreamWriter log; + public PrintJob? LastJob{get;private set;}public (string JobId,PrintEvent Event) Last{get;private set;} + public Journal(string folder){log=new StreamWriter(new FileStream(Path.Combine(folder,"synthetic-receipts.jsonl"),FileMode.CreateNew,FileAccess.Write,FileShare.Read)){AutoFlush=true};} + public void TrackJob(PrintJob job,string? printer){LastJob=job;} + public string? GetState(string id)=>states.GetValueOrDefault(id); + public void Record(string id,PrintEvent evt){log.WriteLine(JsonSerializer.Serialize(new{jobId=id,@event=evt}));states[id]=evt.Status;pending.Add(evt.EventId,(id,evt));Last=(id,evt);} + public IReadOnlyList<(string JobId,PrintEvent Event)> Pending()=>pending.Values.ToList(); + public void Acknowledge(string id){pending.Remove(id);} + public IReadOnlyList<(string JobId,string State)> Incomplete()=>[]; + public void Dispose()=>log.Dispose(); + } + sealed class Connection:IAsyncDisposable { + public required PrintingRealtimeChannel Channel{get;init;}public required Task Reader{get;init;}public required CancellationTokenSource Lifetime{get;init;} + public required Channel Messages{get;init;}public required Channel Wakes{get;init;}public RealtimeSettings Settings{get;private set;}=new(); + public static async Task Open(QqqSessionClient client,CancellationToken token) { + var lifetime=CancellationTokenSource.CreateLinkedTokenSource(token);WebSocket socket=await client.ConnectPrintingAsync(TimeSpan.FromSeconds(20),TimeSpan.FromSeconds(60),token); + var channel=new PrintingRealtimeChannel(socket,new RealtimeSettings());var messages=System.Threading.Channels.Channel.CreateBounded(8); + var wakes=System.Threading.Channels.Channel.CreateBounded(new BoundedChannelOptions(1){FullMode=BoundedChannelFullMode.DropWrite}); + Task reader=channel.RunAsync(e=>{ + if(e.Type=="JOB_AVAILABLE"){wakes.Writer.TryWrite(Stopwatch.GetTimestamp());return;} + if(e.Type is "SETTINGS" or "DESIRED_STATE") {long revision=e.Payload.GetProperty("revision").GetInt64();if(revision!=0)throw new InvalidDataException("Unexpected policy change");return;} + if(e.Type is "PONG" or "ACK")return; + if(!messages.Writer.TryWrite(e))throw new IOException("Fixture envelope inbox full"); + },lifetime.Token); + _=reader.ContinueWith(t=>{wakes.Writer.TryComplete(t.Exception);messages.Writer.TryComplete(t.Exception);},TaskScheduler.Default); + var result=new Connection{Channel=channel,Reader=reader,Lifetime=lifetime,Messages=messages,Wakes=wakes}; + await channel.SendAsync("HELLO",new{agentVersion=PrintApiClient.AgentVersion,capabilities=new[]{"realtime-v1","REFRESH_INVENTORY","CANCEL_SPOOL"},lastAppliedRevision=0,lastSettingsRevision=0,limits=RealtimeSettings.ClientLimits},token:token); + RealtimeEnvelope welcome=await messages.Reader.ReadAsync(token);Require(welcome.Type=="WELCOME","welcome"); + result.Settings=welcome.Payload.GetProperty("settings").Deserialize(PrintApiClient.JsonOptions)!;result.Settings.Validate();channel.UpdateSettings(result.Settings); + await channel.SendAsync("STATE_APPLIED",new{lastAppliedRevision=welcome.Payload.GetProperty("desiredState").GetProperty("revision").GetInt64(),lastSettingsRevision=result.Settings.Revision},acknowledged:true,token:token);return result; + } + public async ValueTask DisposeAsync(){await Lifetime.CancelAsync();try{await Reader;}catch(Exception e)when(e is OperationCanceledException or IOException or WebSocketException){}Channel.Dispose();Lifetime.Dispose();} + } +} diff --git a/dev/client/Workload.csproj b/dev/client/Workload.csproj new file mode 100644 index 0000000..33b6f0e --- /dev/null +++ b/dev/client/Workload.csproj @@ -0,0 +1,10 @@ + + + Exenet10.0 + enableenable + true + + + /core/Qqq.PrintAgent.Core.dll + + diff --git a/dev/client/WorkloadDiscovery.cs b/dev/client/WorkloadDiscovery.cs new file mode 100644 index 0000000..ed5c9cb --- /dev/null +++ b/dev/client/WorkloadDiscovery.cs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +using Qqq.PrintAgent.Core.Authentication; + +internal static class WorkloadDiscovery +{ + internal static async Task DiscoverRealtimeAsync(QqqSessionClient client, CancellationToken cancellationToken) + { + QqqApiDiscovery discovery = await client.DiscoverAsync(cancellationToken); + // Core discovery validates strict stable SemVer; realtime uses the same major gate as ConnectPrintingAsync. + QqqApiDiscovery.Extension extension = discovery.Extensions.Single(item => string.Equals(item.Name, "printing", StringComparison.Ordinal)); + if(!Version.TryParse(extension.Version, out Version? version) || version.Major != 2) + { + throw new InvalidDataException("Native realtime printing API required."); + } + } +} diff --git a/dev/compose.authentik.yaml b/dev/compose.authentik.yaml new file mode 100644 index 0000000..fc77ca1 --- /dev/null +++ b/dev/compose.authentik.yaml @@ -0,0 +1,77 @@ +# Optional real provider reference. No Docker socket or published database port. +services: + authentik-postgres: + profiles: [auth] + platform: linux/amd64 + image: postgres@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea + environment: + POSTGRES_DB: authentik + POSTGRES_USER: authentik + POSTGRES_PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD:?Run the development initializer first} + volumes: + - authentik-database:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, "pg_isready -U authentik -d authentik"] + interval: 5s + timeout: 5s + retries: 30 + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first} + mem_limit: 512m + cpus: 1.0 + + authentik-server: + profiles: [auth] + platform: linux/amd64 + image: ghcr.io/goauthentik/server@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5 + command: server + depends_on: + authentik-postgres: + condition: service_healthy + environment: &authentik-environment + AUTHENTIK_POSTGRESQL__HOST: authentik-postgres + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD:?Run the development initializer first} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?Run the development initializer first} + AUTHENTIK_ERROR_REPORTING__ENABLED: "false" + AUTHENTIK_DISABLE_UPDATE_CHECK: "true" + AUTHENTIK_LOG_LEVEL: warning + ports: + - "127.0.0.1:${AUTHENTIK_HTTP_PORT:-19010}:9000" + volumes: + - authentik-data:/data + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first} + shm_size: 256mb + mem_limit: 1536m + cpus: 2.0 + + authentik-worker: + profiles: [auth] + platform: linux/amd64 + image: ghcr.io/goauthentik/server@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5 + command: worker + depends_on: + authentik-postgres: + condition: service_healthy + environment: + <<: *authentik-environment + AUTHENTIK_BOOTSTRAP_PASSWORD_HASH: ${AUTHENTIK_BOOTSTRAP_PASSWORD_HASH:?Run the development initializer first} + AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:?Run the development initializer first} + AUTHENTIK_BOOTSTRAP_EMAIL: developer@example.invalid + volumes: + - authentik-data:/data + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first} + shm_size: 256mb + mem_limit: 1536m + cpus: 2.0 + +volumes: + authentik-database: + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first} + authentik-data: + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first} diff --git a/dev/compose.native-auth.yaml b/dev/compose.native-auth.yaml new file mode 100644 index 0000000..5a8818c --- /dev/null +++ b/dev/compose.native-auth.yaml @@ -0,0 +1,75 @@ +# Opt-in unpublished core qualification. No service is a replacement for the +# released RC.20 TABLE development host or an installed printing deployment. +services: + authentik-server: + networks: + default: + aliases: + - auth.printing.internal + - qqq.printing.internal + - callback.printing.internal + + native-tls: + profiles: [native-auth] + image: caddy@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648 + platform: linux/amd64 + network_mode: service:authentik-server + depends_on: [authentik-server] + volumes: + - ${DEV_SOURCE_DIR}/dev/native-auth/Caddyfile:/etc/caddy/Caddyfile:ro + - ${DEV_SOURCE_DIR}/dev/native-auth/copy-root.sh:/etc/caddy/copy-root.sh:ro + - native-ca:/data + - native-trust:/trust + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} + cpus: 1 + mem_limit: 256m + + native-qqq: + profiles: [native-auth] + build: + context: ${DEV_SOURCE_DIR} + dockerfile: dev/native-auth/Dockerfile.server + image: ${PRINTING_DEV_PROJECT}-native-server:local + environment: + NATIVE_QQQ_PORT: "19082" + NATIVE_QQQ_ORIGIN: https://qqq.printing.internal + NATIVE_QQQ_DATABASE_PASSWORD: ${NATIVE_QQQ_DATABASE_PASSWORD:?Initialize experimental state first} + NATIVE_QQQ_REFRESH_KEY: ${NATIVE_QQQ_REFRESH_KEY:?Initialize experimental state first} + NATIVE_AUTH_ISSUER: ${NATIVE_AUTH_ISSUER:?Initialize experimental state first} + NATIVE_AUTH_CLIENT_ID: ${NATIVE_AUTH_CLIENT_ID:?Initialize experimental state first} + NATIVE_AUTH_CLIENT_SECRET: ${NATIVE_AUTH_CLIENT_SECRET:?Initialize experimental state first} + NATIVE_AUTH_REDIRECT_URI: https://callback.printing.internal/oauth/callback + volumes: + - native-trust:/trust:ro + - native-qqq-data:/data + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} + cpus: 2 + mem_limit: 1g + + native-client: + profiles: [native-auth] + build: + context: ${DEV_SOURCE_DIR} + dockerfile: dev/native-auth/Dockerfile.client + image: ${PRINTING_DEV_PROJECT}-native-client:local + volumes: + - native-trust:/trust:ro + - ${NATIVE_AUTH_INPUT_FILE:?Initialize experimental state first}:/work/input.json:ro + - ${NATIVE_AUTH_RESULT_FILE:?Initialize experimental state first}:/work/result.json + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} + cpus: 2 + mem_limit: 512m + +volumes: + native-ca: + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} + native-trust: + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} + native-qqq-data: + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} diff --git a/dev/compose.yaml b/dev/compose.yaml new file mode 100644 index 0000000..ac3f684 --- /dev/null +++ b/dev/compose.yaml @@ -0,0 +1,105 @@ +# Every published port is bound to host loopback; no live printing configuration is imported. +x-owned: &owned + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?owned project required} + restart: "no" + cpus: 2 + mem_limit: 1g + pids_limit: 256 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + read_only: true + tmpfs: ["/tmp:rw,noexec,nosuid,size=128m,mode=1777"] + +services: + minio: + <<: *owned + image: cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56 + user: "0:0" + command: [server, /data, --address, ":9000"] + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?fixture secret required} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?fixture secret required} + volumes: [minio-data:/data] + ports: + - "127.0.0.1:${MINIO_HTTP_PORT:-19095}:9000" + - "127.0.0.1:${QQQ_HTTP_PORT:-19081}:${QQQ_HTTP_PORT:-19081}" + qqq: + <<: *owned + build: + context: ${DEV_SOURCE_DIR:?source required} + dockerfile: dev/Dockerfile.server + args: + SOURCE_COMMIT: ${SOURCE_COMMIT:?source identity required} + image: ${PRINTING_DEV_PROJECT}-server:local + network_mode: service:minio + depends_on: [minio] + environment: + QQQ_HTTP_PORT: ${QQQ_HTTP_PORT:-19081} + QQQ_DATABASE_PASSWORD: ${QQQ_DATABASE_PASSWORD:?fixture secret required} + QQQ_ADMIN_PASSWORD: ${QQQ_ADMIN_PASSWORD:?fixture secret required} + QQQ_MANAGEMENT_TOKEN: ${QQQ_MANAGEMENT_TOKEN:?fixture secret required} + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + volumes: [qqq-data:/data] + minio-test: + <<: *owned + profiles: [test] + image: cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56 + user: "0:0" + command: [server, /data, --address, ":9000"] + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + tmpfs: ["/tmp:rw,noexec,nosuid,size=64m,mode=1777", "/data:rw,noexec,nosuid,size=900m,mode=1777"] + test-server: + <<: *owned + profiles: [test] + build: + context: ${DEV_SOURCE_DIR} + dockerfile: dev/Dockerfile.server + args: + SOURCE_COMMIT: ${SOURCE_COMMIT} + image: ${PRINTING_DEV_PROJECT}-server:local + network_mode: service:minio-test + depends_on: [minio-test] + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + TEST_RUN: ${TEST_RUN:-run-uninitialized} + WORKLOAD_MODE: ${WORKLOAD_MODE:-idle} + volumes: [test-data:/work] + entrypoint: [/bin/sh, /app/run-tests.sh] + healthcheck: + test: [CMD-SHELL, 'test -f "/work/$$TEST_RUN/input.json"'] + interval: 1s + timeout: 2s + retries: 120 + test-client: + <<: *owned + profiles: [test] + build: + context: ${DEV_SOURCE_DIR} + dockerfile: dev/Dockerfile.client + image: ${PRINTING_DEV_PROJECT}-client:local + network_mode: service:minio-test + depends_on: + test-server: + condition: service_healthy + volumes: [test-data:/work] + command: ["/work/${TEST_RUN:-run-uninitialized}"] + +volumes: + minio-data: + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} + qqq-data: + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} + test-data: + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} +networks: + default: + labels: + io.qrun.printing.dev: ${PRINTING_DEV_PROJECT} diff --git a/dev/java/DevHost.java b/dev/java/DevHost.java new file mode 100644 index 0000000..0ea5855 --- /dev/null +++ b/dev/java/DevHost.java @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.kingsrook.qbits.printing; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails; +import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** Docker-only developer host. Compose publishes its port to host loopback only. */ +public final class DevHost extends PrintingLocalHost +{ + private static final String TABLE = "printing_documents"; + + private DevHost() throws Exception + { + super(new PrintingLocalSettings("jdbc:h2:file:/data/printing", "sa", required("QQQ_DATABASE_PASSWORD"), "development", + Integer.parseInt(required("QQQ_HTTP_PORT")), required("QQQ_MANAGEMENT_TOKEN"), "developer", required("QQQ_ADMIN_PASSWORD"))); + } + + public static void main(String[] args) throws Exception + { + waitStorage(); + if(args.length == 1 && "--wait-storage".equals(args[0])) + { + return; + } + DevHost host = new DevHost(); + QApplicationLauncher.run(host, new QApplicationLauncherConfig().withServerCustomizer(server -> + { + host.configure(server, true); + // Container interface only. The immutable Compose mapping binds host 127.0.0.1. + var original = server.getJavalinConfigCustomizer(); + server.withJavalinConfigCustomizer(config -> + { + original.accept(config); + config.jetty.host = "0.0.0.0"; + }); + })); + } + + @Override + public QInstance defineQInstance() throws QException + { + QInstance instance = super.defineQInstance(); + S3BackendMetaData backend = new S3BackendMetaData().withName("printing_development_storage").withBucketName("printing-development") + .withEndpoint("http://127.0.0.1:9000").withPathStyleAccess(true).withRegion("us-east-1") + .withAccessKey(required("MINIO_ROOT_USER")).withSecretKey(required("MINIO_ROOT_PASSWORD")); + backend.setConnectTimeoutMillis(3000); + backend.setSocketTimeoutMillis(15000); + instance.addBackend(backend); + instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName()) + .withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE).withFileNameFieldName("id").withContentsFieldName("content")) + .withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB))); + var storage = S3ClientFactory.getClient(backend); + { + try + { + storage.headBucket(HeadBucketRequest.builder().bucket("printing-development").build()); + } + catch(S3Exception missing) + { + if(missing.statusCode() != 404) + { + throw missing; + } + storage.createBucket(CreateBucketRequest.builder().bucket("printing-development").build()); + } + } + PrintingQBitConfig config = instance.getQBits().values().stream().map(value -> value.getConfig()) + .filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow(); + config.withStorageTableName(TABLE); + config.getService().configureStorage(instance, TABLE); + return instance; + } + + private static void waitStorage() throws Exception + { + try(HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build()) + { + long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos(); + while(System.nanoTime() < deadline) + { + try + { + if(client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:9000/minio/health/live")) + .timeout(Duration.ofSeconds(2)).GET().build(), HttpResponse.BodyHandlers.discarding()).statusCode() == 200) + { + return; + } + } + catch(java.io.IOException notReady) + { + // Only the fixture startup window retries; application work is never retried here. + } + Thread.sleep(200); + } + throw new IllegalStateException("Owned MinIO startup timed out"); + } + } + + private static String required(String name) + { + String value = System.getenv(name); + if(value == null || value.isBlank()) + { + throw new IllegalArgumentException("Missing development environment setting: " + name); + } + return value; + } +} diff --git a/dev/java/S3PrintingQualification.java b/dev/java/S3PrintingQualification.java new file mode 100644 index 0000000..5c434b6 --- /dev/null +++ b/dev/java/S3PrintingQualification.java @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +import com.kingsrook.qbits.printing.*; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.*; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.*; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.*; +import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality; +import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.sql.*; +import java.time.Instant; +import java.util.*; +import org.json.*; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; + +/** Non-listening synthetic harness. Never routes work to a Windows device. */ +public final class S3PrintingQualification { + private static final String TABLE = "printing_documents"; + private static final JSONArray CHECKS = new JSONArray(); + private static final JSONArray DOCUMENTS = new JSONArray(); + private static final Map OBJECT_KEYS = new HashMap<>(); + private static DriverDataSource database; + private static S3Client s3; + private static String bucket; + private static String otherBucket; + + public static void main(String[] args) throws Exception { + Path output = Path.of(args[0]).toAbsolutePath(); + Files.createDirectories(output); + String run = args[2]; + bucket = "printing-fixture-" + run; + otherBucket = "printing-canary-" + run; + S3BackendMetaData backend = new S3BackendMetaData().withName("printing_s3_" + run) + .withBucketName(bucket).withEndpoint(args[1]).withPathStyleAccess(true).withRegion("us-east-1") + .withAccessKey(System.getenv("MINIO_ROOT_USER")).withSecretKey(System.getenv("MINIO_ROOT_PASSWORD")); + backend.setConnectTimeoutMillis(3000); + backend.setSocketTimeoutMillis(15000); + QInstance instance = new QInstance(); + // Required metadata only: this harness opens no HTTP endpoint or user session. + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("fixture").withType(QAuthenticationType.MOCK)); + instance.addBackend(backend); + instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName()) + .withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE) + .withFileNameFieldName("id").withContentsFieldName("content")).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB))); + database = new DriverDataSource("jdbc:h2:file:" + output.resolve("synthetic-data").toString().replace('\\', '/'), "sa", ""); + try { + s3 = S3ClientFactory.getClient(backend); + s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build()); + s3.createBucket(CreateBucketRequest.builder().bucket(otherBucket).build()); + s3.putObject(PutObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build(), RequestBody.fromString("synthetic unrelated object")); + s3.putObject(PutObjectRequest.builder().bucket(otherBucket).key("canary.txt").build(), RequestBody.fromString("synthetic separate bucket")); + PrintingService a = service(instance, "fixture-tenant-a"); + PrintingService b = service(instance, "fixture-tenant-b"); + String deviceA = enroll(a); + String deviceB = enroll(b); + byte[] pdf = PrintingTestDocuments.pdf(); + byte[] zpl = "^XA^FO20,20^ADN,30,18^FDSYNTHETIC STORAGE TEST ONLY^FS^XZ".getBytes(StandardCharsets.US_ASCII); + byte[] large = new byte[6 * 1024 * 1024 + 71]; + Arrays.fill(large, (byte)'A'); + System.arraycopy("^XA^FX".getBytes(StandardCharsets.US_ASCII), 0, large, 0, 6); + System.arraycopy("^FS^XZ".getBytes(StandardCharsets.US_ASCII), 0, large, large.length - 6, 6); + String pdfJob = submitCheck(a, deviceA, "small-pdf", "PDF", pdf, false); + finish(a, deviceA, pdfJob, "SPOOLED"); + String zplJob = submitCheck(a, deviceA, "small-zpl", "ZPL", zpl, false); + finish(a, deviceA, zplJob, "SPOOLED"); + String largeJob = submitCheck(a, deviceA, "multipart-zpl", "ZPL", large, true); + finish(a, deviceA, largeJob, "SPOOLED"); + String foreignJob = submitCheck(b, deviceB, "foreign-zpl", "ZPL", zpl, false); + finish(b, deviceB, foreignJob, "SPOOLED"); + String foreignRef = reference(foreignJob); + String ownRef = reference(pdfJob); + check(!ownRef.split("/")[1].equals(foreignRef.split("/")[1]), "tenant prefixes differ"); + expectStatus(404, () -> a.content(deviceA, foreignJob), "foreign tenant job download denied"); + expectStatus(404, () -> a.content(deviceB, foreignJob), "foreign tenant device rejected"); + execute("UPDATE printing_job SET content_reference=? WHERE id=?", foreignRef, pdfJob); + expectStatus(409, () -> a.content(deviceA, pdfJob), "foreign stored reference rejected"); + execute("UPDATE printing_job SET content_reference=? WHERE id=?", ownRef, pdfJob); + check(Arrays.equals(zpl, b.content(deviceB, foreignJob)), "foreign object unchanged after rejection"); + String failed = submitCheck(a, deviceA, "protected-failed", "ZPL", zpl, false); + finish(a, deviceA, failed, "FAILED"); + String unknown = submitCheck(a, deviceA, "protected-unknown", "ZPL", zpl, false); + finish(a, deviceA, unknown, "OUTCOME_UNKNOWN"); + String active = a.submit(request(deviceA, "protected-queued", "ZPL", zpl)).getString("jobId"); + String old = Instant.now().minusSeconds(400L * 86400).toString(); + for(String id : List.of(pdfJob, largeJob, foreignJob, failed, unknown, active)) { + execute("UPDATE printing_job SET created_at=?,completed_at=? WHERE id=?", old, old, id); + } + int before = objectCount(); + check(a.runRetention() == 2, "tenant A retention removes exactly two aged successful documents"); + check(objectCount() == before - 2, "object count reflects exact two removals"); + missing(OBJECT_KEYS.get(ownRef)); + missing(OBJECT_KEYS.get(reference(largeJob))); + expectStatus(410, () -> a.content(deviceA, pdfJob), "expired document returns 410"); + check(a.submit(request(deviceA, "small-pdf", "PDF", pdf)).getString("jobId").equals(pdfJob), "idempotent replay survives retention without reupload"); + check(objectCount() == before - 2, "post-retention replay creates no object"); + for(String id : List.of(zplJob, foreignJob, failed, unknown, active)) { + check(s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey(reference(id))).build()).contentLength() == zpl.length, + "protected object retained: " + status(id)); + } + check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build()).asUtf8String().equals("synthetic unrelated object"), "unrelated same-bucket canary preserved"); + check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(otherBucket).key("canary.txt").build()).asUtf8String().equals("synthetic separate bucket"), "separate bucket canary preserved"); + check(a.runRetention() == 0, "repeated cleanup is idempotent"); + check(b.runRetention() == 1, "tenant B cleanup independently removes its one eligible document"); + check(s3.listMultipartUploads(ListMultipartUploadsRequest.builder().bucket(bucket).build()).uploads().isEmpty(), "no incomplete multipart uploads remain"); + check(QContext.getQSession() == null, "storage restores original absent QQQ session"); + JSONObject result = new JSONObject().put("passed", true).put("timestamp", Instant.now().toString()) + .put("checks", CHECKS).put("documents", DOCUMENTS).put("runtimeJarCount", 204) + .put("appRevision", System.getenv("PRINTING_SOURCE_COMMIT")) + .put("h2Version", org.h2.engine.Constants.VERSION).put("retentionDays", a.getRetentionSettings().getInt("days")) + .put("transport", "loopback HTTP S3-compatible only").put("hardwarePrinting", false) + .put("qualifies", "QQQ StorageAction / AWS SDK v2 S3 data-plane behavior against disposable MinIO") + .put("doesNotQualify", new JSONArray(List.of("AWS IAM", "remote TLS", "production bucket ownership", "Object Lock/version lifecycle", "remote deployment", "hardware printing"))); + Files.writeString(output.resolve("results.json"), result.toString(2) + "\n"); + System.out.println("PASS " + CHECKS.length() + " checks; " + DOCUMENTS.length() + " synthetic document round trips"); + } finally { + Files.writeString(output.resolve("completed-checks.json"), new JSONObject().put("checks", CHECKS).put("documents", DOCUMENTS).toString(2) + "\n"); + S3ClientFactory.clearCache(); + QContext.clear(); + } + } + private static PrintingService service(QInstance instance, String tenant) throws Exception { + PrintingService service = new PrintingService(database, tenant); + service.initialize(); service.configureStorage(instance, TABLE); return service; + } + private static String enroll(PrintingService service) throws Exception { + String id = service.enroll(service.createEnrollment("fixture", "Synthetic station") + .put("deviceName", "Synthetic station").put("agentVersion", "isolated-storage-fixture")).getString("deviceId"); + service.inventory(id, new JSONArray().put(new JSONObject().put("printerId", "synthetic-never-dispatched") + .put("name", "Synthetic storage-only destination").put("status", "READY").put("formats", new JSONArray(List.of("PDF", "ZPL"))))); + service.enablePrinter(id, "synthetic-never-dispatched", true); return id; + } + private static JSONObject request(String device, String key, String format, byte[] bytes) { + return new JSONObject().put("deviceId", device).put("printerId", "synthetic-never-dispatched").put("title", "Synthetic storage qualification") + .put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key); + } + private static String submitCheck(PrintingService service, String device, String key, String format, byte[] bytes, boolean multipart) throws Exception { + JSONObject request = request(device, key, format, bytes); + String id = service.submit(request).getString("jobId"); + int count = objectCount(); + check(id.equals(service.submit(request).getString("jobId")) && count == objectCount(), key + " stable replay creates no object"); + check(service.claim(device).getJSONObject("job").getString("jobId").equals(id), key + " claimed through production service"); + byte[] actual = service.content(device, id); + check(Arrays.equals(bytes, actual) && sha(bytes).equals(sha(actual)), key + " download bytes and SHA256 match"); + String ref = reference(id); + String actualKey = objectKey(ref); + HeadObjectResponse head = s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(actualKey).build()); + check(head.contentLength() == bytes.length, key + " object length exact"); + check(head.contentType().equals(format.equals("PDF") ? "application/pdf" : "application/vnd.zebra-zpl"), key + " content type correct"); + check(head.eTag().contains("-2") == multipart, key + " expected single/multipart object ETag"); + try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT content_base64,content_storage_table FROM printing_job WHERE id=?")) { + statement.setString(1, id); try(var rows = statement.executeQuery()) { check(rows.next() && rows.getString(1) == null && TABLE.equals(rows.getString(2)), key + " payload external, no inline database copy"); } + } + DOCUMENTS.put(new JSONObject().put("case", key).put("format", format).put("bytes", bytes.length).put("sha256", sha(bytes)).put("multipart", multipart).put("etag", head.eTag()) + .put("objectKeyEqualsStoredReference", actualKey.equals(ref)).put("objectKeyPrefixBeforeReference", actualKey.substring(0, actualKey.length() - ref.length()))); + return id; + } + private static void finish(PrintingService service, String device, String id, String status) throws Exception { + for(String phase : List.of("RECEIVED", "SUBMITTING", status)) service.event(device, id, new JSONObject().put("eventId", UUID.randomUUID().toString()).put("status", phase).put("message", "Synthetic receipt; no physical device or spooler used")); + } + private static String sha(byte[] bytes) throws Exception { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + private static int objectCount() { return s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().size(); } + private static String objectKey(String reference) { + String known = OBJECT_KEYS.get(reference); + if(known != null) return known; + List matches = s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().stream() + .map(S3Object::key).filter(key -> key.endsWith(reference)).toList(); + if(matches.size() != 1) throw new AssertionError("Expected exactly one synthetic object for stored reference"); + String key = matches.getFirst(); + String prefix = key.substring(0, key.length() - reference.length()); + if(!prefix.isEmpty() && !prefix.matches("\\\\+")) throw new AssertionError("Unexpected physical S3 key prefix"); + OBJECT_KEYS.put(reference, key); + return key; + } + private static String reference(String id) throws Exception { return field(id, "content_reference"); } + private static String status(String id) throws Exception { return field(id, "status"); } + private static String field(String id, String field) throws Exception { + try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT " + field + " FROM printing_job WHERE id=?")) { + statement.setString(1,id); try(var rows=statement.executeQuery()) { if(!rows.next()) throw new AssertionError("Missing synthetic job"); return rows.getString(1); } + } + } + private static void execute(String sql, String... args) throws Exception { + try(var c = database.getConnection(); var statement = c.prepareStatement(sql)) { for(int i=0; i launches=new ArrayList<>(); List services=new ArrayList<>(); + List devices=new ArrayList<>(); JSONArray inputs=new JSONArray(); + boolean idle=args.length>1 && args[1].equals("idle"); int total=idle?40:10000; long started=System.nanoTime(); int exit=1; + try { + for(int tenant=0;tenant<2;tenant++) { + PrintingLocalHost host=new PrintingLocalHost(new PrintingLocalSettings(jdbc,"sa","","tenant-"+tenant,0,"fixture-only-management-token-123456","operator",password)); + AtomicReference app=new AtomicReference<>(); + QApplicationLauncher launch=QApplicationLauncher.run(host,new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server->{host.configure(server,false);server.withJavalinConfigurationCustomizer(app::set);})); + launches.add(launch); + PrintingService service=launch.getQInstance().getQBits().values().stream().map(v->v.getConfig()).filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService(); + services.add(service); + for(int lane=0;lane<4;lane++) { + JSONObject enrolled=service.enrollForUser(service.createEnrollment("default","Synthetic lane").put("deviceName","Synthetic lane").put("agentVersion","qualification"),"operator"); + String device=enrolled.getString("deviceId");devices.add(device); + service.inventory(device,new JSONArray().put(new JSONObject().put("printerId","synthetic").put("name","No physical printer").put("status","READY").put("formats",new JSONArray().put("ZPL"))));service.enablePrinter(device,"synthetic",true); + inputs.put(new JSONObject().put("origin","http://127.0.0.1:"+app.get().port()+"/").put("username","operator").put("password",password).put("tenant","tenant-"+tenant).put("deviceId",device).put("deviceToken",enrolled.getString("deviceToken"))); + } + } + // Only synthetic credentials inside the already protected run directory. + Files.writeString(dir.resolve("input.json"),new JSONObject().put("lanes",inputs).put("idle",idle).put("jobsPerLane",total/8).toString()); + await(dir.resolve("ready"),Duration.ofMinutes(2)); + byte[] payload=("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS"+" ".repeat(980)+"^XZ").getBytes(StandardCharsets.US_ASCII); + String encoded=Base64.getEncoder().encodeToString(payload); + long producing=System.nanoTime(); + if(idle) { for(int job=0;job> futures=new ArrayList<>(); + for(int lane=0;lane<8;lane++) { final int index=lane; + futures.add(executor.submit(()->{try{ + PrintingService service=services.get(index/4); + for(int job=0;job<1250;job++) { + JSONObject request=new JSONObject().put("deviceId",devices.get(index)).put("printerId","synthetic").put("title","Synthetic workload "+job).put("format","ZPL").put("contentBase64",encoded).put("idempotencyKey","lane-"+index+"-"+job); + JSONObject submitted=service.submit(request); + if(job%25==0 && !submitted.getString("jobId").equals(service.submit(request).getString("jobId")))throw new IllegalStateException("Replay duplicated job"); + } + }catch(Exception e){throw new RuntimeException(e);}})); + } + for(var future:futures)future.get(15,TimeUnit.MINUTES); + } + double producerSeconds=(System.nanoTime()-producing)/1e9; + Files.writeString(dir.resolve("producer-done"),"done"); + await(dir.resolve("done"),Duration.ofMinutes(15)); + JSONObject client=new JSONObject(Files.readString(dir.resolve("result.json"))); + if(!client.getBoolean("success"))throw new IllegalStateException("Client reported failure"); + JSONObject report=new JSONObject().put("success",true).put("jobs",total).put("receipts",total*3).put("idle",idle).put("producerSeconds",producerSeconds).put("elapsedSeconds",(System.nanoTime()-started)/1e9).put("realPrinterCalls",0); + try(Connection connection=DriverManager.getConnection(jdbc,"sa","");Statement query=connection.createStatement()) { + require(query,"SELECT COUNT(*) FROM printing_job",total); + require(query,"SELECT COUNT(*) FROM printing_job WHERE status='SPOOLED'",total); + require(query,"SELECT COUNT(*) FROM printing_event",total*3); + require(query,"SELECT COUNT(*) FROM printing_document_orphan",0); + for(int tenant=0;tenant<2;tenant++) { + require(query,"SELECT COUNT(*) FROM printing_job WHERE tenant_id='tenant-"+tenant+"'",total/2); + require(query,"SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant-"+tenant+"' AND device_id=''",(total/2L)*payload.length); + } + JSONArray cross=new JSONArray();for(int tenant=0;tenant<2;tenant++) { + String foreign=services.get(1-tenant).jobsPage(1,null).getJSONArray("jobs").getJSONObject(0).getString("jobId"); + try {services.get(tenant).getJob(foreign);throw new IllegalStateException("Foreign job visible");}catch(PrintingException denied){if(denied.getStatus()!=404)throw denied;} + cross.put(true); + } + report.put("tenantIsolation",cross).put("retainedAccountingVerified",true).put("bytesPerDocument",payload.length); + } + Files.writeString(dir.resolve("server-result.json"),report.toString(2));exit=0; + } catch(Throwable failure) { + Files.writeString(dir.resolve("server-error.json"),new JSONObject().put("error",failure.getClass().getName()).put("cause",failure.getCause()==null?"":failure.getCause().getClass().getName()).toString()); + } finally { + for(int i=launches.size()-1;i>=0;i--)launches.get(i).stop(); + try(Connection connection=DriverManager.getConnection(jdbc,"sa","");Statement query=connection.createStatement()){query.execute("SHUTDOWN");} + Files.writeString(dir.resolve("stopped.json"),new JSONObject().put("normalStop",true).put("exitCode",exit).toString()); + } + System.exit(exit); + } + static void await(Path file,Duration limit)throws Exception {long deadline=System.nanoTime()+limit.toNanos();while(!Files.exists(file)){if(System.nanoTime()>deadline)throw new TimeoutException("Fixture deadline");Thread.sleep(100);}} + static void require(Statement query,String sql,long expected)throws Exception {try(ResultSet rows=query.executeQuery(sql)){if(!rows.next()||rows.getLong(1)!=expected)throw new IllegalStateException("Synthetic count/accounting mismatch");}} +} diff --git a/dev/log4j2.xml b/dev/log4j2.xml new file mode 100644 index 0000000..2d65f4a --- /dev/null +++ b/dev/log4j2.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev/native-auth/Caddyfile b/dev/native-auth/Caddyfile new file mode 100644 index 0000000..2451363 --- /dev/null +++ b/dev/native-auth/Caddyfile @@ -0,0 +1,20 @@ +{ + admin off + skip_install_trust + auto_https disable_redirects +} + +https://auth.printing.internal { + tls internal + reverse_proxy 127.0.0.1:9000 +} + +https://qqq.printing.internal { + tls internal + reverse_proxy native-qqq:19082 +} + +https://callback.printing.internal { + tls internal + reverse_proxy native-client:8088 +} diff --git a/dev/native-auth/Dockerfile.client b/dev/native-auth/Dockerfile.client new file mode 100644 index 0000000..018b7ba --- /dev/null +++ b/dev/native-auth/Dockerfile.client @@ -0,0 +1,17 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b AS build +# Same exact published production Core source as the normal development workload. +ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fca71fb6 https://github.com/QRun-IO/qqq-print-agent/archive/346fc285198908c47a2ee9db7b9adb53d5e88c3c.tar.gz /tmp/client.tar.gz +RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz +RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo +RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/ +WORKDIR /fixture +COPY dev/native-auth/client . +RUN dotnet build -c Release --nologo -o /out + +FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b +WORKDIR /app +COPY --from=build /out . +COPY dev/native-auth/start-client.sh /app/start-client.sh +RUN chmod 0555 /app/start-client.sh +ENV DOTNET_EnableDiagnostics=0 +ENTRYPOINT ["/app/start-client.sh"] diff --git a/dev/native-auth/Dockerfile.server b/dev/native-auth/Dockerfile.server new file mode 100644 index 0000000..6f60af8 --- /dev/null +++ b/dev/native-auth/Dockerfile.server @@ -0,0 +1,26 @@ +FROM maven:3.9.11-eclipse-temurin-21@sha256:6fdc855a6ed81d288ca7ca37ac6ff5e9308b612485c0801d70b25a858c83d237 AS build +# DEVELOPMENT ONLY: exact public candidate, never installed into a host Maven cache. +ADD --checksum=sha256:d99bafdf5520d0dc039b598787c003f23505cec3a652eb8ded496a24c6ed6060 https://github.com/QRun-IO/qqq/archive/ecf0f550a0357cec6387c2b54efca128a405a629.tar.gz /tmp/core.tar.gz +RUN mkdir /candidate && tar -xzf /tmp/core.tar.gz -C /candidate --strip-components=1 && rm /tmp/core.tar.gz +WORKDIR /candidate +RUN mvn -B -Dmaven.repo.local=/candidate-maven -Drevision=4.1.0-native-dev-ecf0f550 -Dmaven.test.skip=true -pl qqq-bom,qqq-backend-core,qqq-backend-module-rdbms,qqq-backend-module-filesystem,qqq-middleware-javalin -am install +WORKDIR /printing +COPY pom.xml mvnw ./ +COPY .mvn .mvn +COPY checkstyle checkstyle +COPY src src +COPY LICENSE NOTICE ./ +RUN chmod +x mvnw && ./mvnw -B -Dmaven.repo.local=/candidate-maven -Dqqq.version=4.1.0-native-dev-ecf0f550 -Dmaven.test.skip=true package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime +COPY dev/native-auth/java /fixture-src +RUN mkdir /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes /fixture-src/*.java + +FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0 +WORKDIR /app +COPY --from=build /printing/target/classes classes +COPY --from=build /printing/target/runtime lib +COPY --from=build /fixture-classes fixture +COPY dev/log4j2.xml /app/log4j2.xml +COPY dev/native-auth/start-server.sh /app/start-server.sh +RUN chmod 0555 /app/start-server.sh +ENV NATIVE_CORE_SOURCE=ecf0f550a0357cec6387c2b54efca128a405a629 +ENTRYPOINT ["/app/start-server.sh"] diff --git a/dev/native-auth/README.md b/dev/native-auth/README.md new file mode 100644 index 0000000..702e235 --- /dev/null +++ b/dev/native-auth/README.md @@ -0,0 +1,82 @@ +# Experimental native Authentik qualification + +This optional profile tests an **unreleased upstream QQQ candidate**. It does +not replace the default released RC.20 TABLE/MinIO development environment, +install a connector, or qualify the Windows browser callback UI. + +From the repository root, with Docker and PowerShell 7.4 or later: + +```powershell +pwsh ./dev/Invoke-Dev.ps1 -Action Test -ExperimentalNativeAuth +``` + +The command creates a fresh owned project, builds the pinned sources, runs the +test, and removes its containers, network and synthetic database volumes in +`finally`. Protected input and sanitized result files remain outside the +checkout. Do not print or commit the private input or Compose environment. +An explicit fresh `-StateDirectory` preserves the same cleanup behavior. +Persistent `Start` and conversion of an existing standard environment are +refused for this profile. + +The test uses actual Authentik login stages with generated synthetic accounts, +the standard QQQ OAuth2 code/session/refresh handlers, and the published .NET +Core PKCE/session client. The provider issues the authorization codes. The +client receives a real HTTPS callback and stores only the opaque QQQ session +in process; provider refresh tokens remain on the QQQ server. + +The required checks are: + +- Ordinary TLS rejects the fixture before its private CA is installed. +- Actual API discovery advertises the typed native session grant. +- Authorized real provider login resolves a server-derived native identity. +- A wrong-Origin cookie request and an authenticated subject without the + printing permission are rejected. +- A logged-out QQQ session cannot be replayed. +- After the 20-second provider access grant expires, standard QQQ renewal + obtains a new provider grant and preserves the principal and tenant. +- Revoking the synthetic subject's provider refresh grants prevents renewal + after the next access-grant expiry. + +Success is recorded only when every check passes. A failure report contains +the stage and exception type, without callback URLs, credentials or response +bodies. This short renewal test does not prove months of unattended operation. + +## Isolation and source pins + +The upstream source is +[`ecf0f550a0357cec6387c2b54efca128a405a629`](https://github.com/QRun-IO/qqq/commit/ecf0f550a0357cec6387c2b54efca128a405a629). +Its archive checksum is pinned in `Dockerfile.server`. Maven installs the +development build only into `/candidate-maven` inside the image; no host Maven +cache or installed runtime is mounted or changed. Printing source is compiled +against that candidate solely for this test. + +The production .NET Core client is pinned to published source +[`346fc285198908c47a2ee9db7b9adb53d5e88c3c`](https://github.com/QRun-IO/qqq-print-agent/commit/346fc285198908c47a2ee9db7b9adb53d5e88c3c) +with a checked archive hash. Authentik, PostgreSQL, Caddy, Java and .NET images +are pinned by digest in the Compose files and Dockerfiles. + +Caddy issues the three fixture HTTPS hostnames using its private local CA. +`skip_install_trust` prevents automatic trust installation. Only the public +root certificate is copied to the Java and .NET containers' trust stores; +ordinary chain and hostname validation remain enabled. CA private keys stay +in the TLS container's owned volume. No Windows or browser trust store changes +occur. See [Caddy's local HTTPS documentation](https://caddyserver.com/docs/automatic-https#local-https). + +The HTTPS callback receiver is internal to the Compose network. It is separate +from the installed connector's private URI callback. No callback host port is +published. The profile mounts public Caddy configuration and the two specific +private input/result files, rather than a host home or credential directory. + +The QQQ fixture uses fresh standard session, redirect-state and encrypted +refresh-token tables. Its customizer maps the verified provider's synthetic +`printing_admin` group to the existing native permission. It creates no device +pairing, job, print operation or fabricated user session. Released RC.20 lacks +the native-session grant; this experiment must stay opt-in until an appropriate +immutable upstream tuple is published and separately adopted. + +## Recorded local qualification + +On 2026-11-01 the full disposable profile passed all eight checks and exited zero. +Independent inventory confirmed zero owned containers, volumes and networks after +cleanup. This is evidence for the pinned experimental tuple above, not adoption +into the default released runtime or installed Windows UI acceptance. diff --git a/dev/native-auth/client/CallbackReceiver.cs b/dev/native-auth/client/CallbackReceiver.cs new file mode 100644 index 0000000..894ebf7 --- /dev/null +++ b/dev/native-auth/client/CallbackReceiver.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Threading.Channels; + +namespace Printing.NativeAuth; + +/// Receives the actual HTTPS-proxied callback only inside this test network. +internal sealed class CallbackReceiver : IDisposable +{ + private readonly HttpListener _listener = new(); + private readonly Channel _callbacks = Channel.CreateBounded(1); + private readonly Uri _callback; + + internal CallbackReceiver(Uri callback) + { + _callback = callback; + _listener.Prefixes.Add("http://+:8088/"); + _listener.Start(); + _ = ReceiveAsync(); + } + + private async Task ReceiveAsync() + { + try + { + while(_listener.IsListening) + { + HttpListenerContext context = await _listener.GetContextAsync(); + Uri? request = context.Request.Url; + bool valid = request is not null && request.AbsolutePath == _callback.AbsolutePath + && context.Request.Headers["Host"] == _callback.Authority + && context.Request.Headers["X-Forwarded-Proto"] == "https" + && context.Request.HttpMethod == "GET" && request.Query.Length is > 0 and < 16384; + if(valid) + { + var actual = new UriBuilder(_callback) { Query = request!.Query.TrimStart('?') }; + valid = _callbacks.Writer.TryWrite(actual.Uri); + } + context.Response.StatusCode = valid ? 200 : 400; + context.Response.ContentLength64 = 0; + context.Response.Close(); + } + } + catch(Exception exception) when(exception is HttpListenerException or ObjectDisposedException) + { + _callbacks.Writer.TryComplete(); + } + } + + internal ValueTask ReadAsync(CancellationToken token) => _callbacks.Reader.ReadAsync(token); + public void Dispose() => _listener.Close(); +} diff --git a/dev/native-auth/client/FixtureHttp.cs b/dev/native-auth/client/FixtureHttp.cs new file mode 100644 index 0000000..51b978e --- /dev/null +++ b/dev/native-auth/client/FixtureHttp.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json.Nodes; + +namespace Printing.NativeAuth; + +/// Small bounded fixture transport; no redirects, proxy or response-body diagnostics. +internal sealed class FixtureHttp : IDisposable +{ + internal sealed record Reply(HttpStatusCode Status, JsonObject? Json, Uri? Location); + internal sealed record TraceEntry(string Kind, int Status, bool HasQuery, string? LocationKind, bool LocationHasQuery); + internal static List Trace { get; } = []; + internal static string? LastRequestKind { get; private set; } + internal static int? LastStatus { get; private set; } + private readonly HttpClient _client; + private readonly CookieContainer _cookies = new(); + private readonly Uri _origin; + private readonly string? _adminToken; + + internal FixtureHttp(Uri origin, bool cookies = false, string? adminToken = null) + { + _origin = origin; + _adminToken = adminToken; + _client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false, UseCookies = cookies, CookieContainer = _cookies }) + { + Timeout = TimeSpan.FromSeconds(15), + MaxResponseContentBufferSize = 524288, + }; + } + + internal async Task SendAsync(string method, Uri uri, JsonObject? body, CancellationToken cancellationToken) + { + if(uri.Scheme != "https" || uri.GetLeftPart(UriPartial.Authority) != _origin.GetLeftPart(UriPartial.Authority)) + { + throw new InvalidOperationException("A fixture request left its configured HTTPS origin."); + } + using var request = new HttpRequestMessage(new HttpMethod(method), uri); + // Fixed classifications only: never retain a query, callback, identifier or body. + LastRequestKind = Classify(uri, method); + LastStatus = null; + if(_adminToken is not null) + { + if(!uri.AbsolutePath.StartsWith("/api/v3/", StringComparison.Ordinal)) { throw new InvalidOperationException("Administrative credential scope refused."); } + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken); + } + if(body is not null) + { + request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); + request.Headers.Add("Origin", _origin.GetLeftPart(UriPartial.Authority)); + request.Headers.Referrer = _origin; + Cookie? csrf = _cookies.GetCookies(_origin).Cast().SingleOrDefault(cookie => cookie.Name == "authentik_csrf"); + csrf ??= _cookies.GetCookies(_origin).Cast().SingleOrDefault(cookie => cookie.Name == "csrftoken"); + if(csrf is not null) { request.Headers.Add("X-authentik-CSRF", csrf.Value); } + } + using HttpResponseMessage response = await _client.SendAsync(request, cancellationToken); + LastStatus = (int)response.StatusCode; + Uri? location = response.Headers.Location is null ? null : new Uri(_origin, response.Headers.Location); + if(Trace.Count == 32) { Trace.RemoveAt(0); } + Trace.Add(new TraceEntry(LastRequestKind, (int)response.StatusCode, uri.Query.Length != 0, + location is null ? null : Classify(location, "GET"), location?.Query.Length > 0)); + string content = await response.Content.ReadAsStringAsync(cancellationToken); + JsonObject? json = response.Content.Headers.ContentType?.MediaType == "application/json" && content.Length != 0 + ? JsonNode.Parse(content)?.AsObject() : null; + return new Reply(response.StatusCode, json, response.Headers.Location); + } + + internal Task SendAsync(string method, string path, JsonObject? body, CancellationToken cancellationToken) + => SendAsync(method, new Uri(_origin, path), body, cancellationToken); + + private static string Classify(Uri uri, string method) => uri.Host == "callback.printing.internal" ? "callback" + : uri.AbsolutePath.StartsWith("/application/o/authorize", StringComparison.Ordinal) ? "provider-authorization" + : uri.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal) ? "provider-flow-page" + : uri.AbsolutePath.StartsWith("/api/v3/flows/executor/", StringComparison.Ordinal) ? "provider-flow-executor-" + method + : "fixture-api"; + + internal static JsonObject RequireJson(Reply reply, HttpStatusCode expected) + { + if(reply.Status != expected || reply.Json is null) { throw new InvalidOperationException("Unexpected fixture API status or document shape."); } + return reply.Json; + } + + public void Dispose() => _client.Dispose(); +} diff --git a/dev/native-auth/client/NativeAuth.csproj b/dev/native-auth/client/NativeAuth.csproj new file mode 100644 index 0000000..bc47a7d --- /dev/null +++ b/dev/native-auth/client/NativeAuth.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + true + + + /core/Qqq.PrintAgent.Core.dll + + diff --git a/dev/native-auth/client/Program.cs b/dev/native-auth/client/Program.cs new file mode 100644 index 0000000..9601094 --- /dev/null +++ b/dev/native-auth/client/Program.cs @@ -0,0 +1,236 @@ +using System.Net; +using System.Security.Authentication; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Qqq.PrintAgent.Core.Authentication; + +namespace Printing.NativeAuth; + +internal static class Program +{ + private const string CoreSource = "ecf0f550a0357cec6387c2b54efca128a405a629"; + private const string ClientSource = "346fc285198908c47a2ee9db7b9adb53d5e88c3c"; + private const string InputPath = "/work/input.json"; + private const string ResultPath = "/work/result.json"; + private const string TlsMarker = "/tmp/printing-native-untrusted-tls-passed"; + private static readonly List Checks = []; + private static string _stage = "input"; + private static bool? _authorizationRequestMatches; + + public static async Task Main(string[] arguments) + { + JsonObject? input = null; + try + { + if(arguments.Length != 1 || arguments[0] is not ("assert-untrusted" or "test")) + { throw new InvalidOperationException("An explicit fixture action is required."); } + if(new FileInfo(InputPath).Length is <= 0 or > 65536) + { throw new InvalidOperationException("Fixture input size is invalid."); } + input = JsonNode.Parse(await File.ReadAllTextAsync(InputPath))!.AsObject(); + ValidateInput(input); + using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + if(arguments[0] == "assert-untrusted") + { + _stage = "untrusted-tls"; + await AssertUntrustedAsync(new Uri(ProviderSetup.Text(input, "providerOrigin")), deadline.Token); + await File.WriteAllTextAsync(TlsMarker, "passed", deadline.Token); + return 0; + } + if(!File.Exists(TlsMarker)) { throw new InvalidOperationException("The ordinary TLS negative control did not run."); } + Checks.Add("untrusted-tls-refused"); + await QualifyAsync(input, deadline.Token); + await WriteResultAsync(input, "PASS", null, null); + Console.WriteLine("Experimental native Authentik qualification passed; development-only unpublished core."); + return 0; + } + catch(Exception exception) + { + // Exception messages, response bodies, callback URLs and credentials are deliberately withheld. + await WriteResultAsync(input, "FAILED", exception.GetType().Name, + exception is QqqSessionException sessionException ? sessionException.Failure.ToString() : null); + Console.Error.WriteLine("Experimental native Authentik qualification failed at " + _stage + " (" + exception.GetType().Name + ")."); + return 1; + } + } + + private static void ValidateInput(JsonObject input) + { + if(!Regex.IsMatch(ProviderSetup.Text(input, "project"), "^qbit-printing-dev-[a-f0-9]{32}$", RegexOptions.CultureInvariant) + || ProviderSetup.Text(input, "coreSource") != CoreSource || ProviderSetup.Text(input, "clientSource") != ClientSource + || ProviderSetup.Text(input, "providerOrigin") != "https://auth.printing.internal" + || ProviderSetup.Text(input, "qqqOrigin") != "https://qqq.printing.internal" + || ProviderSetup.Text(input, "callback") != "https://callback.printing.internal/oauth/callback" + || ProviderSetup.Text(input, "issuer") != "https://auth.printing.internal/application/o/" + ProviderSetup.Text(input, "project") + "/" + || ProviderSetup.Text(input, "clientId") != ProviderSetup.Text(input, "project")) + { throw new InvalidOperationException("Fixture identity or destination differs from the pinned local contract."); } + } + + private static QqqSessionClient Session(Uri origin) => new(origin, + new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false }); + + private static async Task AssertUntrustedAsync(Uri origin, CancellationToken token) + { + using var transport = new FixtureHttp(origin); + try { await transport.SendAsync("GET", "/-/health/live/", null, token); } + catch(HttpRequestException exception) + { + for(Exception? cause = exception; cause is not null; cause = cause.InnerException) + { + if(cause is AuthenticationException) { return; } + } + throw new InvalidOperationException("The negative TLS control failed for a reason other than certificate authentication."); + } + throw new InvalidOperationException("The test unexpectedly trusted its private CA before scoped installation."); + } + + private static async Task QualifyAsync(JsonObject input, CancellationToken token) + { + var origin = new Uri(ProviderSetup.Text(input, "qqqOrigin")); + var providerOrigin = new Uri(ProviderSetup.Text(input, "providerOrigin")); + var callback = new Uri(ProviderSetup.Text(input, "callback")); + _stage = "readiness"; + await WaitForQqqAsync(origin, token); + using var provider = new ProviderSetup(input); + _stage = "provider-readiness"; + await provider.WaitReadyAsync(token); + _stage = "provider-configuration"; + await provider.ConfigureAsync(token); + using var receiver = new CallbackReceiver(callback); + using var first = Session(origin); + _stage = "typed-native-discovery"; + QqqApiDiscovery discovery = await first.DiscoverAsync(token); + if(discovery.ApiVersion != "1.9.0" || !discovery.Features.Contains("nativeSessionLogin", StringComparer.Ordinal) + || discovery.Extensions.Single(extension => extension.Name == "printing").Version != "2.0.0") + { throw new InvalidOperationException("The actual server did not advertise the pinned native-session API."); } + QqqAuthenticationMetadata metadata = await first.GetAuthenticationMetadataAsync(token); + if(metadata.Values?.ClientId != ProviderSetup.Text(input, "clientId")) + { throw new InvalidOperationException("Native metadata did not retain the configured client identifier."); } + Checks.Add("typed-native-grant"); + + _stage = "real-provider-login"; + await LoginAsync(first, receiver, input, providerOrigin, callback, authorized: true, token); + _stage = "authorized-native-identity"; + QqqSessionBinding identity = await first.GetSessionIdentityAsync(token); + if(string.IsNullOrWhiteSpace(identity.PrincipalId) || identity.TenantId != "printing-native-dev" || identity.DeviceId is not null) + { throw new InvalidOperationException("The native subject did not receive the intended server-derived identity."); } + Checks.Add("real-provider-login"); + _stage = "wrong-origin-cookie"; + await AssertWrongOriginAsync(origin, first.ExportCredential(), token); + await first.GetSessionIdentityAsync(token); + Checks.Add("wrong-origin-cookie-denied"); + + _stage = "permission-denied-user"; + using(var denied = Session(origin)) + { + await LoginAsync(denied, receiver, input, providerOrigin, callback, authorized: false, token); + await ExpectFailureAsync(() => denied.GetSessionIdentityAsync(token), QqqSessionException.FailureKind.PermissionDenied); + await denied.SignOutAsync(token); + } + Checks.Add("permission-denied-user"); + + _stage = "logout-replay"; + QqqSessionCredential retired = first.ExportCredential(); + await first.SignOutAsync(token); + using(var replay = Session(origin)) + { await ExpectFailureAsync(() => replay.RestoreSessionAsync(retired, token), QqqSessionException.FailureKind.AuthenticationRequired); } + Checks.Add("logout-replay-denied"); + + _stage = "fresh-login-for-renewal"; + using var renewable = Session(origin); + await LoginAsync(renewable, receiver, input, providerOrigin, callback, authorized: true, token); + QqqSessionBinding before = await renewable.GetSessionIdentityAsync(token); + HashSet previousGrants = await provider.AccessGrantIdsAsync(token); + _stage = "server-refresh-after-expiry"; + await Task.Delay(TimeSpan.FromSeconds(22), token); + await renewable.ResumeSessionAsync(token); + QqqSessionBinding after = await renewable.GetSessionIdentityAsync(token); + HashSet refreshedGrants = await provider.AccessGrantIdsAsync(token); + if(before != after || !refreshedGrants.Except(previousGrants, StringComparer.Ordinal).Any()) + { throw new InvalidOperationException("Renewal did not preserve the identity and obtain a new actual provider grant."); } + Checks.Add("server-refresh-after-expiry"); + + _stage = "provider-revocation-after-expiry"; + await provider.RevokeRefreshGrantsAsync(token); + await Task.Delay(TimeSpan.FromSeconds(22), token); + await ExpectFailureAsync(() => renewable.ResumeSessionAsync(token), QqqSessionException.FailureKind.AuthenticationRequired); + Checks.Add("provider-revocation-denied"); + _stage = "complete"; + } + + private static async Task LoginAsync(QqqSessionClient client, CallbackReceiver receiver, JsonObject input, + Uri providerOrigin, Uri callback, bool authorized, CancellationToken token) + { + string role = authorized ? "authorized" : "denied"; + _stage = role + "-begin-pkce"; + QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(callback, token); + _stage = role + "-generated-authorization-request"; + Dictionary parameters = attempt.AuthorizationUri.Query.TrimStart('?').Split('&') + .Select(entry => entry.Split('=', 2)).ToDictionary(pair => Uri.UnescapeDataString(pair[0]), pair => Uri.UnescapeDataString(pair[1]), StringComparer.Ordinal); + _authorizationRequestMatches = attempt.AuthorizationUri.AbsolutePath == "/application/o/authorize/" + && parameters.GetValueOrDefault("client_id") == ProviderSetup.Text(input, "clientId") + && parameters.GetValueOrDefault("redirect_uri") == callback.AbsoluteUri + && parameters.GetValueOrDefault("response_type") == "code" + && parameters.GetValueOrDefault("code_challenge_method") == "S256" + && parameters.GetValueOrDefault("state")?.Length == 43; + if(_authorizationRequestMatches != true) + { throw new InvalidOperationException("The generated authorization request differs from the exact provider registration."); } + _stage = role + "-provider-flow"; + await ProviderFlow.AuthenticateAsync(attempt.AuthorizationUri, providerOrigin, callback, + ProviderSetup.Text(input, role + "Username"), ProviderSetup.Text(input, role + "Password"), token); + _stage = role + "-callback"; + Uri actualCallback = await receiver.ReadAsync(token); + _stage = role + "-exchange"; + await client.CompleteBrowserLoginAsync(attempt, actualCallback, token); + } + + private static async Task ExpectFailureAsync(Func operation, QqqSessionException.FailureKind expected) + { + try { await operation(); } + catch(QqqSessionException exception) when(exception.Failure == expected) { return; } + throw new InvalidOperationException("The expected native authentication or permission denial did not occur."); + } + + private static async Task AssertWrongOriginAsync(Uri origin, QqqSessionCredential credential, CancellationToken token) + { + using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false }) + { Timeout = TimeSpan.FromSeconds(15), MaxResponseContentBufferSize = 65536 }; + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(origin, "/qqq/v1/qbit/printing/session")); + request.Headers.Add("X-QQQ-Client", QqqSessionClient.ClientChannel); + request.Headers.Add("Cookie", "sessionUUID=" + credential.SessionToken); + request.Headers.Add("Origin", "https://wrong-origin.invalid"); + using HttpResponseMessage response = await http.SendAsync(request, token); + if(response.StatusCode != HttpStatusCode.Forbidden) + { throw new InvalidOperationException("The fixture did not reject a wrong-Origin cookie request."); } + } + + private static async Task WaitForQqqAsync(Uri origin, CancellationToken token) + { + using var http = new FixtureHttp(origin); + for(int attempt = 0; attempt < 45; attempt++) + { + try + { + FixtureHttp.Reply response = await http.SendAsync("GET", "/qqq/v1/version", null, token); + if(response.Status == HttpStatusCode.OK && response.Json is not null) { return; } + } + catch(HttpRequestException) { } + await Task.Delay(TimeSpan.FromSeconds(2), token); + } + throw new InvalidOperationException("The owned native host did not become ready."); + } + + private static Task WriteResultAsync(JsonObject? input, string status, string? failureType, string? sessionFailure) + => File.WriteAllTextAsync(ResultPath, JsonSerializer.Serialize(new + { + status, scope = "DEVELOPMENT_ONLY_UNRELEASED", coreSource = CoreSource, clientSource = ClientSource, + project = input?["project"]?.ToString(), observedUtc = DateTimeOffset.UtcNow, stage = _stage, failureType, + sessionFailure, lastRequestKind = FixtureHttp.LastRequestKind, lastHttpStatus = FixtureHttp.LastStatus, + lastProviderComponent = ProviderFlow.LastComponent, + authorizationRequestMatches = _authorizationRequestMatches, + requestClassifications = FixtureHttp.Trace, + providerLookupKind = ProviderSetup.LastLookupKind, providerLookupCount = ProviderSetup.LastLookupCount, + providerReadinessCounts = ProviderSetup.ReadinessCounts, + checks = Checks, installedUiAcceptance = false, providerRefreshTokensInClient = false, + }, new JsonSerializerOptions { WriteIndented = true })); +} diff --git a/dev/native-auth/client/ProviderFlow.cs b/dev/native-auth/client/ProviderFlow.cs new file mode 100644 index 0000000..927d3ff --- /dev/null +++ b/dev/native-auth/client/ProviderFlow.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Text.Json.Nodes; + +namespace Printing.NativeAuth; + +/// Uses Authentik's real synthetic-user login stages; never fabricates a session or code. +internal static class ProviderFlow +{ + internal static string? LastComponent { get; private set; } + internal static async Task AuthenticateAsync(Uri authorization, Uri origin, Uri callback, string username, string password, CancellationToken token) + { + using var browser = new FixtureHttp(origin, cookies: true); + using var callbackHttp = new FixtureHttp(new Uri(callback.GetLeftPart(UriPartial.Authority))); + Uri next = authorization; + for(int hop = 0; hop < 24; hop++) + { + if(next.GetLeftPart(UriPartial.Authority) == callback.GetLeftPart(UriPartial.Authority)) + { + if(next.AbsolutePath != callback.AbsolutePath) { throw new InvalidOperationException("Unexpected callback path."); } + FixtureHttp.Reply result = await callbackHttp.SendAsync("GET", next, null, token); + if(result.Status != HttpStatusCode.OK) { throw new InvalidOperationException("The HTTPS callback receiver refused the provider redirect."); } + return; + } + if(next.GetLeftPart(UriPartial.Authority) != origin.GetLeftPart(UriPartial.Authority)) + { throw new InvalidOperationException("A provider redirect left the fixture."); } + FixtureHttp.Reply page = await browser.SendAsync("GET", next, null, token); + if(page.Status is HttpStatusCode.Redirect or HttpStatusCode.SeeOther or HttpStatusCode.TemporaryRedirect) + { + next = new Uri(next, page.Location ?? throw new InvalidOperationException("Missing provider redirect.")); + continue; + } + if(page.Status != HttpStatusCode.OK || !next.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal)) + { throw new InvalidOperationException("Unexpected provider login navigation."); } + string slug = next.AbsolutePath["/if/flow/".Length..].Trim('/'); + if(slug.Length == 0 || slug.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '-')) + { throw new InvalidOperationException("Unexpected provider flow identifier."); } + var executor = new Uri(origin, "/api/v3/flows/executor/" + slug + "/?query=" + Uri.EscapeDataString(next.Query.TrimStart('?'))); + JsonObject challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("GET", executor, null, token), token); + bool redirected = false; + for(int stage = 0; stage < 10; stage++) + { + string component = ProviderSetup.Text(challenge, "component"); + LastComponent = component switch + { + "ak-stage-identification" => "identification", + "ak-stage-password" => "password", + "ak-stage-consent" => "consent", + "xak-flow-redirect" => "redirect", + "ak-stage-access-denied" => "access-denied", + "ak-stage-flow-error" => "flow-error", + _ => "unsupported", + }; + if(component == "xak-flow-redirect") + { + next = new Uri(origin, ProviderSetup.Text(challenge, "to")); + redirected = true; + break; + } + JsonObject response = component switch + { + "ak-stage-identification" => new JsonObject { ["component"] = component, ["uid_field"] = username }, + "ak-stage-password" => new JsonObject { ["component"] = component, ["password"] = password }, + "ak-stage-consent" => new JsonObject { ["component"] = component, ["consent"] = true }, + _ => throw new InvalidOperationException("The provider requested an unsupported test login stage."), + }; + challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("POST", executor, response, token), token); + if(challenge["response_errors"] is JsonObject errors && errors.Count != 0) + { throw new InvalidOperationException("The real provider rejected the synthetic login response."); } + } + if(!redirected) { throw new InvalidOperationException("Provider stage bound exceeded."); } + } + throw new InvalidOperationException("Provider redirect bound exceeded."); + } + + private static async Task ChallengeAsync(FixtureHttp browser, Uri executor, FixtureHttp.Reply response, CancellationToken token) + { + // Authentik uses POST/redirect/GET when a real stage accepts its answer. + // Follow only that same HTTPS executor; other navigation uses typed flow redirects. + for(int redirect = 0; redirect < 8; redirect++) + { + if(response.Status == HttpStatusCode.OK) { return FixtureHttp.RequireJson(response, HttpStatusCode.OK); } + if(response.Status is not (HttpStatusCode.Redirect or HttpStatusCode.SeeOther) || response.Location is null) + { throw new InvalidOperationException("Unexpected provider stage response."); } + var location = new Uri(executor, response.Location); + if(location.GetLeftPart(UriPartial.Authority) != executor.GetLeftPart(UriPartial.Authority) + || location.AbsolutePath != executor.AbsolutePath) + { throw new InvalidOperationException("A stage redirect left its exact HTTPS executor."); } + response = await browser.SendAsync("GET", location, null, token); + } + throw new InvalidOperationException("Provider stage redirect bound exceeded."); + } +} diff --git a/dev/native-auth/client/ProviderSetup.cs b/dev/native-auth/client/ProviderSetup.cs new file mode 100644 index 0000000..2658adc --- /dev/null +++ b/dev/native-auth/client/ProviderSetup.cs @@ -0,0 +1,181 @@ +using System.Net; +using System.Text.Json.Nodes; + +namespace Printing.NativeAuth; + +/// Configures only the fresh project's synthetic provider and subjects. +internal sealed class ProviderSetup : IDisposable +{ + internal static string? LastLookupKind { get; private set; } + internal static int? LastLookupCount { get; private set; } + internal static Dictionary ReadinessCounts { get; } = new(StringComparer.Ordinal); + private readonly JsonObject _input; + private readonly FixtureHttp _admin; + private string _provider = ""; + private string _authorizedUser = ""; + + internal ProviderSetup(JsonObject input) + { + _input = input; + _admin = new FixtureHttp(new Uri(Text(input, "providerOrigin")), adminToken: Text(input, "adminToken")); + } + + internal static string Text(JsonObject document, string key) => document[key]?.ToString() ?? throw new InvalidOperationException("Required fixture field is absent."); + + private async Task ExactAsync(string path, CancellationToken token) + { + JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", path, null, token), HttpStatusCode.OK); + JsonArray records = page["results"]!.AsArray(); + LastLookupKind = LookupKind(path); + LastLookupCount = records.Count; + if(records.Count != 1) { throw new InvalidOperationException("Required provider object is absent or ambiguous."); } + return records[0]!.AsObject(); + } + + private async Task CreateAsync(string path, JsonObject body, CancellationToken token) + => FixtureHttp.RequireJson(await _admin.SendAsync("POST", path, body, token), HttpStatusCode.Created); + + internal async Task WaitReadyAsync(CancellationToken token) + { + string[] paths = ["/api/v3/flows/instances/?slug=default-authentication-flow", + "/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent", + "/api/v3/flows/instances/?slug=default-provider-invalidation-flow", + "/api/v3/propertymappings/provider/scope/?scope_name=openid", + "/api/v3/propertymappings/provider/scope/?scope_name=profile", + "/api/v3/propertymappings/provider/scope/?scope_name=email", + "/api/v3/propertymappings/provider/scope/?scope_name=offline_access"]; + for(int attempt = 0; attempt < 60; attempt++) + { + bool ready = true; + try + { + foreach(string path in paths) + { + FixtureHttp.Reply reply = await _admin.SendAsync("GET", path, null, token); + JsonArray? records = reply.Status == HttpStatusCode.OK ? reply.Json?["results"] as JsonArray : null; + string kind = LookupKind(path); + ReadinessCounts[kind] = records?.Count ?? -1; + if(records?.Count > 1) { throw new InvalidOperationException("An owned provider bootstrap dependency is ambiguous."); } + bool present = records is { Count: 1 }; + if(kind == "authentication-flow") + { present &= records is { Count: 1 } && records[0]?["stages"] is JsonArray { Count: >= 4 }; } + ready &= present; + } + if(ready) { return; } + } + catch(HttpRequestException) { } + await Task.Delay(TimeSpan.FromSeconds(2), token); + } + throw new InvalidOperationException("The owned provider bootstrap did not become ready."); + } + + private static string LookupKind(string path) => path switch + { + "/api/v3/flows/instances/?slug=default-authentication-flow" => "authentication-flow", + "/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent" => "authorization-flow", + "/api/v3/flows/instances/?slug=default-provider-invalidation-flow" => "invalidation-flow", + "/api/v3/propertymappings/provider/scope/?scope_name=openid" => "scope-openid", + "/api/v3/propertymappings/provider/scope/?scope_name=profile" => "scope-profile", + "/api/v3/propertymappings/provider/scope/?scope_name=email" => "scope-email", + "/api/v3/propertymappings/provider/scope/?scope_name=offline_access" => "scope-offline-access", + _ => "other", + }; + + internal async Task ConfigureAsync(CancellationToken token) + { + JsonObject authentication = await ExactAsync("/api/v3/flows/instances/?slug=default-authentication-flow", token); + JsonObject authorization = await ExactAsync("/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent", token); + JsonObject invalidation = await ExactAsync("/api/v3/flows/instances/?slug=default-provider-invalidation-flow", token); + var mappings = new JsonArray(); + foreach(string scope in new[] { "openid", "profile", "email", "offline_access" }) + { + mappings.Add((await ExactAsync("/api/v3/propertymappings/provider/scope/?scope_name=" + scope, token))["pk"]!.DeepClone()); + } + JsonObject groupsMapping = await CreateAsync("/api/v3/propertymappings/provider/scope/", new JsonObject + { + ["name"] = Text(_input, "project") + "-groups", + ["scope_name"] = "profile", + ["expression"] = "return {\"groups\": [group.name for group in user.ak_groups.all()]}", + }, token); + mappings.Add(groupsMapping["pk"]!.DeepClone()); + JsonObject group = await CreateAsync("/api/v3/core/groups/", new JsonObject { ["name"] = "printing_admin", ["is_superuser"] = false }, token); + JsonObject user = await CreateUserAsync("authorizedUsername", "authorizedPassword", new JsonArray(group["pk"]!.DeepClone()), token); + _authorizedUser = Text(user, "pk"); + await CreateUserAsync("deniedUsername", "deniedPassword", new JsonArray(), token); + JsonObject provider = await CreateAsync("/api/v3/providers/oauth2/", new JsonObject + { + ["name"] = Text(_input, "project"), + ["authentication_flow"] = authentication["pk"]!.DeepClone(), + ["authorization_flow"] = authorization["pk"]!.DeepClone(), + ["invalidation_flow"] = invalidation["pk"]!.DeepClone(), + ["client_type"] = "confidential", + ["grant_types"] = new JsonArray("authorization_code", "refresh_token"), + ["client_id"] = Text(_input, "clientId"), + ["client_secret"] = Text(_input, "clientSecret"), + ["access_code_validity"] = "minutes=1", + ["access_token_validity"] = "seconds=20", + ["refresh_token_validity"] = "minutes=5", + ["refresh_token_threshold"] = "minutes=4", + ["redirect_uris"] = new JsonArray(new JsonObject { ["matching_mode"] = "strict", ["url"] = Text(_input, "callback") }), + ["property_mappings"] = mappings, + ["issuer_mode"] = "per_provider", + ["include_claims_in_id_token"] = true, + }, token); + _provider = Text(provider, "pk"); + if(Text(provider, "client_id") != Text(_input, "clientId") + || Text(provider, "authentication_flow") != Text(authentication, "pk")) + { throw new InvalidOperationException("The real provider did not retain the configured client identifier."); } + JsonObject application = await CreateAsync("/api/v3/core/applications/", new JsonObject + { + ["name"] = Text(_input, "project"), ["slug"] = Text(_input, "project"), + ["provider"] = provider["pk"]!.DeepClone(), ["policy_engine_mode"] = "all", + }, token); + if(Text(application, "provider") != _provider) + { throw new InvalidOperationException("The real provider application binding differs from the configured provider."); } + } + + private async Task CreateUserAsync(string usernameKey, string passwordKey, JsonArray groups, CancellationToken token) + { + JsonObject user = await CreateAsync("/api/v3/core/users/", new JsonObject + { + ["username"] = Text(_input, usernameKey), ["name"] = "Synthetic native qualification subject", + ["email"] = Text(_input, usernameKey) + "@example.invalid", ["is_active"] = true, + ["type"] = "internal", ["groups"] = groups, + }, token); + FixtureHttp.Reply response = await _admin.SendAsync("POST", "/api/v3/core/users/" + Text(user, "pk") + "/set_password/", + new JsonObject { ["password"] = Text(_input, passwordKey) }, token); + if(response.Status != HttpStatusCode.NoContent) { throw new InvalidOperationException("Synthetic account password configuration failed."); } + return user; + } + + private async Task GrantsAsync(string kind, CancellationToken token) + { + JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", "/api/v3/oauth2/" + kind + "/?provider=" + _provider + "&user=" + _authorizedUser, null, token), HttpStatusCode.OK); + JsonArray records = page["results"]!.AsArray(); + if(records.Count is 0 or > 20) { throw new InvalidOperationException("The synthetic provider grant set is missing or unexpectedly large."); } + foreach(JsonNode? node in records) + { + JsonObject record = node!.AsObject(); + string provider = record["provider"] is JsonObject nested ? Text(nested, "pk") : Text(record, "provider"); + if(provider != _provider || Text(record["user"]!.AsObject(), "pk") != _authorizedUser) + { throw new InvalidOperationException("The provider grant escaped the synthetic subject scope."); } + } + return records; + } + + internal async Task> AccessGrantIdsAsync(CancellationToken token) + => (await GrantsAsync("access_tokens", token)).Select(node => Text(node!.AsObject(), "pk")).ToHashSet(StringComparer.Ordinal); + + internal async Task RevokeRefreshGrantsAsync(CancellationToken token) + { + foreach(JsonNode? node in await GrantsAsync("refresh_tokens", token)) + { + string id = Text(node!.AsObject(), "pk"); + if(!Guid.TryParse(id, out _) && !long.TryParse(id, out _)) { throw new InvalidOperationException("Unexpected provider grant identifier."); } + FixtureHttp.Reply response = await _admin.SendAsync("DELETE", "/api/v3/oauth2/refresh_tokens/" + id + "/", null, token); + if(response.Status != HttpStatusCode.NoContent) { throw new InvalidOperationException("Synthetic provider revocation failed."); } + } + } + + public void Dispose() => _admin.Dispose(); +} diff --git a/dev/native-auth/copy-root.sh b/dev/native-auth/copy-root.sh new file mode 100644 index 0000000..e35ad84 --- /dev/null +++ b/dev/native-auth/copy-root.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +attempt=0 +while [ ! -s /data/caddy/pki/authorities/local/root.crt ]; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 60 ]; then + echo 'The fixture public CA certificate did not become ready.' >&2 + exit 1 + fi + sleep 1 +done +# Never copy the key or mount the private CA volume into a client/server. +cp /data/caddy/pki/authorities/local/root.crt /trust/root.crt +chmod 0644 /trust/root.crt diff --git a/dev/native-auth/java/NativeAuthHost.java b/dev/native-auth/java/NativeAuthHost.java new file mode 100644 index 0000000..ae542f5 --- /dev/null +++ b/dev/native-auth/java/NativeAuthHost.java @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.kingsrook.qbits.printing; + +import java.net.URI; +import java.sql.Connection; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2NativeSessionLoginMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.RedirectStateMetaDataProducer; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.UserSessionMetaDataProducer; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.UserSessionRefreshTokenMetaDataProducer; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSession; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSessionRefreshToken; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; +import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig; +import io.javalin.http.Context; +import io.javalin.http.ForbiddenResponse; +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Opt-in container fixture for the pinned upstream native-session candidate. + * Standard OAuth2 code exchange, session storage and encrypted renewal are used + * unchanged. No installed pairing, password authentication double or printer. + */ +public final class NativeAuthHost extends AbstractQQQApplication +{ + private static final String TENANT = "printing-native-dev"; + private final String backendName = "printing-native-auth-" + UUID.randomUUID(); + private final String jdbcUrl = "jdbc:h2:file:/data/printing-native-auth"; + private final String databasePassword = required("NATIVE_QQQ_DATABASE_PASSWORD"); + private final URI origin = httpsOrigin(required("NATIVE_QQQ_ORIGIN")); + private final int port = Integer.parseInt(required("NATIVE_QQQ_PORT")); + private final DriverDataSource source = new DriverDataSource(jdbcUrl, "sa", databasePassword); + private final PrintingService service = new PrintingService(source, TENANT); + + private NativeAuthHost() throws Exception + { + if(port < 1024 || port > 65535) + { + throw new IllegalArgumentException("Unsupported internal fixture port"); + } + service.initialize(); + try(Connection connection = source.getConnection(); var statement = connection.createStatement()) + { + statement.execute("CREATE TABLE IF NOT EXISTS userSession (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, uuid VARCHAR(40) NOT NULL UNIQUE, userId VARCHAR(100), accessToken CLOB, createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)"); + statement.execute("CREATE TABLE IF NOT EXISTS redirectState (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, state VARCHAR(45) NOT NULL UNIQUE, redirectUri VARCHAR(4096), createDate TIMESTAMP WITH TIME ZONE)"); + statement.execute("CREATE TABLE IF NOT EXISTS userSessionRefreshToken (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, sessionUuid VARCHAR(40) NOT NULL UNIQUE, refreshToken CLOB, refreshTokenExpiresAt TIMESTAMP WITH TIME ZONE, sessionStartedAt TIMESTAMP WITH TIME ZONE, createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)"); + } + } + + public static void main(String[] args) throws Exception + { + NativeAuthHost host = new NativeAuthHost(); + QApplicationLauncher.run(host, new QApplicationLauncherConfig() + .withServerCustomizer(host::configure)); + } + + @Override + public QInstance defineQInstance() throws QException + { + QInstance instance = new QInstance(); + instance.setFailOnMetaDataProducerError(true); + instance.setHomeAppName("printing"); + instance.setBranding(new QBrandingMetaData().withAppName("QQQ native authentication fixture")); + instance.addBackend(new RDBMSBackendMetaData().withName(backendName).withVendor("h2").withHostName("fixture") + .withDatabaseName("printing-native-auth").withUsername("sa").withPassword(databasePassword).withJdbcUrl(jdbcUrl)); + instance.addTable(new UserSessionMetaDataProducer(backendName).produce(instance)); + instance.addTable(new RedirectStateMetaDataProducer(backendName).produce(instance)); + instance.addTable(new UserSessionRefreshTokenMetaDataProducer(backendName).produce(instance)); + + String issuer = required("NATIVE_AUTH_ISSUER"); + URI issuerUri = URI.create(issuer); + if(!"https".equals(issuerUri.getScheme()) || issuerUri.getHost() == null + || issuerUri.getRawUserInfo() != null || issuerUri.getRawQuery() != null || issuerUri.getRawFragment() != null || !issuer.endsWith("/")) + { + throw new IllegalArgumentException("An explicit HTTPS fixture issuer ending in slash is required"); + } + OAuth2AuthenticationMetaData authentication = new OAuth2AuthenticationMetaData() + .withBaseUrl(issuer).withExternalBaseUrl(issuer) + .withClientId(required("NATIVE_AUTH_CLIENT_ID")).withClientSecret(required("NATIVE_AUTH_CLIENT_SECRET")) + .withScopes("openid profile email offline_access") + .withUserSessionTableName(UserSession.TABLE_NAME).withRedirectStateTableName(RedirectStateMetaDataProducer.TABLE_NAME) + .withRefreshTokenTableName(UserSessionRefreshToken.TABLE_NAME) + .withRefreshTokensEnabled(true).withRefreshTokenSkewSeconds(0).withMaxSessionLifetimeSeconds(3600) + .withRefreshTokenEncryptionKey(required("NATIVE_QQQ_REFRESH_KEY")).withRefreshTokenEncryptionKeyId("fixture-1") + .withNativeSessionLogin(new OAuth2NativeSessionLoginMetaData().withIssuer(issuer) + .withDiscoveryUrl(issuer + ".well-known/openid-configuration") + .withRedirectUris(List.of(required("NATIVE_AUTH_REDIRECT_URI")))); + authentication.withName("printing-native-provider").withSessionStoreEnabled(false) + .withCustomizer(new QCodeReference(PrintingPermissions.class)); + instance.registerAuthenticationProvider(AuthScope.instanceDefault(), authentication); + new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backendName).withService(service)) + .produce(instance, "printing"); + for(QTableMetaData table : instance.getTables().values()) + { + if(backendName.equals(table.getBackendName())) + { + table.withBackendDetails(new RDBMSTableBackendDetails().withTableName(table.getName().toUpperCase(Locale.ROOT))); + for(var field : table.getFields().values()) + { + field.withBackendName(field.getName().toUpperCase(Locale.ROOT)); + } + } + } + return instance; + } + + private void configure(QApplicationJavalinServer server) + { + // Only the owned TLS proxy is published; this HTTP listener remains inside + // the Compose network. Forwarded HTTPS is required even for discovery. + server.withPort(port).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false) + .withAdditionalRouteProviders(List.of(new PrintingRealtimeRoutes(service))) + .withJavalinConfigCustomizer(config -> + { + config.jetty.host = "0.0.0.0"; + config.http.maxRequestSize = 1048576; + config.routes.before(this::guardOrigin); + }); + } + + private void guardOrigin(Context context) + { + if(!origin.getRawAuthority().equalsIgnoreCase(context.header("Host")) + || !"https".equals(context.header("X-Forwarded-Proto"))) + { + throw new ForbiddenResponse("The fixture requires its configured HTTPS proxy origin"); + } + String suppliedOrigin = context.header("Origin"); + if(suppliedOrigin != null && !origin.toString().equals(suppliedOrigin)) + { + throw new ForbiddenResponse("Cross-origin fixture requests are not allowed"); + } + if("cross-site".equalsIgnoreCase(context.header("Sec-Fetch-Site")) + || context.header("X-QQQ-In-Process-Session") != null) + { + throw new ForbiddenResponse("Only standard native session authentication is allowed"); + } + boolean unsafe = !Set.of("GET", "HEAD", "OPTIONS").contains(context.method().name()); + boolean cookie = context.cookie("sessionUUID") != null || context.cookie("sessionId") != null; + if(unsafe && cookie && suppliedOrigin == null) + { + throw new ForbiddenResponse("Cookie mutations require the configured Origin"); + } + } + + /** Only maps claims after the unchanged standard OAuth2 module authenticates. */ + public static final class PrintingPermissions implements QAuthenticationModuleCustomizerInterface + { + @Override + public void customizeSession(QInstance instance, QSession session, Map context) + { + session.setPermissions(Set.of()); + session.withSecurityKeyValue("printing_tenant", TENANT); + Object claims = context.get("jwtPayloadJsonObject"); + if(session.getUser() != null && claims instanceof JSONObject payload) + { + JSONArray groups = payload.optJSONArray("groups"); + if(groups != null && groups.toList().contains("printing_admin")) + { + session.setPermissions(Set.of("printing_admin")); + } + } + } + } + + private static URI httpsOrigin(String value) + { + URI parsed = URI.create(value); + if(!"https".equals(parsed.getScheme()) || parsed.getHost() == null || parsed.getRawUserInfo() != null + || parsed.getRawQuery() != null || parsed.getRawFragment() != null || !parsed.getRawPath().isEmpty()) + { + throw new IllegalArgumentException("A root HTTPS fixture origin without trailing slash is required"); + } + return parsed; + } + + private static String required(String name) + { + String value = System.getenv(name); + if(value == null || value.isBlank()) + { + throw new IllegalArgumentException("Set the synthetic fixture variable " + name); + } + return value; + } +} diff --git a/dev/native-auth/start-client.sh b/dev/native-auth/start-client.sh new file mode 100644 index 0000000..782ec7f --- /dev/null +++ b/dev/native-auth/start-client.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +# This first request must fail with the ordinary TLS validator, before trust. +dotnet /app/NativeAuth.dll assert-untrusted +test -s /trust/root.crt +cp /trust/root.crt /usr/local/share/ca-certificates/printing-native-fixture.crt +update-ca-certificates >/dev/null 2>&1 +exec dotnet /app/NativeAuth.dll test diff --git a/dev/native-auth/start-server.sh b/dev/native-auth/start-server.sh new file mode 100644 index 0000000..8b136b4 --- /dev/null +++ b/dev/native-auth/start-server.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +# Only this disposable container's JRE trust store is changed. The mounted file +# is the public root certificate; the CA private key is never mounted here. +test -s /trust/root.crt +keytool -importcert -noprompt -alias printing-native-fixture -file /trust/root.crt -cacerts -storepass changeit >/dev/null 2>&1 +exec java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' com.kingsrook.qbits.printing.NativeAuthHost diff --git a/dev/run-tests.sh b/dev/run-tests.sh new file mode 100644 index 0000000..1e0b7b2 --- /dev/null +++ b/dev/run-tests.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +case "$TEST_RUN" in run-[a-f0-9]*) ;; *) echo 'Invalid owned test run' >&2; exit 2;; esac +mkdir -p "/work/$TEST_RUN/s3" +java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' com.kingsrook.qbits.printing.DevHost --wait-storage +java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' S3PrintingQualification "/work/$TEST_RUN/s3" http://127.0.0.1:9000 "$TEST_RUN" +exec java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' com.kingsrook.qbits.printing.WorkloadFixture "/work/$TEST_RUN" "$WORKLOAD_MODE" diff --git a/dev/tests/Authentik.Tests.ps1 b/dev/tests/Authentik.Tests.ps1 new file mode 100644 index 0000000..ec4923e --- /dev/null +++ b/dev/tests/Authentik.Tests.ps1 @@ -0,0 +1,68 @@ +#Requires -Version 7.4 +# No Docker or HTTP calls: these regressions exercise private bootstrap state. +BeforeAll { + $script:authScript = Join-Path $PSScriptRoot '../Invoke-Authentik.ps1' + $script:project = 'qbit-printing-dev-0123456789abcdef0123456789abcdef' +} + +Describe 'Authentik development bootstrap' { + BeforeEach { + $state = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $state | Out-Null + $envFile = Join-Path $state 'compose.env' + $prefix = "PRINTING_DEV_PROJECT=$project`nMINIO_ROOT_USER=synthetic`nQQQ_HTTP_PORT=19081`n" + [IO.File]::WriteAllText($envFile, $prefix) + } + + It 'preserves common environment values and creates a matching hashed admin password without console output' { + $output = @(& $authScript -Action Initialize -StateDirectory $state -ProjectName $project) + $output.Count | Should -Be 0 + $environment = Get-Content -LiteralPath $envFile -Raw + $environment.StartsWith($prefix, [StringComparison]::Ordinal) | Should -BeTrue + [regex]::Matches($environment, '(?m)^AUTHENTIK_[A-Z_]+=').Count | Should -Be 5 + $private = Get-Content -LiteralPath (Join-Path $state 'auth-secrets.json') -Raw | ConvertFrom-Json + $match = [regex]::Match($environment, '(?m)^AUTHENTIK_BOOTSTRAP_PASSWORD_HASH=''pbkdf2_sha256\$1000000\$([^$]+)\$([^'']+)''') + $match.Success | Should -BeTrue + $actual = [Convert]::ToBase64String([Security.Cryptography.Rfc2898DeriveBytes]::Pbkdf2($private.adminPassword, [Text.Encoding]::UTF8.GetBytes($match.Groups[1].Value), 1000000, [Security.Cryptography.HashAlgorithmName]::SHA256, 32)) + [string]::Equals($actual, $match.Groups[2].Value, [StringComparison]::Ordinal) | Should -BeTrue + } + + It 'does not rotate credentials or append duplicate keys on repeated initialization' { + & $authScript -Action Initialize -StateDirectory $state -ProjectName $project + $beforeEnvironment = (Get-FileHash -LiteralPath $envFile).Hash + $beforeSecrets = (Get-FileHash -LiteralPath (Join-Path $state 'auth-secrets.json')).Hash + & $authScript -Action Initialize -StateDirectory $state -ProjectName $project + (Get-FileHash -LiteralPath $envFile).Hash | Should -Be $beforeEnvironment + (Get-FileHash -LiteralPath (Join-Path $state 'auth-secrets.json')).Hash | Should -Be $beforeSecrets + } + + It 'rejects a changed project or port without overwriting private state' { + & $authScript -Action Initialize -StateDirectory $state -ProjectName $project + $before = (Get-FileHash -LiteralPath $envFile).Hash + { & $authScript -Action Initialize -StateDirectory $state -ProjectName $project -Port 19011 } | Should -Throw '*different project or port*' + { & $authScript -Action Initialize -StateDirectory $state -ProjectName 'qbit-printing-dev-aaaaaaaa' } | Should -Throw '*different project or port*' + (Get-FileHash -LiteralPath $envFile).Hash | Should -Be $before + } + + It 'rejects partial initialization rather than assigning different secrets to an existing database' { + Add-Content -LiteralPath $envFile -Value 'AUTHENTIK_SECRET_KEY=synthetic' + { & $authScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*Partial Authentik initialization*' + Test-Path -LiteralPath (Join-Path $state 'auth-secrets.json') | Should -BeFalse + } + + It 'rejects missing environment keys after initialization' { + & $authScript -Action Initialize -StateDirectory $state -ProjectName $project + [IO.File]::WriteAllText($envFile, $prefix) + { & $authScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*incomplete or ambiguous*' + } + + It 'withholds malformed private configuration contents' { + [IO.File]::WriteAllText((Join-Path $state 'auth-secrets.json'), '{"private":"synthetic-value-to-withhold"') + { & $authScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*contents are withheld*' + } + + It 'refuses a state directory inside the source checkout before writing' { + $repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) + { & $authScript -Action Initialize -StateDirectory $repository -ProjectName $project } | Should -Throw '*outside the source checkout*' + } +} diff --git a/dev/tests/Lifecycle.Tests.ps1 b/dev/tests/Lifecycle.Tests.ps1 new file mode 100644 index 0000000..287b5cc --- /dev/null +++ b/dev/tests/Lifecycle.Tests.ps1 @@ -0,0 +1,126 @@ +#Requires -Version 7.4 +# Ownership regressions use mocked Docker metadata; no containers or network calls. +BeforeAll { + $script:entrypoint = Join-Path $PSScriptRoot '../Invoke-Dev.ps1' + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($entrypoint, [ref]$tokens, [ref]$errors) + if ($errors.Count) + { + throw 'Entrypoint parsing failed.' + } + foreach ($name in @('Assert-OwnedResource', 'Get-RecordedAuthentikPort')) + { + $guard = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true) + . ([scriptblock]::Create($guard.Extent.Text)) + } + $script:project = 'qbit-printing-dev-0123456789abcdef0123456789abcdef' + function docker + { + throw 'Unmocked Docker call refused.' + } +} + +Describe 'Development lifecycle ownership' { + BeforeEach { + $script:wrongResource = '' + Mock docker { + $global:LASTEXITCODE = 0 + $command = $args -join ' ' + if ($command.StartsWith('ps -aq', [StringComparison]::Ordinal)) + { + return 'container-id' + } + if ($command.StartsWith('network ls', [StringComparison]::Ordinal)) + { + return 'network-id' + } + if ($command.StartsWith('volume ls', [StringComparison]::Ordinal)) + { + return 'volume-id' + } + if ($script:wrongResource -and $command.Contains($script:wrongResource, [StringComparison]::Ordinal)) + { + return 'another-project' + } + return $script:project + } + } + It 'accepts only consistently labeled containers, networks and volumes' { + { Assert-OwnedResource } | Should -Not -Throw + Should -Invoke docker -Times 6 -Exactly + } + It 'rejects a foreign container before any cleanup' { + $script:wrongResource = 'container-id' + { Assert-OwnedResource } | Should -Throw '*Container ownership mismatch*' + Should -Invoke docker -ParameterFilter { ($args -join ' ') -match 'down|remove|prune' } -Times 0 + } + It 'rejects a foreign network before any cleanup' { + $script:wrongResource = 'network-id' + { Assert-OwnedResource } | Should -Throw '*Network ownership mismatch*' + Should -Invoke docker -ParameterFilter { ($args -join ' ') -match 'down|remove|prune' } -Times 0 + } + It 'rejects a foreign volume before any cleanup' { + $script:wrongResource = 'volume-id' + { Assert-OwnedResource } | Should -Throw '*Volume ownership mismatch*' + Should -Invoke docker -ParameterFilter { ($args -join ' ') -match 'down|remove|prune' } -Times 0 + } + It 'refuses an existing unowned state directory before contacting Docker' { + { & $entrypoint -Action Stop -StateDirectory $TestDrive } | Should -Throw '*lacks this tool ownership record*' + Should -Invoke docker -Times 0 + } + It 'rejects persistent experimental startup before any state creation' { + $path = Join-Path $TestDrive 'experimental-start' + { & $entrypoint -Action Start -ExperimentalNativeAuth -StateDirectory $path } | Should -Throw '*Test-only profile*' + Test-Path -LiteralPath $path | Should -BeFalse + Should -Invoke docker -Times 0 + } + It 'does not repurpose a saved standard environment for experimental authentication' { + $path = Join-Path $TestDrive 'standard-environment' + [IO.Directory]::CreateDirectory($path) | Out-Null + @{sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')); project = $project; authentik = $false } | + ConvertTo-Json | Set-Content (Join-Path $path 'owner.json') + { & $entrypoint -Action Test -ExperimentalNativeAuth -StateDirectory $path } | Should -Throw '*cannot be repurposed*' + Should -Invoke docker -Times 0 + } + It 'refuses a forged project name before contacting Docker' { + $path = Join-Path $TestDrive 'forged' + [IO.Directory]::CreateDirectory($path) | Out-Null + @{sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')); project = 'unrelated'; authentik = $false } | ConvertTo-Json | Set-Content (Join-Path $path 'owner.json') + { & $entrypoint -Action Stop -StateDirectory $path } | Should -Throw '*State ownership mismatch*' + Should -Invoke docker -Times 0 + } + It 'restores a nondefault Authentik port from saved state' { + $script:envFile = Join-Path $TestDrive 'ports.env' + [IO.File]::WriteAllText($envFile, "QQQ_HTTP_PORT=19081`nAUTHENTIK_HTTP_PORT=19011`n") + Get-RecordedAuthentikPort | Should -Be 19011 + } + It 'rejects ambiguous saved port configuration' { + $script:envFile = Join-Path $TestDrive 'ambiguous.env' + [IO.File]::WriteAllText($envFile, "AUTHENTIK_HTTP_PORT=19010`nAUTHENTIK_HTTP_PORT=19011`n") + { Get-RecordedAuthentikPort } | Should -Throw '*missing or invalid*' + } + It 'rejects a state ancestor junction or symlink before writing or contacting Docker' { + $target = Join-Path $TestDrive 'target' + $link = Join-Path $TestDrive 'linked' + [IO.Directory]::CreateDirectory($target) | Out-Null + if ($IsWindows) + { + New-Item -ItemType Junction -Path $link -Target $target -ErrorAction Stop | Out-Null + } + else + { + New-Item -ItemType SymbolicLink -Path $link -Target $target -ErrorAction Stop | Out-Null + } + try + { + { & $entrypoint -Action Start -StateDirectory (Join-Path $link 'new-state') } | Should -Throw '*symbolic link or reparse point*' + Test-Path -LiteralPath (Join-Path $target 'new-state') | Should -BeFalse + Should -Invoke docker -Times 0 + } + finally + { + [IO.Directory]::Delete($link) + } + } +} diff --git a/dev/tests/NativeAuth.Tests.ps1 b/dev/tests/NativeAuth.Tests.ps1 new file mode 100644 index 0000000..f66e9fe --- /dev/null +++ b/dev/tests/NativeAuth.Tests.ps1 @@ -0,0 +1,68 @@ +#Requires -Version 7.4 +# No Docker, TLS, provider or QQQ calls; only private input and result validation. +BeforeAll { + $script:nativeScript = Join-Path $PSScriptRoot '../Invoke-NativeAuth.ps1' + $script:project = 'qbit-printing-dev-0123456789abcdef0123456789abcdef' + $script:requiredChecks = @('untrusted-tls-refused', 'typed-native-grant', 'real-provider-login', 'wrong-origin-cookie-denied', 'permission-denied-user', 'logout-replay-denied', 'server-refresh-after-expiry', 'provider-revocation-denied') +} + +Describe 'Experimental native provider qualification state' { + BeforeEach { + $state = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $state | Out-Null + $envFile = Join-Path $state 'compose.env' + $prefix = "PRINTING_DEV_PROJECT=$project`nMINIO_ROOT_USER=synthetic`n" + [IO.File]::WriteAllText($envFile, $prefix) + @{ project = $project; clientId = $project; bootstrapToken = 'synthetic-admin'; clientSecret = 'synthetic-client'; userPassword = 'synthetic-user' } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-secrets.json') + $script:report = @{ + status = 'PASS'; scope = 'DEVELOPMENT_ONLY_UNRELEASED'; project = $project + coreSource = 'ecf0f550a0357cec6387c2b54efca128a405a629' + clientSource = '346fc285198908c47a2ee9db7b9adb53d5e88c3c'; checks = $requiredChecks + } + $script:resultFile = Join-Path $state 'native-auth-result.json' + } + + It 'preserves common state and creates only the exact owned destinations without console secrets' { + $output = @(& $nativeScript -Action Initialize -StateDirectory $state -ProjectName $project) + $output.Count | Should -Be 0 + (Get-Content -LiteralPath $envFile -Raw).StartsWith($prefix, [StringComparison]::Ordinal) | Should -BeTrue + $inputData = Get-Content -LiteralPath (Join-Path $state 'native-input.json') -Raw | ConvertFrom-Json + $inputData.qqqOrigin | Should -Be 'https://qqq.printing.internal' + $inputData.callback | Should -Be 'https://callback.printing.internal/oauth/callback' + $inputData.project | Should -Be $project + (Get-Content -LiteralPath $script:resultFile -Raw | ConvertFrom-Json).status | Should -Be 'NOT_RUN' + $before = (Get-FileHash -LiteralPath $envFile).Hash + { & $nativeScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*fresh owned environment*' + (Get-FileHash -LiteralPath $envFile).Hash | Should -Be $before + } + + It 'refuses credentials owned by another project before writing native input' { + { & $nativeScript -Action Initialize -StateDirectory $state -ProjectName 'qbit-printing-dev-aaaaaaaa' } | Should -Throw '*does not match*' + Test-Path -LiteralPath (Join-Path $state 'native-input.json') | Should -BeFalse + } + + It 'accepts the exact source and complete bounded report' { + $script:report | ConvertTo-Json | Set-Content -LiteralPath $script:resultFile + & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project | Should -Match 'candidate is unpublished' + } + + It 'rejects a passing report from a different source' { + $script:report.coreSource = '0000000000000000000000000000000000000000' + $script:report | ConvertTo-Json | Set-Content -LiteralPath $script:resultFile + { & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*exact source*' + } + + It 'rejects missing renewal evidence instead of treating successful login as complete' { + $script:report.checks = @($requiredChecks | Where-Object { $_ -ne 'server-refresh-after-expiry' }) + $script:report | ConvertTo-Json | Set-Content -LiteralPath $script:resultFile + { & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*required checks*' + } + + It 'rejects malformed or oversized reports without echoing their contents' { + [IO.File]::WriteAllText($script:resultFile, '{"private":"do-not-echo"') + { & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*contents are withheld*' + [IO.File]::WriteAllText($script:resultFile, ('x' * 65537)) + { & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*bounded size*' + } +} diff --git a/dev/tests/ProviderDiagnostics.Tests.ps1 b/dev/tests/ProviderDiagnostics.Tests.ps1 new file mode 100644 index 0000000..09e821a --- /dev/null +++ b/dev/tests/ProviderDiagnostics.Tests.ps1 @@ -0,0 +1,48 @@ +#Requires -Version 7.4 +BeforeAll { + $path = Join-Path $PSScriptRoot '../Invoke-Dev.ps1' + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw 'Entrypoint parsing failed.' } + $function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'ConvertTo-ProviderErrorSummary' }, $true) + . ([scriptblock]::Create($function.Extent.Text)) +} + +Describe 'Private provider diagnostic redaction' { + It 'retains source coordinates but excludes exception values, locals and request data' { + $record = @{event = 'secret-request'; exception = @(@{exc_type = 'KeyError'; exc_value = 'secret-value'; frames = @( + @{filename = '/authentik/flows/views/executor.py'; lineno = 42; name = 'post'; locals = @{password = 'secret-password'}; source = 'secret-source'}, + @{filename = '/outside/secret-path.py'; lineno = 3; name = 'unsafe'} + ) + }) + } | ConvertTo-Json -Depth 8 -Compress + $result = ConvertTo-ProviderErrorSummary @($record, 'secret-unstructured-line') + $result.exceptionTypes | Should -Be @('KeyError') + $result.frames.Count | Should -Be 1 + $result.frames[0].function | Should -Be 'post' + $result | ConvertTo-Json -Depth 6 | Should -Not -Match 'secret|password|outside|locals|source' + } + It 'bounds frames and does not retain malformed or query-bearing coordinates' { + $frames = @(1..20 | ForEach-Object { @{filename = '/authentik/test.py'; lineno = $_; name = 'get'} }) + $record = @{exception = @(@{exc_type = 'ValueError'; frames = $frames})} | ConvertTo-Json -Depth 8 -Compress + (ConvertTo-ProviderErrorSummary @($record)).frames.Count | Should -Be 12 + $bad = @{exception = @(@{exc_type = 'secret?Error'; frames = @(@{filename = '/authentik/test.py?secret'; lineno = 1; name = 'get'})})} | ConvertTo-Json -Depth 8 -Compress + $result = ConvertTo-ProviderErrorSummary @($bad, '{broken', 'null') + $result.exceptionTypes.Count | Should -Be 0 + $result.frames.Count | Should -Be 0 + } + It 'records only fixed known warning flags from unstructured output' { + $result = ConvertTo-ProviderErrorSummary @('Invalid client identifier secret-query', 'Non-applicable authentication flow secret', 'failed to resolve application secret') + $result.warnings.invalidClientIdentifier | Should -BeTrue + $result.warnings.nonApplicableAuthenticationFlow | Should -BeTrue + $result.warnings.failedToResolveApplication | Should -BeTrue + $result | ConvertTo-Json -Depth 6 | Should -Not -Match 'secret' + } + It 'retains fixed DRF exception names without their messages' { + $record = @{exception = @(@{exc_type = 'PermissionDenied'; exc_value = 'secret-csrf-value'; frames = @()})} | ConvertTo-Json -Depth 5 -Compress + $result = ConvertTo-ProviderErrorSummary @($record) + $result.exceptionTypes | Should -Be @('PermissionDenied') + $result | ConvertTo-Json -Depth 6 | Should -Not -Match 'secret' + } +} diff --git a/dev/tests/WorkloadFailure.Tests.ps1 b/dev/tests/WorkloadFailure.Tests.ps1 new file mode 100644 index 0000000..e32908d --- /dev/null +++ b/dev/tests/WorkloadFailure.Tests.ps1 @@ -0,0 +1,60 @@ +#Requires -Version 7.4 +BeforeAll { + $script:entrypoint = Join-Path $PSScriptRoot '../Invoke-Dev.ps1' + function docker { throw 'Unmocked Docker call refused.' } +} + +Describe 'Early workload client failure' { + BeforeEach { + $script:state = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N')) + @{success = $false; stage = 'native_login_and_channels'; error = 'QqqSessionException'; reason = 'secret-value https://private.invalid/?token=secret'; completed = 0} | ConvertTo-Json | Set-Content (Join-Path $TestDrive 'client-fixture.json') + Mock docker { + $global:LASTEXITCODE = 0 + if ($args[0] -eq 'compose') + { + if ($args -contains 'ps') + { + if ($args[-1] -eq 'test-client') { return ('c' * 64) } + if ($args[-1] -eq 'test-server') { return ('b' * 64) } + } + return + } + if ($args[0] -eq 'inspect') + { + if ($args[1] -eq ('c' * 64)) { return 'false|17' } + throw 'SERVER_WAIT_REACHED' + } + if ($args[0] -eq 'cp') + { + $fixtureRoot = [IO.Directory]::GetParent([IO.Directory]::GetParent([IO.Directory]::GetParent($args[-1]).FullName).FullName).FullName + Copy-Item -LiteralPath (Join-Path $fixtureRoot 'client-fixture.json') -Destination $args[-1] + return + } + if ($args[0] -in @('ps', 'network', 'volume')) { return } + throw 'Unexpected fake Docker operation.' + } + } + It 'surfaces the client failure before server waiting and still cleans owned containers' { + { & $entrypoint -Action Test -StateDirectory $script:state } | Should -Throw '*Workload client failed at native_login_and_channels (QqqSessionException)*' + Should -Invoke docker -ParameterFilter { $args[0] -eq 'inspect' -and $args[1] -eq ('b' * 64) } -Times 0 + Should -Invoke docker -ParameterFilter { $args[0] -eq 'compose' -and $args -contains 'rm' -and $args -contains '--stop' } -Times 1 -Exactly + $result = Get-ChildItem -LiteralPath $script:state -Filter result.json -Recurse + (Get-Content -LiteralPath $result.FullName -Raw | ConvertFrom-Json).reason | Should -Be 'secret-value https://private.invalid/?token=secret' + } + It 'does not print unrecognized stage or exception text' { + @{success = $false; stage = 'secret-stage https://private.invalid/'; error = 'secret-error'} | ConvertTo-Json | Set-Content (Join-Path $TestDrive 'client-fixture.json') + { & $entrypoint -Action Test -StateDirectory $script:state } | Should -Throw '*Workload client failed at unknown (UnknownFailure)*' + Should -Invoke docker -ParameterFilter { $args[0] -eq 'inspect' -and $args[1] -eq ('b' * 64) } -Times 0 + } + It 'fails safely for diagnostics and still cleans owned containers' -TestCases @( + @{Kind = 'malformed'; Content = '{secret-malformed-json'}, + @{Kind = 'null'; Content = 'null'}, + @{Kind = 'oversized'; Content = (@{success = $false; stage = 'input'; error = 'JsonException'; reason = ('s' * 65536)} | ConvertTo-Json -Compress)} + ) { + param($Content) + $Content | Set-Content (Join-Path $TestDrive 'client-fixture.json') + { & $entrypoint -Action Test -StateDirectory $script:state } | Should -Throw '*Workload client failed at unknown (UnknownFailure)*' + Should -Invoke docker -ParameterFilter { $args[0] -eq 'inspect' -and $args[1] -eq ('b' * 64) } -Times 0 + Should -Invoke docker -ParameterFilter { $args[0] -eq 'compose' -and $args -contains 'rm' -and $args -contains '--stop' } -Times 1 -Exactly + } +} diff --git a/docs/QUALIFICATION-2026-11-01.md b/docs/QUALIFICATION-2026-11-01.md new file mode 100644 index 0000000..4e3c7d8 --- /dev/null +++ b/docs/QUALIFICATION-2026-11-01.md @@ -0,0 +1,104 @@ +# Server qualification — 2026-11-01 + +The published server **0.3.0-rc.1** and published Windows **0.3.1-rc.1** Core runtime +completed the following isolated qualification. No live server, installed service, +real document, physical printer or external cloud bucket was changed. + +## Network workload + +These measurements describe the earlier released-artifact fixture runs. The +portable [Docker developer tooling](../dev/README.md) records its own per-run +measurements and includes separate default smoke and opt-in burst commands. + +The existing Java/.NET interoperability fixture was extended with synthetic work. +It used the actual QQQ table-authenticated host, native session/device binding, +WebSocket channel, released Core `PrintWorker`, HTTPS-client code over explicitly +allowed loopback HTTP, file downloads, and WebSocket receipt acknowledgements. +Only the physical printer and local fixture journal were substitutes. The server +persisted real SQL jobs and receipts. Admission limits remained enabled. + +| Check | Observed result | +| --- | --- | +| Burst workload | 10,000 jobs; two tenants; eight devices | +| Documents | 1,021-byte synthetic ZPL; exact downloaded bytes checked | +| Completion | 10,000 SPOOLED rows; 30,000 durable event rows; no pending work | +| Replay | 400 submit and 400 receipt replays; no duplicates | +| Isolation | Foreign device binding and tenant job access refused | +| Accounting | Retained bytes exact; no orphan upload rows | +| Duration | 96.12 seconds including setup; about 104 jobs/second | +| Maximum backlog | 7,798 committed jobs; final backlog zero | +| Working set | Server peak 680 MiB; client peak approximately 85 MiB | +| Cold persistence | 10,000 rows present; read-only check left DB unchanged | + +Burst enqueue-to-terminal-ACK latency was p50 43.30 seconds, p95 62.84 seconds, +p99 64.66 seconds. These values include intentional queue backlog; they are not +idle notification latency. + +A separate **40-job one-at-a-time** run measured: + +| Interval | Median | 95th percentile | +| --- | ---: | ---: | +| Received notification to claim completion | 8.72 ms | 27.18 ms | +| Received notification to verified download completion | 29.74 ms | 49.07 ms | +| Enqueue to durable terminal receipt acknowledgement | 49.65 ms | 76.56 ms | + +The fixture does not periodically poll for jobs. A received notification wakes a +finite claim/drain operation. Liveness PING messages do not query jobs. These are +HTTP/WS loopback results, **not HTTPS/WSS, remote network, physical print-speed, +large-document sustained throughput, or long-duration soak qualification**. +The fixture journal is not the installed Windows SQLite journal; its recovery +behavior is covered separately by the Windows tests. + +## Existing storage path, current release + +The existing S3 qualification harness was rerun against this exact published +server and its unchanged 204-JAR runtime. All **65 checks / six document round +trips** passed through native QQQ `StorageAction`, `S3StorageAction`, and AWS SDK +against one disposable local MinIO container. The run includes actual multipart +upload above 5 MiB, exact length/hash/MIME, tenant namespaces, immutable-reference +validation, stable replay, completion-age retention, and protection of active, +failed, uncertain, fresh, foreign and unrelated objects. The owned container and +its ephemeral data were removed after identity verification. + +This is S3-compatible data-plane evidence. It does not qualify AWS IAM, remote +TLS, bucket policies, versioning/Object Lock, or a configured production storage +target. The existing platform-specific key-layout binding guard remains required; +no shared QQQ library or cross-OS migration behavior was changed. + +## Repeatable service regression + +`PrintingWorkloadTest` is an opt-in file-backed regression, separate from the +network fixture. It runs 10,000 real service lifecycles through four pooled SQL +connections, tests replay and tenant pagination, verifies exact event/byte counts, +and closes every connection before reopening without schema initialization. + +```powershell +./mvnw.cmd -Pui-demo -Dprinting.qualification.load=true verify +``` + +The protocol fixtures must be present at the usual sibling checkout; alternatively +set `-Dprinting.protocol.fixtures=/path/to/reviewed/fixtures`. +The full gate passed **119 tests, zero skips and zero Checkstyle violations**. +A subsequent exact event-count assertion improvement passed its focused workload +rerun. Default builds skip this explicit workload test; ordinary regression tests +remain unchanged. The separate development workflow runs cross-platform script +checks and the real 40-job/S3 Docker smoke on pull requests and main pushes; its +experimental native-auth run is manual. Hosted results must be checked on the +submitted commit. No application implementation or dependency changed. + +## Scope boundaries + +The user's current development/test target is local Docker. AWS/account/IAM or +remote deployment provisioning is not a prerequisite for this change. + +The separate [experimental native-auth profile](../dev/native-auth/README.md) +passed real Authentik login, denied-user/wrong-Origin checks, logout, short token +renewal and provider revocation using container-private HTTPS and the pinned +unreleased QQQ API 1.9 candidate. It does not upgrade this regression's released +RC.20 dependency or qualify the installed Windows browser callback. The workload +and S3 measurements above remain HTTP/WS and MinIO evidence, respectively. + +Long-duration soak, mid-session account/role changes and sustained physical +throughput remain separate from these bounded tests and previously confirmed +individual physical prints. No application implementation, dependency or +published package version changes in this test/developer-tooling change. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 6537dbd..b9950d8 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,61 @@ # Session state — 2026-10-31 +## Local Docker development — 2026-11-01 + +The current qualification scope is local Docker only. The new `dev/Invoke-Dev.ps1` +provides owned Start/Test/Stop commands for a native QQQ TABLE host, dedicated +MinIO storage and optional real Authentik/PostgreSQL. Source images and the client +Core archive are pinned. Generated credentials stay in private state outside the +checkout; no installed connector, live database or physical printer is used. + +Portable Docker checks passed: the default 40-job smoke, 65 actual QQQ S3 backend +checks, and an opt-in 10,000-job/two-tenant/eight-client run with 30,000 durable +server receipts and 400 submission plus 400 receipt replays. The burst completed +in 147.856 seconds (about 67.63 jobs/second); its enqueue-to-ACK p95 was 32.984 +seconds while work intentionally queued. This is HTTP/WS with a fake file printer +and a simple fixture client journal, not TLS, hardware or a long soak claim. + +Native TABLE login, identity, frontend asset and logout passed. Real Authentik +configuration/discovery passed; a registered redirect continued to login and the +otherwise-identical unregistered redirect was rejected. The released RC.20 host +still advertises API 1.8 without the native session grant: browser login, renewal +and the Windows callback are not established by these checks. All owned test +containers, volumes and networks were removed. Lifecycle/bootstrap regressions +passed 28 cases including experimental-profile refusal and diagnostic-redaction +checks; scripts passed parser and PSScriptAnalyzer checks. + +The separate opt-in native-auth profile passed eight actual checks against the +immutable upstream API 1.9 candidate `ecf0f550a0357cec6387c2b54efca128a405a629`: +ordinary TLS refusal before container-only CA installation, typed-grant discovery, +real Authentik login through the production Core PKCE client, wrong-Origin cookie +rejection, denied-user permissions, logout replay refusal, server-side renewal +after access-token expiry and denial after provider refresh-token revocation. +The final run exited zero and all owned containers, volumes and networks were +removed. Earlier failed fixture attempts remain in private evidence; their fixes +were standard-flow readiness/binding, bounded provider redirects and the correct +Authentik CSRF header. No authentication check was disabled. + +This experimental candidate is not the default released RC.20 runtime or a +published production dependency upgrade. Container HTTPS and automated real +provider flows do not qualify the Windows browser callback UI, host trust, +months-long operation or physical printing. See [local development](../dev/README.md), +[provider limits](../dev/AUTHENTIK.md) and [native qualification](../dev/native-auth/README.md). +Earlier release checkpoints below are historical; no publication or live change +was performed by this development-tooling lane. + +## Opt-in service workload regression — 2026-11-01 + +The combined test/development change also includes a 10,000-job, two-tenant, +eight-lane file-backed service regression with replay, exact accounting, +pagination and cold reopen checks. It is opt-in and never accesses a printer. +The original isolated full gate passed 119 tests with zero Checkstyle violations; +the final exact receipt-count assertion passed its focused rerun. Its source is +unchanged when integrated here, and the combined checkout passed one focused +workload test with zero Checkstyle violations. The earlier released-artifact +network run and +40-job idle latency measurements are recorded in +[qualification evidence](QUALIFICATION-2026-11-01.md), separately from the portable +Docker run above. No runtime source, dependencies or version changed. ## Active device controls and standard QQQ authentication Implementation is in the isolated device-controls worktree. Native device/table actions support pause, resume, disconnect, reconnect, revoke, inventory refresh and queued-job clearing; job records support exact owned Windows-job cancellation. Related printers, jobs, pending queue and bounded command history are registered. Derived status separates connection presence, desired state and observed revision. Domain writes use current QQQ permissions; clearing a queue checks every affected job before the transactional mutation. diff --git a/docs/TODO.md b/docs/TODO.md index bbdc046..089734d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,24 @@ # Follow-up +## Current local development qualification + +- [x] Supply portable, owned Docker Start/Test/Stop commands with private synthetic + credentials and explicit cleanup; leave the installed pairing untouched. +- [x] Run the 40-job smoke, 65 real S3 backend checks and 10,000 network jobs with + 30,000 durable server receipts and replay/isolation assertions. +- [x] Qualify real Authentik provider discovery and registered/unregistered redirect + controls while reporting the released QQQ 1.8 native-grant absence honestly. +- [x] Qualify the separate experimental API 1.9/container-TLS native login, + denied-user/wrong-Origin permissions, logout, short renewal and provider revocation. +- [x] Include the opt-in 10,000-job pooled-SQL/cold-reopen service regression and + preserve separate released-artifact network/idle-latency evidence. +- [ ] Review and publish the developer tooling through repository gates. Do not + promote experimental core artifacts into the released application's dependency + set as a side effect of these tests. + +The older release checklists below are historical context. AWS/cloud-account +provisioning is outside the user's current local-Docker-only request. + - [x] Complete native device controls, per-record/selection actions, queue/history, dashboard and legacy-boundary integration tests; 110 tests pass with zero Checkstyle violations. - [x] Complete 25 actual Windows Core client/native QQQ interoperability checks with table-based authentication, no printers and no jobs. - [ ] Complete installed candidate acceptance after protected CI. @@ -46,7 +65,9 @@ - [x] Implement the dedicated QQQ object-storage document backend; live S3/IAM qualification remains pending configuration. - [ ] Logical destination mappings. - [x] Run the coordinated Java gate for profile migration, long text and 10,000-row bounded-history regressions; included in the passing 28-test v0.1.3-rc.1 gate. -- [ ] Qualify sustained throughput separately from synthetic bounded-history tests. +- [ ] Qualify long-duration soak and sustained physical throughput separately + from the completed bounded network/fake-printer workloads; see + [qualification evidence](QUALIFICATION-2026-11-01.md). - [x] Device-scoped test/retry/cancel/queued-job routes and native record/table cancellation/reprint review, explicit duplicate-risk consent and retry idempotency. - [x] Implement admin document retention and bounded scheduled cleanup, default 365 days. - [ ] Server-side job expiry and operational alerts. diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingWorkloadTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingWorkloadTest.java new file mode 100644 index 0000000..6af87df --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingWorkloadTest.java @@ -0,0 +1,255 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.h2.jdbcx.JdbcConnectionPool; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Opt-in service workload: real file-backed SQL and receipts, never a printer. + *******************************************************************************/ +@EnabledIfSystemProperty(named = "printing.qualification.load", matches = "true") +class PrintingWorkloadTest +{ + private static final int LANES = 8; + private static final int JOBS_PER_LANE = 1250; + private static final byte[] CONTENT = ("^XA^FO20,20^FDSYNTHETIC - NOT SENT TO A PRINTER^FS" + " ".repeat(970) + "^XZ").getBytes(StandardCharsets.UTF_8); + + @TempDir + Path directory; + + + /*************************************************************************** + ** Run a day's sizing target through actual admission, claim and persistence. + ***************************************************************************/ + @Test + @Timeout(value = 10, unit = TimeUnit.MINUTES) + void completesTenThousandJobsWithReplayAndColdReopen() throws Exception + { + String jdbc = "jdbc:h2:file:" + directory.resolve("workload").toAbsolutePath().toString().replace('\\', '/'); + JdbcConnectionPool pool = JdbcConnectionPool.create(jdbc, "sa", ""); + pool.setMaxConnections(4); + pool.setLoginTimeout(15); + List> expected = List.of(ConcurrentHashMap.newKeySet(), ConcurrentHashMap.newKeySet()); + List> lanes = new ArrayList<>(); + CountDownLatch start = new CountDownLatch(1); + new PrintingService(pool, "tenant-0").initialize(); + for(int lane = 0; lane < LANES; lane++) + { + String tenant = "tenant-" + (lane % 2); + PrintingService service = new PrintingService(pool, tenant); + String device = enroll(service, lane); + int laneIndex = lane; + lanes.add(() -> + { + start.await(); + return runLane(pool, tenant, device, laneIndex, expected.get(laneIndex % 2)); + }); + } + long began = System.nanoTime(); + List durations = new ArrayList<>(); + try + { + try(var executor = Executors.newFixedThreadPool(LANES)) + { + var futures = lanes.stream().map(executor::submit).toList(); + start.countDown(); + for(var future : futures) + { + for(long duration : future.get(8, TimeUnit.MINUTES)) + { + durations.add(duration); + } + } + } + for(int tenant = 0; tenant < 2; tenant++) + { + verifyHistory(new PrintingService(pool, "tenant-" + tenant), expected.get(tenant)); + } + verifyAccounting(pool); + assertEquals(0, pool.getActiveConnections()); + } + finally + { + pool.dispose(); + } + // All pooled connections close before reopening. No application initialize repairs the database. + JdbcConnectionPool reopened = JdbcConnectionPool.create(jdbc, "sa", ""); + try + { + verifyAccounting(reopened); + for(int tenant = 0; tenant < 2; tenant++) + { + verifyHistory(new PrintingService(reopened, "tenant-" + tenant), expected.get(tenant)); + } + } + finally + { + reopened.dispose(); + } + double seconds = (System.nanoTime() - began) / 1_000_000_000.0; + long[] sorted = durations.stream().mapToLong(Long::longValue).sorted().toArray(); + JSONObject report = new JSONObject().put("completedAt", Instant.now().toString()).put("jobs", sorted.length) + .put("tenants", 2).put("devices", LANES).put("poolConnections", 4).put("bytesPerDocument", CONTENT.length) + .put("elapsedSeconds", seconds).put("jobsPerSecond", sorted.length / seconds) + .put("p50LifecycleMillis", sorted[sorted.length / 2] / 1_000_000.0) + .put("p95LifecycleMillis", sorted[(int) (sorted.length * 0.95)] / 1_000_000.0) + .put("p99LifecycleMillis", sorted[(int) (sorted.length * 0.99)] / 1_000_000.0) + .put("replayedSubmissions", LANES * (JOBS_PER_LANE / 25)).put("replayedReceipts", sorted.length) + .put("coldReopenVerified", true).put("physicalPrinting", false).put("httpOrS3Throughput", false) + .put("javaVersion", System.getProperty("java.version")).put("os", System.getProperty("os.name")); + Path reportPath = Path.of("target", "qualification", "service-workload.json"); + Files.createDirectories(reportPath.getParent()); + Files.writeString(reportPath, report.toString(2)); + } + + + /*************************************************************************** + ** Each lane represents one device with serial physical submission semantics. + ***************************************************************************/ + private static long[] runLane(JdbcConnectionPool pool, String tenant, String device, int lane, Set expected) throws Exception + { + PrintingService service = new PrintingService(pool, tenant); + long[] durations = new long[JOBS_PER_LANE]; + for(int index = 0; index < JOBS_PER_LANE; index++) + { + long began = System.nanoTime(); + JSONObject request = new JSONObject().put("deviceId", device).put("printerId", "synthetic") + .put("title", "Synthetic workload " + index).put("format", "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(CONTENT)) + .put("idempotencyKey", "lane-" + lane + "-job-" + index); + String job = service.submit(request).getString("jobId"); + assertTrue(expected.add(job)); + if(index % 25 == 0) + { + assertEquals(job, service.submit(request).getString("jobId")); + } + assertEquals(job, service.claim(device).getJSONObject("job").getString("jobId")); + assertArrayEquals(CONTENT, service.content(device, job)); + for(String status : List.of("RECEIVED", "SUBMITTING", "SPOOLED")) + { + JSONObject event = new JSONObject().put("eventId", "lane-" + lane + "-job-" + index + "-" + status).put("status", status); + if("SPOOLED".equals(status)) + { + event.put("spoolerJobId", "synthetic-" + index); + } + service.event(device, job, event); + if("SPOOLED".equals(status)) + { + service.event(device, job, event); + } + } + durations[index] = System.nanoTime() - began; + } + assertTrue(service.claim(device).isNull("job")); + return (durations); + } + + + /*************************************************************************** + ** Service pagination must return every completed job once, without bytes. + ***************************************************************************/ + private static void verifyHistory(PrintingService service, Set expected) throws Exception + { + Set actual = new HashSet<>(); + String cursor = null; + do + { + JSONObject page = service.jobsPage(137, cursor); + JSONArray jobs = page.getJSONArray("jobs"); + assertTrue(jobs.length() <= 137); + for(int index = 0; index < jobs.length(); index++) + { + JSONObject job = jobs.getJSONObject(index); + assertTrue(actual.add(job.getString("jobId"))); + assertEquals("SPOOLED", job.getString("status")); + assertFalse(job.has("contentBase64")); + } + cursor = page.isNull("nextCursor") ? null : page.getString("nextCursor"); + } + while(cursor != null); + assertEquals(expected, actual); + assertEquals(LANES * JOBS_PER_LANE / 2, actual.size()); + } + + + /*************************************************************************** + ** Replays do not inflate stored receipts, retained bytes or queue size. + ***************************************************************************/ + private static void verifyAccounting(JdbcConnectionPool pool) throws Exception + { + try(var connection = pool.getConnection(); var statement = connection.createStatement()) + { + for(String sql : List.of("SELECT COUNT(*) FROM printing_job WHERE status='SPOOLED'", "SELECT COUNT(*) FROM printing_event", + "SELECT SUM(retained_bytes) / " + CONTENT.length + " FROM printing_resource_usage WHERE device_id=''")) + { + try(var rows = statement.executeQuery(sql)) + { + assertTrue(rows.next()); + assertEquals((long) LANES * JOBS_PER_LANE * (sql.contains("printing_event") ? 3 : 1), rows.getLong(1)); + } + } + try(var rows = statement.executeQuery("SELECT COUNT(*) FROM printing_document_orphan")) + { + assertTrue(rows.next()); + assertEquals(0, rows.getLong(1)); + } + } + } + + + /*************************************************************************** + ** Explicit synthetic enrollment and approval never target any Windows queue. + ***************************************************************************/ + private static String enroll(PrintingService service, int lane) throws Exception + { + String device = service.enroll(service.createEnrollment("fixture", "lane-" + lane) + .put("deviceName", "lane-" + lane).put("agentVersion", "qualification")).getString("deviceId"); + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "synthetic").put("name", "Synthetic only") + .put("formats", new JSONArray(List.of("ZPL"))))); + service.enablePrinter(device, "synthetic", true); + return (device); + } +} \ No newline at end of file -- 2.54.0 From bfba557b66ef4acb964ce5ef48bc78a40ca83990 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 10:53:45 -0600 Subject: [PATCH 32/35] docs: record published Docker qualification (#22) Record protected PR21 publication and passing hosted Docker, script and Java results. Keep the experimental upstream candidate distinct from released-runtime adoption and the unchanged live installation. Both Markdown files and diff checks pass; no runtime changes. Prepared and reviewed with Codex (GPT-6). Signed-off-by: James Maes Co-authored-by: Codex (GPT-6) --- docs/SESSION-STATE.md | 18 ++++++++++++++++-- docs/TODO.md | 9 ++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index b9950d8..c3e2dd5 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -2,6 +2,19 @@ ## Local Docker development — 2026-11-01 +The developer tooling is published on `main` through [PR #21](https://github.com/QRun-IO/qbit-printing/pull/21), +merged as `3592decba8087439aff5d1b75a9726c72f460cb9`; its tree matches tested +head `def8d961f3d82a1235f8e45ffbeacb0fbe79c421`. The final hosted +[development run](https://github.com/QRun-IO/qbit-printing/actions/runs/41324370247) +passed the actual 40-job HTTP/WS smoke and 65 S3 checks, nine pinned-Core discovery +compatibility cases, and all 33 script tests on each of Windows and Ubuntu. +The [Java run](https://github.com/QRun-IO/qbit-printing/actions/runs/41324370253) +reported 133 tests: 132 executed, one intentionally skipped opt-in workload test, +zero failures/errors, and zero Checkstyle violations. That workload separately +passed on the combined source. These are development-tooling publication results, +not a server release or live runtime upgrade. The experimental API 1.9 candidate +remains opt-in and is not adopted into the released application dependencies. + The current qualification scope is local Docker only. The new `dev/Invoke-Dev.ps1` provides owned Start/Test/Stop commands for a native QQQ TABLE host, dedicated MinIO storage and optional real Authentik/PostgreSQL. Source images and the client @@ -40,8 +53,8 @@ published production dependency upgrade. Container HTTPS and automated real provider flows do not qualify the Windows browser callback UI, host trust, months-long operation or physical printing. See [local development](../dev/README.md), [provider limits](../dev/AUTHENTIK.md) and [native qualification](../dev/native-auth/README.md). -Earlier release checkpoints below are historical; no publication or live change -was performed by this development-tooling lane. +Earlier release checkpoints below are historical. This developer-tooling merge +did not publish a new server product release or change the live paired runtime. ## Opt-in service workload regression — 2026-11-01 @@ -56,6 +69,7 @@ network run and 40-job idle latency measurements are recorded in [qualification evidence](QUALIFICATION-2026-11-01.md), separately from the portable Docker run above. No runtime source, dependencies or version changed. + ## Active device controls and standard QQQ authentication Implementation is in the isolated device-controls worktree. Native device/table actions support pause, resume, disconnect, reconnect, revoke, inventory refresh and queued-job clearing; job records support exact owned Windows-job cancellation. Related printers, jobs, pending queue and bounded command history are registered. Derived status separates connection presence, desired state and observed revision. Domain writes use current QQQ permissions; clearing a queue checks every affected job before the transactional mutation. diff --git a/docs/TODO.md b/docs/TODO.md index 089734d..f3ee2f4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -12,9 +12,12 @@ denied-user/wrong-Origin permissions, logout, short renewal and provider revocation. - [x] Include the opt-in 10,000-job pooled-SQL/cold-reopen service regression and preserve separate released-artifact network/idle-latency evidence. -- [ ] Review and publish the developer tooling through repository gates. Do not - promote experimental core artifacts into the released application's dependency - set as a side effect of these tests. +- [x] Publish developer tooling through PR #21, merged as + `3592decba8087439aff5d1b75a9726c72f460cb9`: hosted 40-job/65-S3 checks, + 33 script tests on both Windows and Ubuntu, and the ordinary Java gate + (133 reported, 132 executed, one opt-in skip, zero failures/Checkstyle violations). + Keep experimental API 1.9 artifacts opt-in; this merge does not adopt them into + the released application or upgrade the live paired runtime. The older release checklists below are historical context. AWS/cloud-account provisioning is outside the user's current local-Docker-only request. -- 2.54.0 From 02b922aa11fae1bcd7b35d16485fba0a91c5fbba Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 12:04:51 -0600 Subject: [PATCH 33/35] fix(dev): accept compatible printing discovery in native provider tests (#23) Reuse production Core strict discovery and the shared printing-major-2 gate while retaining the exact experimental API 1.9/native-session requirement. Add build-time fake-HTTP boundary cases without changing PKCE, TLS, provider or permission handling. The old native gate fails compatible 2.1.0; the fix passes 12 native and nine default discovery cases with zero compiler warnings/errors. One fresh current-source Docker provider run passes all eight checks with verified owned cleanup. Preserve unreleased-upstream and installed-browser limitations; no live runtime or physical printing changes. Signed-off-by: James Maes Co-authored-by: Codex (GPT-6) --- dev/client/WorkloadDiscovery.cs | 3 +- dev/native-auth/Dockerfile.client | 4 +- dev/native-auth/README.md | 14 +++ dev/native-auth/client/NativeAuth.csproj | 1 + dev/native-auth/client/NativeAuthDiscovery.cs | 22 +++++ .../client/NativeAuthDiscoveryChecks.cs | 93 +++++++++++++++++++ dev/native-auth/client/Program.cs | 10 +- docs/SESSION-STATE.md | 11 +++ docs/TODO.md | 5 + 9 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 dev/native-auth/client/NativeAuthDiscovery.cs create mode 100644 dev/native-auth/client/NativeAuthDiscoveryChecks.cs diff --git a/dev/client/WorkloadDiscovery.cs b/dev/client/WorkloadDiscovery.cs index ed5c9cb..7aecc00 100644 --- a/dev/client/WorkloadDiscovery.cs +++ b/dev/client/WorkloadDiscovery.cs @@ -3,7 +3,7 @@ using Qqq.PrintAgent.Core.Authentication; internal static class WorkloadDiscovery { - internal static async Task DiscoverRealtimeAsync(QqqSessionClient client, CancellationToken cancellationToken) + internal static async Task DiscoverRealtimeAsync(QqqSessionClient client, CancellationToken cancellationToken) { QqqApiDiscovery discovery = await client.DiscoverAsync(cancellationToken); // Core discovery validates strict stable SemVer; realtime uses the same major gate as ConnectPrintingAsync. @@ -12,5 +12,6 @@ internal static class WorkloadDiscovery { throw new InvalidDataException("Native realtime printing API required."); } + return discovery; } } diff --git a/dev/native-auth/Dockerfile.client b/dev/native-auth/Dockerfile.client index 018b7ba..bdc8f36 100644 --- a/dev/native-auth/Dockerfile.client +++ b/dev/native-auth/Dockerfile.client @@ -4,9 +4,11 @@ ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fc RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/ -WORKDIR /fixture +WORKDIR /fixture/dev/native-auth/client COPY dev/native-auth/client . +COPY dev/client/WorkloadDiscovery.cs /fixture/dev/client/WorkloadDiscovery.cs RUN dotnet build -c Release --nologo -o /out +RUN dotnet /out/NativeAuth.dll --check-discovery FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b WORKDIR /app diff --git a/dev/native-auth/README.md b/dev/native-auth/README.md index 702e235..233b116 100644 --- a/dev/native-auth/README.md +++ b/dev/native-auth/README.md @@ -41,6 +41,13 @@ Success is recorded only when every check passes. A failure report contains the stage and exception type, without callback URLs, credentials or response bodies. This short renewal test does not prove months of unattended operation. +Printing discovery accepts stable semantic versions in major 2, including +2.0.0 and 2.1.0, through the same production Core validation and shared major +gate as the default workload. The experimental upstream API must still be +exactly 1.9.0 with `nativeSessionLogin`; typed metadata, PKCE, TLS, callback and +permission checks are unchanged. The native-client image build runs 12 fake-HTTP +compatibility checks before any provider or fixture input is used. + ## Isolation and source pins The upstream source is @@ -80,3 +87,10 @@ On 2026-11-01 the full disposable profile passed all eight checks and exited zer Independent inventory confirmed zero owned containers, volumes and networks after cleanup. This is evidence for the pinned experimental tuple above, not adoption into the default released runtime or installed Windows UI acceptance. + +A fresh run against server source `3592dec` (printing API 2.1.0) and the reviewed +probe compatibility correction also passed all eight checks on 2026-11-01. +All source files matched the pre-run hash manifest through completion, and +independent cleanup again found no owned containers, volumes or networks. +The native image's 12 discovery checks and default workload's nine checks pass. +The unreleased upstream and installed-browser limitations above still apply. diff --git a/dev/native-auth/client/NativeAuth.csproj b/dev/native-auth/client/NativeAuth.csproj index bc47a7d..a02154b 100644 --- a/dev/native-auth/client/NativeAuth.csproj +++ b/dev/native-auth/client/NativeAuth.csproj @@ -8,5 +8,6 @@ /core/Qqq.PrintAgent.Core.dll + diff --git a/dev/native-auth/client/NativeAuthDiscovery.cs b/dev/native-auth/client/NativeAuthDiscovery.cs new file mode 100644 index 0000000..839e5f7 --- /dev/null +++ b/dev/native-auth/client/NativeAuthDiscovery.cs @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Kingsrook, LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +using Qqq.PrintAgent.Core.Authentication; + +namespace Printing.NativeAuth; + +/// Requires the pinned experimental native grant before provider authentication. +internal static class NativeAuthDiscovery +{ + /// Checks actual production discovery without sending credentials. + internal static async Task DiscoverAsync(QqqSessionClient client, CancellationToken token) + { + QqqApiDiscovery discovery = await WorkloadDiscovery.DiscoverRealtimeAsync(client, token); + if(discovery.ApiVersion != "1.9.0" || !discovery.Features.Contains("nativeSessionLogin", StringComparer.Ordinal)) + { + throw new InvalidOperationException("The actual server did not advertise the pinned native-session API."); + } + } +} diff --git a/dev/native-auth/client/NativeAuthDiscoveryChecks.cs b/dev/native-auth/client/NativeAuthDiscoveryChecks.cs new file mode 100644 index 0000000..57a2d5f --- /dev/null +++ b/dev/native-auth/client/NativeAuthDiscoveryChecks.cs @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Kingsrook, LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +using System.Net; +using System.Text.Json; +using Qqq.PrintAgent.Core.Authentication; + +namespace Printing.NativeAuth; + +/// Exercises the real native probe gate against bounded, credential-free HTTP responses. +internal static class NativeAuthDiscoveryChecks +{ + /// Accepts compatible printing releases while preserving the exact upstream grant requirement. + internal static async Task RunAsync() + { + (string PrintingVersion, string ApiVersion, bool NativeGrant, bool Accepted)[] cases = + [ + ("2.0.0", "1.9.0", true, true), + ("2.1.0", "1.9.0", true, true), + ("2.9.17", "1.9.0", true, true), + ("1.9.0", "1.9.0", true, false), + ("3.0.0", "1.9.0", true, false), + ("2.1", "1.9.0", true, false), + ("2.01.0", "1.9.0", true, false), + ("2.1.0-rc.1", "1.9.0", true, false), + ("not-a-version", "1.9.0", true, false), + ("2.1.0", "1.8.0", true, false), + ("2.1.0", "1.10.0", true, false), + ("2.1.0", "1.9.0", false, false), + ]; + foreach((string printingVersion, string apiVersion, bool nativeGrant, bool expected) in cases) + { + using var handler = new DiscoveryHandler(printingVersion, apiVersion, nativeGrant); + using var client = new QqqSessionClient(new Uri("https://printing.example.invalid"), handler); + bool accepted = false; + try + { + await NativeAuthDiscovery.DiscoverAsync(client, CancellationToken.None); + accepted = true; + } + catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired) + { + } + catch(InvalidDataException) + { + } + catch(InvalidOperationException) + { + } + if(accepted != expected || handler.Requests != 1) + { + throw new InvalidDataException($"Native discovery compatibility case {printingVersion}/{apiVersion}/grant={nativeGrant} failed."); + } + } + Console.WriteLine($"Native discovery compatibility checks passed: {cases.Length}."); + } + + + + /// Supplies discovery only and fails if the probe attempts authenticated traffic. + private sealed class DiscoveryHandler(string printingVersion, string apiVersion, bool nativeGrant) : HttpMessageHandler + { + /// Ensures rejected metadata still traversed the public HTTP discovery boundary. + internal int Requests { get; private set; } + + + + /// Refuses any credential or request outside the public discovery boundary. + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(request.Method != HttpMethod.Get || request.RequestUri?.AbsoluteUri != "https://printing.example.invalid/qqq/v1/version" + || request.Headers.Contains("Cookie") || request.Headers.Contains("Authorization") + || request.Headers.Contains("X-QQQ-Device-Id") || request.Headers.Contains("X-QQQ-Device-Token")) + { + throw new InvalidOperationException("Native discovery must precede authentication and device proof."); + } + Requests++; + string[] features = nativeGrant + ? ["version", "metaData", "processes", "processCancel", "extensions", "widgets", "nativeSessionLogin"] + : ["version", "metaData", "processes", "processCancel", "extensions", "widgets"]; + string json = JsonSerializer.Serialize(new + { + apiVersion, + features, + clientChannels = new[] { "api" }, + extensions = new[] { new { name = "printing", version = printingVersion, pathPrefix = "/qbit/printing" } } + }); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) }); + } + } +} diff --git a/dev/native-auth/client/Program.cs b/dev/native-auth/client/Program.cs index 9601094..4e89f50 100644 --- a/dev/native-auth/client/Program.cs +++ b/dev/native-auth/client/Program.cs @@ -20,6 +20,11 @@ internal static class Program public static async Task Main(string[] arguments) { + if(arguments is ["--check-discovery"]) + { + await NativeAuthDiscoveryChecks.RunAsync(); + return 0; + } JsonObject? input = null; try { @@ -99,10 +104,7 @@ internal static class Program using var receiver = new CallbackReceiver(callback); using var first = Session(origin); _stage = "typed-native-discovery"; - QqqApiDiscovery discovery = await first.DiscoverAsync(token); - if(discovery.ApiVersion != "1.9.0" || !discovery.Features.Contains("nativeSessionLogin", StringComparer.Ordinal) - || discovery.Extensions.Single(extension => extension.Name == "printing").Version != "2.0.0") - { throw new InvalidOperationException("The actual server did not advertise the pinned native-session API."); } + await NativeAuthDiscovery.DiscoverAsync(first, token); QqqAuthenticationMetadata metadata = await first.GetAuthenticationMetadataAsync(token); if(metadata.Values?.ClientId != ProviderSetup.Text(input, "clientId")) { throw new InvalidOperationException("Native metadata did not retain the configured client identifier."); } diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index c3e2dd5..5f6a5af 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -2,6 +2,17 @@ ## Local Docker development — 2026-11-01 +The optional native-auth probe now shares production Core discovery and the +default workload's printing-major-2 gate, accepting stable 2.1.0 while retaining +the exact experimental API 1.9/native grant requirement. Its previous exact-2.0.0 +check reproduced a rejection before the fix. Native and default client image +builds pass 12 and nine fake-HTTP compatibility checks respectively, with zero +warnings/errors. One fresh provider run against server source `3592dec` +(printing API 2.1.0) and the reviewed probe correction passed all eight checks. +All source hashes remained unchanged through completion; independent cleanup +found zero owned containers, volumes and networks. No installed runtime, +physical printer or host trust setting changed. + The developer tooling is published on `main` through [PR #21](https://github.com/QRun-IO/qbit-printing/pull/21), merged as `3592decba8087439aff5d1b75a9726c72f460cb9`; its tree matches tested head `def8d961f3d82a1235f8e45ffbeacb0fbe79c421`. The final hosted diff --git a/docs/TODO.md b/docs/TODO.md index f3ee2f4..958bdf9 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,6 +2,11 @@ ## Current local development qualification +- [x] Reuse production discovery and the shared major-2 gate in the optional + native-auth probe; reproduce the old 2.1.0 rejection, then pass 12 native and + nine default workload fake-HTTP compatibility checks without provider launch. +- [x] Rerun the eight-check native provider profile once against printing API 2.1.0 + after review; all eight passed with unchanged source hashes and owned cleanup. - [x] Supply portable, owned Docker Start/Test/Stop commands with private synthetic credentials and explicit cleanup; leave the installed pairing untouched. - [x] Run the 40-job smoke, 65 real S3 backend checks and 10,000 network jobs with -- 2.54.0 From 1a0964ef233d5359eb9933d9f7cb48be677c6508 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 12:41:00 -0600 Subject: [PATCH 34/35] chore: enforce owned source copyright notices (#25) * chore(standards): preserve owned-source copyright notices Add accurate repository copyright and license comments to 50 owned source/build/configuration files. Preserve existing holders, upstream wrappers, legal payloads and schema/data exemptions. Exact inverse-byte reconstruction and applicable parser/token/AST checks prove comment-only changes; existing relevant regressions pass. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes * chore: enforce owned source copyright notices Check real leading comments against explicit repository provenance and retain existing native style gates. Co-Authored-By: Codex GPT-6 Signed-off-by: James Maes --------- Signed-off-by: James Maes Co-authored-by: Codex (GPT-6) --- .github/ISSUE_TEMPLATE/bug.yml | 3 + .github/ISSUE_TEMPLATE/config.yml | 3 + .github/dependabot.yml | 3 + .github/scripts/check-codeql-sarif.py | 3 + .github/scripts/check-maven-inventory.py | 3 + .github/scripts/check-source-headers.py | 128 +++++++++++++++++ .github/scripts/test-maven-inventory.py | 3 + .github/scripts/test-scan-gates.py | 3 + .github/scripts/test-source-headers.py | 156 +++++++++++++++++++++ .github/source-headers.json | 111 +++++++++++++++ .github/workflows/ci.yml | 7 + .github/workflows/codeql-analysis.yml | 3 + .github/workflows/codeql.yml | 3 + .github/workflows/development.yml | 3 + .github/workflows/pr-gates.yml | 3 + .github/workflows/security.yml | 3 + dev/Dockerfile.client | 3 + dev/Dockerfile.server | 3 + dev/Invoke-Authentik.ps1 | 3 + dev/Invoke-Dev.ps1 | 3 + dev/Invoke-NativeAuth.ps1 | 3 + dev/Test-DevelopmentHost.ps1 | 3 + dev/client/DiscoveryChecks.cs | 2 + dev/client/Program.cs | 2 + dev/client/Workload.csproj | 5 + dev/client/WorkloadDiscovery.cs | 2 + dev/compose.authentik.yaml | 3 + dev/compose.native-auth.yaml | 3 + dev/compose.yaml | 3 + dev/java/DevHost.java | 20 +++ dev/java/S3PrintingQualification.java | 20 +++ dev/java/WorkloadFixture.java | 20 +++ dev/log4j2.xml | 5 + dev/native-auth/Dockerfile.client | 3 + dev/native-auth/Dockerfile.server | 3 + dev/native-auth/client/CallbackReceiver.cs | 3 + dev/native-auth/client/FixtureHttp.cs | 3 + dev/native-auth/client/NativeAuth.csproj | 5 + dev/native-auth/client/Program.cs | 3 + dev/native-auth/client/ProviderFlow.cs | 3 + dev/native-auth/client/ProviderSetup.cs | 3 + dev/native-auth/copy-root.sh | 3 + dev/native-auth/java/NativeAuthHost.java | 20 +++ dev/native-auth/start-client.sh | 3 + dev/native-auth/start-server.sh | 3 + dev/run-tests.sh | 3 + dev/tests/Authentik.Tests.ps1 | 3 + dev/tests/Lifecycle.Tests.ps1 | 3 + dev/tests/NativeAuth.Tests.ps1 | 3 + dev/tests/ProviderDiagnostics.Tests.ps1 | 3 + dev/tests/WorkloadFailure.Tests.ps1 | 3 + docs/SOURCE-HEADERS.md | 17 +++ pom.xml | 5 + src/test/resources/log4j2-test.xml | 5 + 54 files changed, 641 insertions(+) create mode 100644 .github/scripts/check-source-headers.py create mode 100644 .github/scripts/test-source-headers.py create mode 100644 .github/source-headers.json create mode 100644 docs/SOURCE-HEADERS.md diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 8c25430..1581cc1 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + name: Bug report description: Report a reproducible printing QBit issue. title: 'bug: ' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7f95f00..6a075fb 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + blank_issues_enabled: true contact_links: - name: Report a security vulnerability privately diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8075d0f..413636c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + version: 2 updates: - package-ecosystem: maven diff --git a/.github/scripts/check-codeql-sarif.py b/.github/scripts/check-codeql-sarif.py index cf44763..8705490 100644 --- a/.github/scripts/check-codeql-sarif.py +++ b/.github/scripts/check-codeql-sarif.py @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + """Reject missing/malformed CodeQL evidence before enforcing the severity gate.""" import json import math diff --git a/.github/scripts/check-maven-inventory.py b/.github/scripts/check-maven-inventory.py index 945aeb2..ffaac49 100644 --- a/.github/scripts/check-maven-inventory.py +++ b/.github/scripts/check-maven-inventory.py @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + """Fail closed unless scanners cover Maven's selected UI-host runtime graph.""" import argparse from collections import Counter diff --git a/.github/scripts/check-source-headers.py b/.github/scripts/check-source-headers.py new file mode 100644 index 0000000..2392c7e --- /dev/null +++ b/.github/scripts/check-source-headers.py @@ -0,0 +1,128 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Check leading notices in explicitly owned, Git-tracked source families.""" + +import fnmatch +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +def selected(name: str, policy: dict[str, Any]) -> bool: + """Select owned paths, honoring documented exact/pattern exclusions first.""" + if any(fnmatch.fnmatchcase(name, item["pattern"]) for item in policy["exclusions"]): + return False + return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"]) + + +def leading_comments(text: str, style: str) -> str: + """Read comments only, preserving required interpreter/XML directive ordering.""" + text = text.removeprefix("\ufeff") + if text.startswith("", text) + if style != "xml" or declaration is None: + raise ValueError("Invalid XML declaration") + text = text[declaration.end():] + elif text.startswith("#!"): + if style not in ("hash", "powershell", "slash"): + raise ValueError("Unexpected interpreter directive") + text = text.partition("\n")[2] + while re.match(r"#Requires\b", text, re.IGNORECASE): + if style != "powershell": + raise ValueError("Unexpected PowerShell directive") + text = text.partition("\n")[2] + comments = [] + while text.strip(): + text = text.lstrip() + if text.startswith(""), "powershell": ("<#", "#>")}[style] + body, separator, rest = text[len(opening):].partition(closing) + if not separator: + raise ValueError("Unterminated leading comment") + comments.append(body) + text = rest + else: + break + return "\n".join(re.sub(r"^\s*[*~]\s?", "", line).strip() for line in "\n".join(comments).splitlines()) + + +def check_notice(name: str, text: str, policy: dict[str, Any]) -> None: + """Require an approved holder/license pair in real leading comment syntax.""" + suffix = Path(name).suffix + style = policy["styles"].get(suffix) + if Path(name).name.startswith("Dockerfile."): + style = "hash" + if style is None: + raise ValueError("Selected path has no configured comment syntax") + comments = leading_comments(text, style) + approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])] + for notice in approved: + holder = re.escape(notice["holder"]) + copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$" + if re.search(copyright_pattern, comments) is None: + continue + spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments) + if spdx and any(value != notice["license"] for value in spdx): + continue + apache = all(phrase in comments for phrase in ( + "Licensed under the Apache License, Version 2.0", + "you may not use this file except in compliance with the License.", + "https://www.apache.org/licenses/LICENSE-2.0", + "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND", + "limitations under the License.", + )) + if suffix == ".java" and not apache: + continue + if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache): + return + raise ValueError("Missing or mismatched leading copyright/license notice") + + +def check_repository(root: Path, policy: dict[str, Any]) -> int: + """Check tracked sources without scanning build outputs or executing source.""" + tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") + failures = [] + count = 0 + for name in tracked: + if not name or not selected(name, policy): + continue + count += 1 + try: + check_notice(name, (root / name).read_text(encoding="utf-8-sig"), policy) + except (ValueError, OSError) as error: + failures.append(f"{name}: {error}") + if count == 0: + raise ValueError("No owned source files selected") + if failures: + raise ValueError("\n".join(failures)) + return count + + +def main() -> None: + """Load the repository-specific reviewed scope and fail CI on a bad notice.""" + root = Path(__file__).resolve().parents[2] + policy = json.loads((root / ".github/source-headers.json").read_text(encoding="utf-8")) + count = check_repository(root, policy) + print(f"Leading source notices verified: {count} tracked files") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.CalledProcessError) as error: + print(str(error), file=sys.stderr) + sys.exit(1) diff --git a/.github/scripts/test-maven-inventory.py b/.github/scripts/test-maven-inventory.py index 8b745ac..8bd5c77 100644 --- a/.github/scripts/test-maven-inventory.py +++ b/.github/scripts/test-maven-inventory.py @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + """Synthetic regressions for missing, incorrect and empty scanner inventories.""" import copy import importlib.util diff --git a/.github/scripts/test-scan-gates.py b/.github/scripts/test-scan-gates.py index d9356b2..c8494eb 100644 --- a/.github/scripts/test-scan-gates.py +++ b/.github/scripts/test-scan-gates.py @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + """Exercise the exact CI guards using synthetic evidence only.""" import contextlib import copy diff --git a/.github/scripts/test-source-headers.py b/.github/scripts/test-source-headers.py new file mode 100644 index 0000000..8a04b44 --- /dev/null +++ b/.github/scripts/test-source-headers.py @@ -0,0 +1,156 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Exercise notice parsing, provenance, scope and real Git inventory rejection.""" + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from typing import Any + + +SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py")) +assert SPEC is not None and SPEC.loader is not None +HEADERS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(HEADERS) +POLICY = json.loads(Path(__file__).parents[1].joinpath("source-headers.json").read_text(encoding="utf-8")) +MIT = "Copyright (c) 2026 James Maes\nSPDX-License-Identifier: MIT" +APACHE = """Copyright (C) 2021-2025. Kingsrook, LLC +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.""" + + +def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]: + """Use the production styles with a small explicit owned fixture scope.""" + return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], + "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]} + + +class HeaderTests(unittest.TestCase): + """Ensure policy failures cannot pass through text-only copyright searches.""" + + def test_each_real_comment_syntax(self) -> None: + """Recognize hash, slash, block and XML notices in their own languages.""" + for name, text in ( + ("src/a.py", "# " + MIT.replace("\n", "\n# ") + "\nprint(1)"), + ("src/a.cs", "// " + MIT.replace("\n", "\n// ") + "\nclass A {}"), + ("src/a.mjs", "/*\n * " + MIT.replace("\n", "\n * ") + "\n */\nexport {};"), + ("src/a.xml", "\n"), + ("src/a.ps1", "<#\n" + MIT + "\n#>\nWrite-Output 1"), + ): + with self.subTest(name=name): + HEADERS.check_notice(name, text, fixture_policy()) + + def test_required_first_lines_and_bom(self) -> None: + """Accept shebangs, PowerShell requirements and XML declarations first.""" + for name, prefix, body in ( + ("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")), + ("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")), + ("src/a.xml", "\n", ""), + ("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"), + ): + with self.subTest(name=name): + HEADERS.check_notice(name, "\ufeff" + prefix + body, fixture_policy()) + + def test_misordered_required_first_lines_fail(self) -> None: + """A notice must not displace an interpreter or XML declaration.""" + for name, text in ( + ("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"), + ("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"), + ("src/a.xml", ""), + ): + with self.subTest(name=name), self.assertRaises(ValueError): + HEADERS.check_notice(name, text, fixture_policy()) + + def test_code_strings_and_late_comments_do_not_count(self) -> None: + """Reject docstrings and comments after executable declarations.""" + for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"", + "\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")): + with self.subTest(text=text), self.assertRaises(ValueError): + HEADERS.check_notice("src/a.py", text, fixture_policy()) + + def test_wrong_syntax_and_unterminated_comments_fail(self) -> None: + """Notice text alone is insufficient without a complete language comment.""" + for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"): + with self.subTest(text=text), self.assertRaises(ValueError): + HEADERS.check_notice("src/a.cs", text, fixture_policy()) + with self.assertRaises(ValueError): + HEADERS.check_notice("src/a.py", "<#" + MIT + "#>", fixture_policy()) + + def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None: + """Do not conflate repository licenses or accept conflicting identifiers.""" + for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), + MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]): + with self.subTest(text=text), self.assertRaises(ValueError): + HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy()) + + def test_complete_legacy_apache_without_spdx(self) -> None: + """Retain valid legacy year ranges and complete Apache notices.""" + policy = fixture_policy("Kingsrook, LLC", "Apache-2.0") + HEADERS.check_notice("src/A.java", "/*" + APACHE + "*/\nclass A {}", policy) + HEADERS.check_notice("src/a.xml", "", policy) + + def test_java_requires_full_apache_block(self) -> None: + """An abbreviated SPDX-only Java notice cannot replace scoped convention.""" + policy = fixture_policy("Kingsrook, LLC", "Apache-2.0") + for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", + APACHE.replace("limitations under the License.", "")): + with self.subTest(notice=notice), self.assertRaises(ValueError): + HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy) + + def test_virtual_agent_provenance_is_path_scoped(self) -> None: + """Only the owned virtual-agent family may use its existing QRun.IO holder.""" + policy = fixture_policy() + policy["notices"].append({"holder": "QRun.IO, Inc.", "license": "MIT", "paths": ["tools/virtual-agent/*"]}) + text = "/*Copyright 2026 QRun.IO, Inc. Licensed under the MIT License (see LICENSE).\nSPDX-License-Identifier: MIT*/" + HEADERS.check_notice("tools/virtual-agent/a.mjs", text, policy) + with self.assertRaises(ValueError): + HEADERS.check_notice("src/a.mjs", text, policy) + + def test_current_scope_and_documented_exclusions(self) -> None: + """New scripts enter the guard; data, generated and upstream files do not.""" + self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY)) + self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY)) + for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", + "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", + "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"): + with self.subTest(name=name): + self.assertFalse(HEADERS.selected(name, POLICY)) + self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"])) + + def test_guard_sources_match_this_repository_provenance(self) -> None: + """Check copied guard files against local policy even before Git staging.""" + for filename in ("check-source-headers.py", "test-source-headers.py"): + path = Path(__file__).with_name(filename) + name = ".github/scripts/" + filename + self.assertTrue(HEADERS.selected(name, POLICY)) + HEADERS.check_notice(name, path.read_text(encoding="utf-8"), POLICY) + + def test_real_git_inventory_rejects_missing_tracked_header(self) -> None: + """A tracked new source fails while untracked generated output is ignored.""" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run(["git", "init", "-q", str(root)], check=True) + root.joinpath("src").mkdir() + source = root / "src/a.py" + source.write_text("print(1)", encoding="utf-8") + subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) + with self.assertRaisesRegex(ValueError, "src/a.py"): + HEADERS.check_repository(root, fixture_policy()) + source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8") + root.joinpath("src/untracked.py").write_text("print(2)", encoding="utf-8") + self.assertEqual(1, HEADERS.check_repository(root, fixture_policy())) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/source-headers.json b/.github/source-headers.json new file mode 100644 index 0000000..71b19ee --- /dev/null +++ b/.github/source-headers.json @@ -0,0 +1,111 @@ +{ + "description": "Scope of the reviewed 2026-11-01 owned-source notice pass. Python fnmatch patterns match slash-containing relative paths. Native C# IDE0073 and full Java Apache conventions remain in force.", + "includes": [ + ".github/*.yml", + ".github/*.yaml", + ".github/scripts/*.py", + "pom.xml", + "checkstyle/*.xml", + "dev/*.ps1", + "dev/*.cs", + "dev/*.csproj", + "dev/*.java", + "dev/*.sh", + "dev/*.yaml", + "dev/*.xml", + "dev/Dockerfile.*", + "dev/native-auth/Dockerfile.*", + "src/main/java/*.java", + "src/test/java/*.java", + "src/test/resources/log4j2-test.xml" + ], + "exclusions": [ + { + "pattern": "*.json", + "reason": "Schema, fixtures, manifests and lock data: repository LICENSE/NOTICE, no comments." + }, + { + "pattern": "*.lock", + "reason": "Dependency lock data, not owned executable source." + }, + { + "pattern": "*/generated/*", + "reason": "Explicit generated-code directory." + }, + { + "pattern": "*/bin/*", + "reason": "Generated build output." + }, + { + "pattern": "*/obj/*", + "reason": "Generated build output." + }, + { + "pattern": "*/node_modules/*", + "reason": "Third-party dependency output." + }, + { + "pattern": ".mvn/*", + "reason": "Upstream Maven wrapper provenance." + }, + { + "pattern": "mvnw*", + "reason": "Upstream Maven wrapper provenance." + }, + { + "pattern": "Assets/Legal/*", + "reason": "Byte-exact third-party legal files." + }, + { + "pattern": "src/main/resources/*", + "reason": "Bundled font/legal assets and OpenAPI schema data." + }, + { + "pattern": "src/test/resources/openapi/*", + "reason": "Historical contract schema fixtures." + }, + { + "pattern": "*.slnx", + "reason": "Solution metadata outside the completed source-header pass; root LICENSE/NOTICE applies." + }, + { + "pattern": "*.manifest", + "reason": "Application activation metadata outside this pass; root LICENSE/NOTICE applies." + }, + { + "pattern": "*.runsettings", + "reason": "Test runner metadata outside this pass; root LICENSE/NOTICE applies." + }, + { + "pattern": ".editorconfig", + "reason": "Style configuration outside this pass; root LICENSE/NOTICE applies." + } + ], + "styles": { + ".py": "hash", + ".ps1": "powershell", + ".psd1": "powershell", + ".sh": "hash", + ".yml": "hash", + ".yaml": "hash", + ".cs": "slash", + ".java": "slash", + ".mjs": "slash", + ".js": "slash", + ".xml": "xml", + ".props": "xml", + ".csproj": "xml", + ".wxs": "xml", + ".wixproj": "xml", + ".xaml": "xml" + }, + "notices": [ + { + "paths": [ + "*" + ], + "license": "Apache-2.0", + "holder": "Kingsrook, LLC" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7acd140..7a41bce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + name: Build and prerelease on: @@ -33,6 +36,10 @@ jobs: with: distribution: temurin java-version: '21' + - name: Verify owned source notices and rejection paths + run: | + python3 .github/scripts/test-source-headers.py + python3 .github/scripts/check-source-headers.py - name: Checkstyle, unit tests and HTTP contract tests run: bash ./mvnw --batch-mode --no-transfer-progress -Pui-demo verify - name: Prepare JAR, POM and checksums diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e22af6e..06ebaca 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + # Local copy of KofTwentyTwo/standards at fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2). # Pins, inputs and analysis checks preserved; report validation now fails closed. # Static analysis (K22-CI-22): CodeQL with the security-extended suite, failing on diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bc38661..c75eaa0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + name: codeql on: pull_request: diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index a18c4cb..cde4a9e 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + name: development on: diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml index f652a19..e32bcf5 100644 --- a/.github/workflows/pr-gates.yml +++ b/.github/workflows/pr-gates.yml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + # Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and # dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA # (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current. diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 87858bc..8da4dcc 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + # Local copy of KofTwentyTwo/standards security.yml at # fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2). # Scanner pins and security gates are preserved. SCA scans Maven-selected runtime diff --git a/dev/Dockerfile.client b/dev/Dockerfile.client index be450c7..b2ac6bd 100644 --- a/dev/Dockerfile.client +++ b/dev/Dockerfile.client @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b AS build # Exact reviewed published Core source; no workstation DLL or mutable branch. ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fca71fb6 https://github.com/QRun-IO/qqq-print-agent/archive/346fc285198908c47a2ee9db7b9adb53d5e88c3c.tar.gz /tmp/client.tar.gz diff --git a/dev/Dockerfile.server b/dev/Dockerfile.server index 67c329e..6a1ced3 100644 --- a/dev/Dockerfile.server +++ b/dev/Dockerfile.server @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + FROM maven:3.9.11-eclipse-temurin-21@sha256:6fdc855a6ed81d288ca7ca37ac6ff5e9308b612485c0801d70b25a858c83d237 AS build WORKDIR /source COPY pom.xml mvnw ./ diff --git a/dev/Invoke-Authentik.ps1 b/dev/Invoke-Authentik.ps1 index a399915..b911fd6 100644 --- a/dev/Invoke-Authentik.ps1 +++ b/dev/Invoke-Authentik.ps1 @@ -1,4 +1,7 @@ #Requires -Version 7.4 +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + <# .SYNOPSIS Prepares or checks the optional, owned Authentik development fixture. diff --git a/dev/Invoke-Dev.ps1 b/dev/Invoke-Dev.ps1 index ac51a7c..83dd9be 100644 --- a/dev/Invoke-Dev.ps1 +++ b/dev/Invoke-Dev.ps1 @@ -1,4 +1,7 @@ #Requires -Version 7.4 +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + <# .SYNOPSIS Starts, tests, or removes one owned local Docker printing environment. diff --git a/dev/Invoke-NativeAuth.ps1 b/dev/Invoke-NativeAuth.ps1 index e37b8cd..2798255 100644 --- a/dev/Invoke-NativeAuth.ps1 +++ b/dev/Invoke-NativeAuth.ps1 @@ -1,4 +1,7 @@ #Requires -Version 7.4 +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + <# .SYNOPSIS Prepares or verifies the opt-in, unpublished native-session Docker qualification. diff --git a/dev/Test-DevelopmentHost.ps1 b/dev/Test-DevelopmentHost.ps1 index 26c01f4..7a4c444 100644 --- a/dev/Test-DevelopmentHost.ps1 +++ b/dev/Test-DevelopmentHost.ps1 @@ -1,4 +1,7 @@ #Requires -Version 7.4 +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + <# .SYNOPSIS Checks the owned local QQQ development host without enrolling or printing. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$StateDirectory) diff --git a/dev/client/DiscoveryChecks.cs b/dev/client/DiscoveryChecks.cs index f7c789b..8fa80ed 100644 --- a/dev/client/DiscoveryChecks.cs +++ b/dev/client/DiscoveryChecks.cs @@ -1,3 +1,5 @@ +// Copyright 2026 Kingsrook, LLC + // SPDX-License-Identifier: Apache-2.0 using System.Net; using System.Text.Json; diff --git a/dev/client/Program.cs b/dev/client/Program.cs index 20ed3bb..ef446f4 100644 --- a/dev/client/Program.cs +++ b/dev/client/Program.cs @@ -1,3 +1,5 @@ +// Copyright 2026 Kingsrook, LLC + // SPDX-License-Identifier: Apache-2.0 using System.Collections.Concurrent; using System.Diagnostics; diff --git a/dev/client/Workload.csproj b/dev/client/Workload.csproj index 33b6f0e..9a2c168 100644 --- a/dev/client/Workload.csproj +++ b/dev/client/Workload.csproj @@ -1,3 +1,8 @@ + + Exenet10.0 diff --git a/dev/client/WorkloadDiscovery.cs b/dev/client/WorkloadDiscovery.cs index 7aecc00..b70169d 100644 --- a/dev/client/WorkloadDiscovery.cs +++ b/dev/client/WorkloadDiscovery.cs @@ -1,3 +1,5 @@ +// Copyright 2026 Kingsrook, LLC + // SPDX-License-Identifier: Apache-2.0 using Qqq.PrintAgent.Core.Authentication; diff --git a/dev/compose.authentik.yaml b/dev/compose.authentik.yaml index fc77ca1..6076db5 100644 --- a/dev/compose.authentik.yaml +++ b/dev/compose.authentik.yaml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + # Optional real provider reference. No Docker socket or published database port. services: authentik-postgres: diff --git a/dev/compose.native-auth.yaml b/dev/compose.native-auth.yaml index 5a8818c..fff0207 100644 --- a/dev/compose.native-auth.yaml +++ b/dev/compose.native-auth.yaml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + # Opt-in unpublished core qualification. No service is a replacement for the # released RC.20 TABLE development host or an installed printing deployment. services: diff --git a/dev/compose.yaml b/dev/compose.yaml index ac3f684..0ac49bf 100644 --- a/dev/compose.yaml +++ b/dev/compose.yaml @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + # Every published port is bound to host loopback; no live printing configuration is imported. x-owned: &owned labels: diff --git a/dev/java/DevHost.java b/dev/java/DevHost.java index 0ea5855..4d7677d 100644 --- a/dev/java/DevHost.java +++ b/dev/java/DevHost.java @@ -1,3 +1,23 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. 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. + */ + // SPDX-License-Identifier: Apache-2.0 package com.kingsrook.qbits.printing; diff --git a/dev/java/S3PrintingQualification.java b/dev/java/S3PrintingQualification.java index 5c434b6..8ebd807 100644 --- a/dev/java/S3PrintingQualification.java +++ b/dev/java/S3PrintingQualification.java @@ -1,3 +1,23 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. 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. + */ + // SPDX-License-Identifier: Apache-2.0 import com.kingsrook.qbits.printing.*; import com.kingsrook.qqq.backend.core.context.QContext; diff --git a/dev/java/WorkloadFixture.java b/dev/java/WorkloadFixture.java index 6ca6916..63bf17f 100644 --- a/dev/java/WorkloadFixture.java +++ b/dev/java/WorkloadFixture.java @@ -1,3 +1,23 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. 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. + */ + // SPDX-License-Identifier: Apache-2.0 package com.kingsrook.qbits.printing; import java.nio.file.*; diff --git a/dev/log4j2.xml b/dev/log4j2.xml index 2d65f4a..88817ee 100644 --- a/dev/log4j2.xml +++ b/dev/log4j2.xml @@ -1,4 +1,9 @@ + + diff --git a/dev/native-auth/Dockerfile.client b/dev/native-auth/Dockerfile.client index bdc8f36..ab03c27 100644 --- a/dev/native-auth/Dockerfile.client +++ b/dev/native-auth/Dockerfile.client @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b AS build # Same exact published production Core source as the normal development workload. ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fca71fb6 https://github.com/QRun-IO/qqq-print-agent/archive/346fc285198908c47a2ee9db7b9adb53d5e88c3c.tar.gz /tmp/client.tar.gz diff --git a/dev/native-auth/Dockerfile.server b/dev/native-auth/Dockerfile.server index 6f60af8..f68c627 100644 --- a/dev/native-auth/Dockerfile.server +++ b/dev/native-auth/Dockerfile.server @@ -1,3 +1,6 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + FROM maven:3.9.11-eclipse-temurin-21@sha256:6fdc855a6ed81d288ca7ca37ac6ff5e9308b612485c0801d70b25a858c83d237 AS build # DEVELOPMENT ONLY: exact public candidate, never installed into a host Maven cache. ADD --checksum=sha256:d99bafdf5520d0dc039b598787c003f23505cec3a652eb8ded496a24c6ed6060 https://github.com/QRun-IO/qqq/archive/ecf0f550a0357cec6387c2b54efca128a405a629.tar.gz /tmp/core.tar.gz diff --git a/dev/native-auth/client/CallbackReceiver.cs b/dev/native-auth/client/CallbackReceiver.cs index 894ebf7..0aa7138 100644 --- a/dev/native-auth/client/CallbackReceiver.cs +++ b/dev/native-auth/client/CallbackReceiver.cs @@ -1,3 +1,6 @@ +// Copyright 2026 Kingsrook, LLC +// SPDX-License-Identifier: Apache-2.0 + using System.Net; using System.Threading.Channels; diff --git a/dev/native-auth/client/FixtureHttp.cs b/dev/native-auth/client/FixtureHttp.cs index 51b978e..38c7ee9 100644 --- a/dev/native-auth/client/FixtureHttp.cs +++ b/dev/native-auth/client/FixtureHttp.cs @@ -1,3 +1,6 @@ +// Copyright 2026 Kingsrook, LLC +// SPDX-License-Identifier: Apache-2.0 + using System.Net; using System.Net.Http.Headers; using System.Text; diff --git a/dev/native-auth/client/NativeAuth.csproj b/dev/native-auth/client/NativeAuth.csproj index a02154b..4e8ac0d 100644 --- a/dev/native-auth/client/NativeAuth.csproj +++ b/dev/native-auth/client/NativeAuth.csproj @@ -1,3 +1,8 @@ + + Exe diff --git a/dev/native-auth/client/Program.cs b/dev/native-auth/client/Program.cs index 4e89f50..a61fe33 100644 --- a/dev/native-auth/client/Program.cs +++ b/dev/native-auth/client/Program.cs @@ -1,3 +1,6 @@ +// Copyright 2026 Kingsrook, LLC +// SPDX-License-Identifier: Apache-2.0 + using System.Net; using System.Security.Authentication; using System.Text.Json; diff --git a/dev/native-auth/client/ProviderFlow.cs b/dev/native-auth/client/ProviderFlow.cs index 927d3ff..0ac26d5 100644 --- a/dev/native-auth/client/ProviderFlow.cs +++ b/dev/native-auth/client/ProviderFlow.cs @@ -1,3 +1,6 @@ +// Copyright 2026 Kingsrook, LLC +// SPDX-License-Identifier: Apache-2.0 + using System.Net; using System.Text.Json.Nodes; diff --git a/dev/native-auth/client/ProviderSetup.cs b/dev/native-auth/client/ProviderSetup.cs index 2658adc..226f617 100644 --- a/dev/native-auth/client/ProviderSetup.cs +++ b/dev/native-auth/client/ProviderSetup.cs @@ -1,3 +1,6 @@ +// Copyright 2026 Kingsrook, LLC +// SPDX-License-Identifier: Apache-2.0 + using System.Net; using System.Text.Json.Nodes; diff --git a/dev/native-auth/copy-root.sh b/dev/native-auth/copy-root.sh index e35ad84..fd15407 100644 --- a/dev/native-auth/copy-root.sh +++ b/dev/native-auth/copy-root.sh @@ -1,4 +1,7 @@ #!/bin/sh +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + set -eu attempt=0 while [ ! -s /data/caddy/pki/authorities/local/root.crt ]; do diff --git a/dev/native-auth/java/NativeAuthHost.java b/dev/native-auth/java/NativeAuthHost.java index ae542f5..9b4f8ff 100644 --- a/dev/native-auth/java/NativeAuthHost.java +++ b/dev/native-auth/java/NativeAuthHost.java @@ -1,3 +1,23 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2026. 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. + */ + // SPDX-License-Identifier: Apache-2.0 package com.kingsrook.qbits.printing; diff --git a/dev/native-auth/start-client.sh b/dev/native-auth/start-client.sh index 782ec7f..51b0742 100644 --- a/dev/native-auth/start-client.sh +++ b/dev/native-auth/start-client.sh @@ -1,4 +1,7 @@ #!/bin/sh +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + set -eu # This first request must fail with the ordinary TLS validator, before trust. dotnet /app/NativeAuth.dll assert-untrusted diff --git a/dev/native-auth/start-server.sh b/dev/native-auth/start-server.sh index 8b136b4..42ccdee 100644 --- a/dev/native-auth/start-server.sh +++ b/dev/native-auth/start-server.sh @@ -1,4 +1,7 @@ #!/bin/sh +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + set -eu # Only this disposable container's JRE trust store is changed. The mounted file # is the public root certificate; the CA private key is never mounted here. diff --git a/dev/run-tests.sh b/dev/run-tests.sh index 1e0b7b2..500f884 100644 --- a/dev/run-tests.sh +++ b/dev/run-tests.sh @@ -1,4 +1,7 @@ #!/bin/sh +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + set -eu case "$TEST_RUN" in run-[a-f0-9]*) ;; *) echo 'Invalid owned test run' >&2; exit 2;; esac mkdir -p "/work/$TEST_RUN/s3" diff --git a/dev/tests/Authentik.Tests.ps1 b/dev/tests/Authentik.Tests.ps1 index ec4923e..6c5bc42 100644 --- a/dev/tests/Authentik.Tests.ps1 +++ b/dev/tests/Authentik.Tests.ps1 @@ -1,4 +1,7 @@ #Requires -Version 7.4 +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + # No Docker or HTTP calls: these regressions exercise private bootstrap state. BeforeAll { $script:authScript = Join-Path $PSScriptRoot '../Invoke-Authentik.ps1' diff --git a/dev/tests/Lifecycle.Tests.ps1 b/dev/tests/Lifecycle.Tests.ps1 index 287b5cc..274ad94 100644 --- a/dev/tests/Lifecycle.Tests.ps1 +++ b/dev/tests/Lifecycle.Tests.ps1 @@ -1,4 +1,7 @@ #Requires -Version 7.4 +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + # Ownership regressions use mocked Docker metadata; no containers or network calls. BeforeAll { $script:entrypoint = Join-Path $PSScriptRoot '../Invoke-Dev.ps1' diff --git a/dev/tests/NativeAuth.Tests.ps1 b/dev/tests/NativeAuth.Tests.ps1 index f66e9fe..1435ea6 100644 --- a/dev/tests/NativeAuth.Tests.ps1 +++ b/dev/tests/NativeAuth.Tests.ps1 @@ -1,4 +1,7 @@ #Requires -Version 7.4 +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + # No Docker, TLS, provider or QQQ calls; only private input and result validation. BeforeAll { $script:nativeScript = Join-Path $PSScriptRoot '../Invoke-NativeAuth.ps1' diff --git a/dev/tests/ProviderDiagnostics.Tests.ps1 b/dev/tests/ProviderDiagnostics.Tests.ps1 index 09e821a..5928b92 100644 --- a/dev/tests/ProviderDiagnostics.Tests.ps1 +++ b/dev/tests/ProviderDiagnostics.Tests.ps1 @@ -1,4 +1,7 @@ #Requires -Version 7.4 +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + BeforeAll { $path = Join-Path $PSScriptRoot '../Invoke-Dev.ps1' $tokens = $null diff --git a/dev/tests/WorkloadFailure.Tests.ps1 b/dev/tests/WorkloadFailure.Tests.ps1 index e32908d..bbb8256 100644 --- a/dev/tests/WorkloadFailure.Tests.ps1 +++ b/dev/tests/WorkloadFailure.Tests.ps1 @@ -1,4 +1,7 @@ #Requires -Version 7.4 +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + BeforeAll { $script:entrypoint = Join-Path $PSScriptRoot '../Invoke-Dev.ps1' function docker { throw 'Unmocked Docker call refused.' } diff --git a/docs/SOURCE-HEADERS.md b/docs/SOURCE-HEADERS.md new file mode 100644 index 0000000..bde6a98 --- /dev/null +++ b/docs/SOURCE-HEADERS.md @@ -0,0 +1,17 @@ +# Owned source notice check + +Run these commands from the repository root with the existing Python 3 interpreter: + +```text +python3 .github/scripts/test-source-headers.py +python3 .github/scripts/check-source-headers.py +``` + +The standard-library checker enforces the scope of the reviewed 2026-11-01 header pass, not an estate-wide licensing or formatting audit. `.github/source-headers.json` names the owned source/config path families, comment syntax, accepted holder/license pairs and explicit exclusion reasons. Python `fnmatch` patterns match slash-containing relative Git paths; new files in those families are covered after they are tracked. Empty selections fail. The checker does not scan untracked build output or modify files. + +Only leading language comments count. Copyright text in a string, Python docstring or comment after code is rejected. UTF-8 BOMs, initial shebangs, PowerShell `#Requires` directives and XML declarations are handled before the notice. These declarations cannot be displaced beneath a new notice. PowerShell block comments are recognized only for PowerShell files. Existing years and holders are not rewritten. Complete canonical Apache notices are valid without redundant SPDX; Java still requires the full Apache block. A contradictory SPDX identifier fails. + +JSON/schema/fixture/lock data, explicit generated output directories, vendor legal assets and upstream Maven wrappers are excluded with reasons in the policy. Solution/activation/test-runner metadata and `.editorconfig` remain outside this completed pass; their applicable root LICENSE/NOTICE remains authoritative. No broad claim about third-party licensing follows from this check. New owned file types or provenance must be reviewed before changing the allowlist; do not add blanket exclusions to make a failure disappear. + +The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. CI invokes them in existing jobs using the runner's available Python, with no new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged. +This repository accepts Kingsrook, LLC / Apache-2.0 for its reviewed source/build/config families. The existing CI verify job runs the guard. diff --git a/pom.xml b/pom.xml index 08789ea..22b7b4d 100644 --- a/pom.xml +++ b/pom.xml @@ -1,4 +1,9 @@ + + 4.0.0 com.kingsrookqbit-build-parent2.0.0 diff --git a/src/test/resources/log4j2-test.xml b/src/test/resources/log4j2-test.xml index 9ed980c..c9077d0 100644 --- a/src/test/resources/log4j2-test.xml +++ b/src/test/resources/log4j2-test.xml @@ -1 +1,6 @@ + + -- 2.54.0 From 5f74c0c69a552bd6c1f439fda486171935740409 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:43:31 +0000 Subject: [PATCH 35/35] chore(deps-dev): Bump org.apache.maven.plugins:maven-checkstyle-plugin Bumps [org.apache.maven.plugins:maven-checkstyle-plugin](https://github.com/apache/maven-checkstyle-plugin) from 3.3.1 to 3.6.0. - [Release notes](https://github.com/apache/maven-checkstyle-plugin/releases) - [Commits](https://github.com/apache/maven-checkstyle-plugin/compare/maven-checkstyle-plugin-3.3.1...maven-checkstyle-plugin-3.6.0) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-checkstyle-plugin dependency-version: 3.6.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 22b7b4d..010deee 100644 --- a/pom.xml +++ b/pom.xml @@ -47,7 +47,7 @@ - org.apache.maven.pluginsmaven-checkstyle-plugin3.3.1 + org.apache.maven.pluginsmaven-checkstyle-plugin3.6.0 com.puppycrawl.toolscheckstyle10.12.6 checkstyle/config.xmltruetruewarningtrue validatecheck -- 2.54.0