From 08ee0efa3d9edc3853ce96bc8b0a0481e0046da3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 13:04:03 -0600 Subject: [PATCH 1/7] feat: authorize operator printing with exact retained acceptance Add current-user printer media catalog, stable request recovery and atomic retained-source acceptance. Recheck native authority through admission and commit, bind history to the accepted printer record, and gate selected media on the current client capability. Co-Authored-By: Codex GPT-6 Signed-off-by: James Maes --- docs/OPERATOR-CATALOG.md | 67 ++ docs/RETAINED-PRINT-FACADE.md | 114 +++ docs/SESSION-STATE.md | 35 + docs/TODO.md | 16 + .../qbits/printing/PrintingApiSchemas.java | 14 +- .../printing/PrintingDeviceControls.java | 4 +- .../qbits/printing/PrintingMediaProfile.java | 162 ++++ .../PrintingOperatorAuthorization.java | 395 ++++++++++ .../printing/PrintingOperatorPermission.java | 56 ++ .../printing/PrintingOperatorProcesses.java | 95 +++ .../qbits/printing/PrintingQBitConfig.java | 16 + .../qbits/printing/PrintingQBitProducer.java | 2 + .../printing/PrintingReadPermission.java | 10 +- .../printing/PrintingRealtimeRoutes.java | 6 +- .../qbits/printing/PrintingService.java | 329 +++++++- .../qbits/printing/PrintingUiApi.java | 44 +- .../qbits/printing/PrintingUiApiSpec.java | 39 +- .../qbits/printing/RetainedPrintFacade.java | 358 +++++++++ .../RetainedPrintSourceAuthority.java | 54 ++ .../resources/openapi/qqq-v1/printing.yaml | 376 ++++++++- .../PrintingOperatorAuthorityTest.java | 378 ++++++++++ .../qbits/printing/PrintingOperatorTest.java | 405 ++++++++++ .../qbits/printing/PrintingRealtimeTest.java | 49 ++ .../qbits/printing/PrintingSocketFixture.java | 9 +- .../printing/PrintingUiApiContractTest.java | 4 +- .../printing/RetainedPrintFacadeTest.java | 713 ++++++++++++++++++ 26 files changed, 3718 insertions(+), 32 deletions(-) create mode 100644 docs/OPERATOR-CATALOG.md create mode 100644 docs/RETAINED-PRINT-FACADE.md create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingMediaProfile.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorization.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingOperatorProcesses.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/RetainedPrintFacade.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/RetainedPrintSourceAuthority.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorityTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java diff --git a/docs/OPERATOR-CATALOG.md b/docs/OPERATOR-CATALOG.md new file mode 100644 index 0000000..2f6c2b0 --- /dev/null +++ b/docs/OPERATOR-CATALOG.md @@ -0,0 +1,67 @@ +# Operator catalog and queue media + +This source adds native printing extension 2.2.0. Publication and Windows media +agent qualification are pending. Existing administrator and device routes keep +their permissions; this is an additive operator API, not a device bearer API. + +Hosts grant `printing_operator` and the `printing_tenant` security key. The native +`printingSubmit` process remains the domain write permission. Current device and +printer table/record security, credential scopes and client-channel ceilings apply +on every operation. Generic job/command history, table writes, enrollment and +device management remain administrator-only. Hosts may add destination locks. +Native callbacks cannot replace the initiating actor, session, instance, service +or row-security context. Submission rechecks current process, credential, channel +and destination authority after loading/storage callbacks and before commit. +The backend process is hidden and has no base64-entry form. Echo obtains its user +selections from the catalog; backend metadata also references native printer and +format possible-value sources. + +- `GET /qqq/v1/qbit/printing/operator/printers?limit=50&after=...` returns at most + 200 authorized approved/exposed printers, in stable printer-row-ID order. The + opaque next cursor advances over at most `limit` candidate rows even if parent + device security hides every candidate. Continue until `nextCursor` is null. +- `POST /qqq/v1/qbit/printing/operator/jobs` accepts `requestId`, `printerRowId`, + `title`, `format` (`PDF` or `ZPL`), `contentBase64` and optional `options`. The + native printer row determines the device and opaque Windows queue identity. + `requestId` is 1–128 characters from letters, digits, `.`, `_`, `:`, `-`. + The authenticated actor and tenant scope the stable key; callers cannot supply + actor identity or an internal idempotency key. The response is the existing job + receipt after durable commit. Changed requests conflict instead of reprinting. +- `GET /qqq/v1/qbit/printing/operator/requests/{requestId}` returns only that + actor's exact accepted request, or 404. It does not enqueue, read document bytes, + or initialize settings. Current destination read authority remains required; + later loss of exposure alone does not erase the acceptance receipt. + Lookup checks the original accepted native printer-row ID. Deleting that row + and creating another with the same device/queue pair does not transfer history. + +Each catalog entry has `printerRowId`, `deviceId`, `printerId`, effective `name`, +`deviceName`, `status`, `formats`, `mediaProfiles`, and optional `description` and +`location`. These names are presentation; the stable row identity selects a queue. + +Media profiles report actual Windows queue defaults. Their typed facts are `id`, +`revision` (lowercase SHA-256), `name`, `widthMicrons`, `heightMicrons`, `dpiX`, +`dpiY`, `source: WINDOWS_QUEUE_DEFAULT`, `applyMode: DRIVER_DEFAULT`. Unknown or +unsupported driver facts produce an empty list. The initial client reports one +configured default; the wire bound is 32. Dimensions are 1–2,000,000 microns and +DPI is 1–9600. Names are at most 512 characters. Unknown additive reported facts +are ignored when canonicalizing; known required facts remain validated. Arbitrary +page-size values are never interpreted as a Windows stock change. +Profile and selection IDs share the protocol's 128-character URL-safe ID bound. + +Select exact reported media with `options.mediaProfile: {id, revision}`. The +selection is immutable job intent and accepts no extra fields. New submissions +reject stale/missing profiles before admission/storage writes; exact accepted +replays remain accepted. The capable Windows agent rechecks its actual current +default before spooling. Selected-media dispatch requires the current agent +capability `printer-media-v1`, including after reconnect/downgrade. Legacy +inventory without profiles clears stale media facts. Omitted selection preserves +ordinary existing queue behavior. Paper-width/height overrides remain unsupported, +including when a media selection is supplied. + +Generated/retained source authorization belongs to the configured source owner. +The retained facade shares this operator permission and destination boundary; it +preserves its atomic accepted source/actor/request binding. Barcode fit and +encoding remain the renderer's job. + +`QUEUED` is durable acceptance; `SPOOLED` means spooler acceptance. Neither is +physical output confirmation. These tests use synthetic bytes and no real printer. diff --git a/docs/RETAINED-PRINT-FACADE.md b/docs/RETAINED-PRINT-FACADE.md new file mode 100644 index 0000000..68b36af --- /dev/null +++ b/docs/RETAINED-PRINT-FACADE.md @@ -0,0 +1,114 @@ +# Retained print acceptance (additive candidate) + +This optional Java integration preserves the existing native generation print +process and the existing printing queue. It adds no device wire fields or REST +routes. It is disabled until the host supplies +`PrintingQBitConfig.withRetainedSourceAuthority`. Current `printing_operator` +(or administrator) authority and native tenant/device/printer record checks +remain mandatory. Submission also requires the shared `printingSubmit` process +permission. This grants no device management or generic job-history access. + +## Public contract + +`RetainedPrintFacade.Request(requestId, sourceLocator, printerRecordId, title, +optionsJson)` contains no actor or asserted source binding. Request IDs use +`[A-Za-z0-9._:-]{1,128}`, including owner namespaces such as `echo:`. +`submit` derives the signed-in native actor and returns an immutable +`Acceptance`; it includes the exact source, job/event identities, tenant, +initiating actor, destination, title and normalized options JSON, including any +`mediaProfile` ID/revision. `find` authorizes the same complete request and +returns that acceptance, null for observed absence, or a conflict for an +unresolved upload reservation. Reuse the same request identity after a lost +response. Never convert an unavailable lookup into absence or create another +intent automatically. + +The host-configured `RetainedPrintSourceAuthority.resolve(locator, requestId)` +must authorize the current native actor, source association and currently +eligible immutable version. It returns `SourceReference(owner, opaqueReference, +sha256, sizeBytes, mediaType)`. `opaqueReference` is at most 4000 Java +characters, stable and canonical under that owner's rules; printing does not +parse owner-specific fields. The optional Record Files adapter must encode its +complete version/source/generation-request/initiating-actor/tenant tuple and +compare generation's committed receipt. Accepted MIME types are +`application/pdf` and `application/vnd.zebra-zpl`; the current configured +PDF/ZPL limits apply before the byte read, within the existing integer-length +storage ceiling. `read(reference, requestId)` must enforce the same current +permissions and return only those exact bytes. Hash/length verification remains +in printing's existing submission path. Do not supply arbitrary source +authorities per request or reconstruct a user session. + +Source admission remains the owner's policy. An explicitly configured published +template may qualify generated output without a per-label release dialog; +external-upload quarantine is unchanged. Printing performs no automatic release +and creates no human-release audit. `resolve` must be read-only and must not +invoke a generation reconciliation operation that can generate or dispatch work. + +Source/destination checks surround owner callbacks and precede acceptance. +Submission refuses an ambient QQQ transaction: queue acceptance is independently +committed and cannot be rolled back by a caller's transaction. Submission also +enforces the native job-table credential and channel mutation ceilings; lookup +uses the corresponding read ceilings without granting generic table access. +`find` calls no byte reader, submission or event writer, and its option +normalization cannot initialize settings. It reauthorizes the source and +destination records, then compares the exact accepted intent; a later exposure, +media inventory or dispatch-limit change does not reinterpret that historical +intent. A later permission loss may deny returning a receipt whose job already +committed; it never authorizes a replacement job. + +## Persistence and replay + +Existing `printing_job` gains nullable `retained_actor_id`, +`retained_binding_json`, and `accepted_event_id`. Existing `printing_event` +gains nullable `event_origin`. `initialize` applies additive upgrades; old +jobs/events remain unbound/unclassified. None of these internal fields are added +to the device `JOB_COLUMNS` DTO or native table metadata. + +Submission and exact lookup both use the existing tenant admission lock, so an +in-flight inline acceptance cannot be mistaken for absence. The unique +`(tenant_id, idempotency_key)` serialize submissions. Retained keys use +`retained:` plus the request ID. Ordinary/bound collisions refuse. Complete +normalized intent includes actor, owner reference, content hash/length/media, +destination, title, options and tenant. The existing storage reservation/orphan +path is reused; it may commit an upload reservation before writing object +storage. The final job, retained binding, and server acceptance event commit +together. Failed acceptance-event insertion rolls back the job. Rollback or +orphan-cleanup failures are suppressed onto the original failure rather than +replacing it. Existing initialization widens legacy 300-character titles to +1024; the bound title preserves exact whitespace and Unicode and is compared on +replay. Acceptance-event JSON has `type=ACCEPTED`, `origin=SERVER_ACCEPTANCE`, +`status=QUEUED`; it does not claim device receipt or physical output. + +This is atomic acceptance binding inside printing, not a distributed transaction +over the retained owner's authorization database. Deployment still owns durable +database/storage settings, bounded provider calls and current session policy. + +## Committed observations + +`observation(requestId, eventId)` reads only an exact committed event joined to +the accepted job. New ordinary device events are stamped `DEVICE_OBSERVATION`; +pre-upgrade unclassified events and server acceptance events cannot be returned +as device evidence. The current native operator must have current device/printer +row authority and satisfy the job credential/channel read ceiling. Before +returning any source metadata, printing calls the configured owner's +`authorizeObservation(source, requestId)` under the current reporter session, +then rechecks the current ceiling and destination authority. Its default +implementation refuses; adapters must explicitly implement their current +source-observation policy. This callback does not grant content access or +impersonate the initiating actor. This is an operator-authorized observation +lookup, not device enrollment or permission to forge reporter identity. The +optional RF authority adapter must preserve initiating actor separately from +current reporting actor and map only supported stored states. Neither `SPOOLED` +nor `SPOOLER_COMPLETED` proves physical output. + +## Verification scope + +The tests use real H2 transactions and native QQQ row authorization with +synthetic printers. They never contact a device or emit physical output. +Coverage includes commit-then-throw acknowledgment loss, concurrent exact +replay, held-admission-lock lookup, mutation ceilings, owner observation +refusal, callback revocation, exact content hash/length, configured document +bounds, title upgrade/replay, read-only historical lookup and original-error +preservation when rollback fails. Qualification of this adopted source is +recorded in the session notes; the older peer candidate's test result is not +evidence for this revision. Cross-QBit integration remains a separate gate. No +release, install, shared-cache substitution or runtime migration has occurred. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index c3e2dd5..962c304 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,5 +1,40 @@ # Session state — 2026-10-31 +## Echo operator/media and retained-source candidate — 2026-11-01 + +The isolated Echo candidate adds native printing extension **2.2.0** on the +unchanged product `0.4.0-SNAPSHOT` and QQQ `4.1.0-RC.20` dependency tuple. +`printing_operator` permits the authorized printer/media catalog and exact-actor +submission/reconciliation without device administration or general job history. +Reported Windows queue-default media is revision-bound; clients without the current +`printer-media-v1` capability cannot claim jobs carrying a media selection. + +The opt-in retained-source facade shares that boundary and commits immutable +source/actor/destination/intent plus its server acceptance event inside the existing +queue transaction. Its owner adapter is required and default-deny; read-only lookup +never generates content or submits a replacement job. See +[operator catalog](OPERATOR-CATALOG.md) and +[retained acceptance](RETAINED-PRINT-FACADE.md) for exact contracts and limits. + +The combined `-Pui-demo verify` gate passed: 183 reported tests, 182 executed, +zero failures/errors, one intentional opt-in workload skip, and zero Checkstyle +violations. This supersedes the initial 164-case candidate gate. It includes 10 +operator, 18 authority-boundary, 21 retained-source and actual native +HTTP/WebSocket capability-downgrade tests against synthetic H2 fixtures; +no live database, connector, printer or shared artifact has been changed. The +experimental API 1.10 used by Echo is a separate owner-controlled tuple and has not +been substituted for this candidate's qualified RC.20 dependencies. Publication, +cross-QBit consumer qualification and any physical output remain separate gates. + +Native destination and storage callbacks cannot carry stale actor, session, +credential, channel or row authority into admission, replay or commit. Receipt +lookup authorizes the original stored printer-row ID; a replacement row with the +same device/queue pair cannot inherit earlier acceptance history. The regressions +reproduce both prior paths, verify denial and rollback, and preserve authorized +read-only observation after the original queue loses exposure. Media IDs are +bounded to the shared 128-character contract; long device display names remain +supported without truncation. + ## 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), diff --git a/docs/TODO.md b/docs/TODO.md index f3ee2f4..8276b81 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,21 @@ # Follow-up +## Echo integration candidate + +- [x] Implement authorized operator catalog, native submission/process permission, + exact actor/request lookup and reported driver-default media revisions. +- [x] Integrate the opt-in atomic retained-source facade using the same queue, + current authority, immutable acceptance and exact device-event observation. +- [x] Close native callback authority changes and preserve the exact accepted + printer-row identity during read-only request reconciliation. +- [x] Complete combined candidate verification: 183 reported tests, 182 executed, + one opt-in workload skip, zero failures/errors and Checkstyle violations. + Preserve exact source, draft protocol fixtures and frozen runtime hashes. +- [ ] Qualify Echo/Generation's exact core tuple and configured source authority; + do not silently adopt experimental core or bypass external-upload admission. +- [ ] Publish the reviewed additive contract and coordinate client/runtime adoption + before owner-operated end-to-end physical acceptance. + ## Current local development qualification - [x] Supply portable, owned Docker Start/Test/Stop commands with private synthetic diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java b/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java index e84015b..ebf594c 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingApiSchemas.java @@ -48,11 +48,19 @@ final class PrintingApiSchemas 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, "MediaSelection", fields("id", id(), "revision", text(64)), "id", "revision"); + add(schemas, "MediaProfile", fields("id", id(), "revision", text(64), "name", text(512), "widthMicrons", number(), "heightMicrons", number(), "dpiX", number(), "dpiY", number(), + "source", enumeration("WINDOWS_QUEUE_DEFAULT"), "applyMode", enumeration("DRIVER_DEFAULT")), "id", "revision", "name", "widthMicrons", "heightMicrons", "dpiX", "dpiY", "source", "applyMode"); + add(schemas, "OperatorPrinter", fields("printerRowId", id(), "deviceId", id(), "printerId", id(), "name", text(1024), "deviceName", text(1024), "description", text(4096), "location", text(512), "status", text(40), "formats", array(enumeration("PDF", "ZPL")), "mediaProfiles", array(ref("MediaProfile"))), + "printerRowId", "deviceId", "printerId", "name", "deviceName", "status", "formats", "mediaProfiles"); + add(schemas, "OperatorCatalog", fields("printers", array(ref("OperatorPrinter")), "nextCursor", text(200).withNullable(true)), "printers", "nextCursor"); + add(schemas, "OperatorSubmit", fields("requestId", text(128), "printerRowId", id(), "title", text(1024), "format", enumeration("PDF", "ZPL"), "contentBase64", text(Integer.MAX_VALUE), "options", ref("Options")), + "requestId", "printerRowId", "title", "format", "contentBase64"); 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, "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")), "mediaProfiles", array(ref("MediaProfile")), "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, "Options", fields("copies", number(), "scale", enumeration("ACTUAL_SIZE", "FIT"), "mediaProfile", ref("MediaSelection"), "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"); @@ -60,7 +68,7 @@ final class PrintingApiSchemas 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, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1")), "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"); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java index beb3cf2..de8ae26 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java @@ -266,8 +266,8 @@ final class PrintingDeviceControls } 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()) + if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 3 + || capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value) && !"printer-media-v1".equals(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length()) { throw new PrintingException(400, "Invalid control capabilities"); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingMediaProfile.java b/src/main/java/com/kingsrook/qbits/printing/PrintingMediaProfile.java new file mode 100644 index 0000000..bb99bbe --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingMediaProfile.java @@ -0,0 +1,162 @@ +/* + * 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. + */ + +package com.kingsrook.qbits.printing; + + +import java.util.HashSet; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** Immutable reported Windows queue defaults; these never change printer stock. + *******************************************************************************/ +public record PrintingMediaProfile(String id, String revision, String name, int widthMicrons, int heightMicrons, int dpiX, int dpiY) +{ + /*************************************************************************** + ** Validate the bounded wire record without coercion or guessed dimensions. + ***************************************************************************/ + public static PrintingMediaProfile parse(JSONObject value) + { + if(!value.keySet().containsAll(Set.of("id", "revision", "name", "widthMicrons", "heightMicrons", "dpiX", "dpiY", "source", "applyMode")) + || !"WINDOWS_QUEUE_DEFAULT".equals(value.opt("source")) || !"DRIVER_DEFAULT".equals(value.opt("applyMode"))) + { + throw new PrintingException(400, "Invalid reported media profile"); + } + String id = text(value, "id", 128); + String revision = text(value, "revision", 64); + if(!id.matches("[A-Za-z0-9._-]+") || !revision.matches("[0-9a-f]{64}")) + { + throw new PrintingException(400, "Invalid media profile identity"); + } + return new PrintingMediaProfile(id, revision, text(value, "name", 512), integer(value, "widthMicrons", 2000000), + integer(value, "heightMicrons", 2000000), integer(value, "dpiX", 9600), integer(value, "dpiY", 9600)); + } + + + /*************************************************************************** + ** Canonical public facts are separate from the immutable job selection. + ***************************************************************************/ + public JSONObject json() + { + return new JSONObject().put("id", id).put("revision", revision).put("name", name).put("widthMicrons", widthMicrons) + .put("heightMicrons", heightMicrons).put("dpiX", dpiX).put("dpiY", dpiY) + .put("source", "WINDOWS_QUEUE_DEFAULT").put("applyMode", "DRIVER_DEFAULT"); + } + + + /*************************************************************************** + ** Missing inventory facts mean no known stock, including older clients. + ***************************************************************************/ + static JSONArray inventory(JSONObject printer) + { + JSONArray values = printer.has("mediaProfiles") ? printer.optJSONArray("mediaProfiles") : new JSONArray(); + if(values == null || values.length() > 32) + { + throw new PrintingException(400, "Invalid media profile list"); + } + Set ids = new HashSet<>(); + JSONArray result = new JSONArray(); + for(Object item : values) + { + if(!(item instanceof JSONObject object)) + { + throw new PrintingException(400, "Invalid media profile"); + } + PrintingMediaProfile profile = parse(object); + if(!ids.add(profile.id())) + { + throw new PrintingException(400, "Duplicate media profile identity"); + } + result.put(profile.json()); + } + return result; + } + + + /*************************************************************************** + ** Validate a selection independently before comparing it with current facts. + ***************************************************************************/ + static void selection(JSONObject options) + { + if(!options.has("mediaProfile")) + { + return; + } + JSONObject selected = options.optJSONObject("mediaProfile"); + if(selected == null || !selected.keySet().equals(Set.of("id", "revision")) + || !text(selected, "id", 128).matches("[A-Za-z0-9._-]+") || !text(selected, "revision", 64).matches("[0-9a-f]{64}")) + { + throw new PrintingException(400, "Invalid media profile selection"); + } + } + + + /*************************************************************************** + ** Reject changed defaults before admission or document storage writes. + ***************************************************************************/ + static void requireCurrent(JSONObject printer, JSONObject options) + { + if(!options.has("mediaProfile")) + { + return; + } + JSONObject selected = options.getJSONObject("mediaProfile"); + JSONArray profiles = new JSONArray(printer.optString("media_profiles_json", "[]")); + for(Object item : profiles) + { + PrintingMediaProfile profile = parse((JSONObject) item); + if(profile.id().equals(selected.getString("id")) && profile.revision().equals(selected.getString("revision"))) + { + return; + } + } + throw new PrintingException(409, "Printer media changed or is unavailable; select the current queue default again"); + } + + + /*************************************************************************** + ** Text identities and labels retain their exact value. + ***************************************************************************/ + private static String text(JSONObject value, String key, int maximum) + { + if(!(value.opt(key) instanceof String text) || text.isBlank() || text.length() > maximum) + { + throw new PrintingException(400, "Invalid media profile " + key); + } + return text; + } + + + /*************************************************************************** + ** Accept integral JSON values only, within the supported physical bounds. + ***************************************************************************/ + private static int integer(JSONObject value, String key, int maximum) + { + Object raw = value.opt(key); + if(!(raw instanceof Integer || raw instanceof Long) || ((Number) raw).longValue() < 1 || ((Number) raw).longValue() > maximum) + { + throw new PrintingException(400, "Invalid media profile " + key); + } + return ((Number) raw).intValue(); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorization.java b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorization.java new file mode 100644 index 0000000..7c827e1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorization.java @@ -0,0 +1,395 @@ +/* + * 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. + */ + +package com.kingsrook.qbits.printing; + + +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +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.permissions.TablePermissionSubType; +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.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.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** Current-user catalog and submission boundary shared by HTTP and host processes. + *******************************************************************************/ +public final class PrintingOperatorAuthorization +{ + /*************************************************************************** + ** No cached user authority or alternate queue. + ***************************************************************************/ + private PrintingOperatorAuthorization() + { + } + + + /*************************************************************************** + ** Resolve the authenticated actor and current tenant before every operation. + ***************************************************************************/ + 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 PrintingOperatorPermission().checkPermissionsThrowing(new RunBackendStepInput(), null); + PrintingProcesses.currentService(); + return QContext.getQSession().getUser().getIdReference(); + } + + + /*************************************************************************** + ** Native table and record checks apply even inside another backend process. + ***************************************************************************/ + private static QRecord record(String table, String id) throws QException + { + CallContext caller = capture(); + QueryInput scoped = new QueryInput().withTableName(table).withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id)) + .withCriteria(new QFilterCriteria("tenant_id", QCriteriaOperator.EQUALS, PrintingProcesses.currentService().getTenantId())).withLimit(1)); + scoped.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + if(new QueryAction().execute(scoped).getRecords().isEmpty()) + { + throw new QPermissionDeniedException("Print destination is not accessible"); + } + caller.check(); + GetInput input = new GetInput(); + input.setTableName(table); + input.setPrimaryKey(id); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + QRecord row = new GetAction().execute(input).getRecord(); + PermissionsHelper.checkTablePermissionThrowing(input, TablePermissionSubType.READ); + caller.check(); + if(row == null || !id.equals(row.getValueString("id"))) + { + throw new QPermissionDeniedException("Print destination is not accessible"); + } + return row; + } + + + /*************************************************************************** + ** Authorize the exact native printer row and its enabled device each time. + ** Callers use the returned wire printer identity only for this operation. + ***************************************************************************/ + public static QRecord destination(String printerRowId) throws QException + { + CallContext caller = capture(); + QRecord printer = record(PrintingTables.PRINTER, printerRowId); + QRecord device = record(PrintingTables.DEVICE, printer.getValueString("device_id")); + if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available")) + || !Integer.valueOf(1).equals(device.getValueInteger("enabled"))) + { + throw new QPermissionDeniedException("Print destination is not approved and exposed"); + } + requireDestinationRead(); + caller.check(); + return printer; + } + + + /*************************************************************************** + ** Shared retained-source boundary also requires the exact supported format. + ***************************************************************************/ + public static QRecord printer(String printerRowId, String format) throws QException + { + QRecord printer = destination(printerRowId); + if(!Set.of("PDF", "ZPL").contains(format) || !new JSONArray(printer.getValueString("formats")).toList().contains(format)) + { + throw new PrintingException(400, "Printer does not support this document format"); + } + return printer; + } + + + /*************************************************************************** + ** Reconciliation still checks current row authority after exposure changes. + ** The caller must first establish its own accepted request/source binding. + ***************************************************************************/ + public static QRecord authorizeDestinationHistory(String printerRowId) throws QException + { + CallContext caller = capture(); + QRecord printer = record(PrintingTables.PRINTER, printerRowId); + record(PrintingTables.DEVICE, printer.getValueString("device_id")); + requireDestinationRead(); + caller.check(); + return printer; + } + + + /*************************************************************************** + ** Page through authorized rows; hidden parent devices never leak through joins. + ***************************************************************************/ + public static JSONObject catalog(int limit, String after) throws QException + { + CallContext caller = capture(); + if(limit < 1 || limit > 200 || (after != null && !after.matches("[A-Za-z0-9._-]{1,200}"))) + { + throw new PrintingException(400, "Invalid catalog page"); + } + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1)) + .withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1)) + .withCriteria(new QFilterCriteria("tenant_id", QCriteriaOperator.EQUALS, PrintingProcesses.currentService().getTenantId())) + .withOrderBy(new QFilterOrderBy("id", true)).withLimit(limit + 1); + if(after != null) + { + filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, after)); + } + QueryInput input = new QueryInput().withTableName(PrintingTables.PRINTER).withFilter(filter); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + List rows = new QueryAction().execute(input).getRecords(); + caller.check(); + JSONArray printers = new JSONArray(); + int consumed = Math.min(limit, rows.size()); + for(QRecord candidate : rows.subList(0, consumed)) + { + try + { + QRecord printer = destination(candidate.getValueString("id")); + QRecord device = record(PrintingTables.DEVICE, printer.getValueString("device_id")); + requireDestinationRead(); + caller.check(); + printers.put(new JSONObject().put("printerRowId", printer.getValueString("id")).put("deviceId", printer.getValueString("device_id")) + .put("printerId", printer.getValueString("printer_id")).put("name", label(printer, "display_name", "name", "queue_name", "id")) + .put("deviceName", label(device, "display_name", "name", "computer_name", "id")).put("description", printer.getValueString("description")).put("location", printer.getValueString("location")) + .put("status", printer.getValueString("status")).put("formats", new JSONArray(printer.getValueString("formats"))) + .put("mediaProfiles", new JSONArray(printer.getValueString("media_profiles_json") == null ? "[]" : printer.getValueString("media_profiles_json")))); + } + catch(QPermissionDeniedException hidden) + { + // Native parent-row security can legitimately hide a candidate. + } + caller.check(); + } + caller.check(); + return new JSONObject().put("printers", printers) + .put("nextCursor", rows.size() > limit ? rows.get(consumed - 1).getValueString("id") : JSONObject.NULL); + } + + + /*************************************************************************** + ** The process permission remains authoritative for all byte submissions. + ***************************************************************************/ + public static JSONObject submit(JSONObject request) throws Exception + { + CallContext caller = capture(); + requireSubmission(); + request = new JSONObject(request.toString()); + if(!Set.of("requestId", "printerRowId", "title", "format", "contentBase64", "options").containsAll(request.keySet())) + { + throw new PrintingException(400, "Unknown operator print request field"); + } + QRecord printer = printer(request.getString("printerRowId"), request.getString("format")); + caller.check(); + JSONObject prepared = new JSONObject(request.toString()); + prepared.put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id")) + .put("idempotencyKey", requestKey(caller.actor(), request.getString("requestId"))); + try + { + return caller.service().submitAuthorized(prepared, request.getString("printerRowId"), () -> + { + caller.check(); + requireSubmission(); + QRecord current = printer(prepared.getString("printerRowId"), prepared.getString("format")); + if(!prepared.getString("deviceId").equals(current.getValueString("device_id")) || !prepared.getString("printerId").equals(current.getValueString("printer_id"))) + { + throw new QPermissionDeniedException("Print destination changed during submission"); + } + requireSubmission(); + caller.check(); + }); + } + catch(java.sql.SQLException exception) + { + if(exception.getCause() instanceof QException denied) + { + throw denied; + } + throw exception; + } + } + + + /*************************************************************************** + ** Only the current actor's exact request can be reconciled; no job enumeration. + ***************************************************************************/ + public static JSONObject observe(String requestId) throws Exception + { + CallContext caller = capture(); + requireStorage(false); + PrintingService.OperatorAcceptance accepted = caller.service().findOperatorAcceptance(requestKey(caller.actor(), requestId)); + if(accepted == null) + { + throw new PrintingException(404, "Print request not found"); + } + JSONObject job = accepted.job(); + // Historical acceptance remains observable if exposure changes, but current row authority is mandatory. + QRecord printer = authorizeDestinationHistory(accepted.printerRowId()); + if(!job.getString("deviceId").equals(printer.getValueString("device_id")) || !job.getString("printerId").equals(printer.getValueString("printer_id"))) + { + throw new QPermissionDeniedException("Accepted print destination changed"); + } + requireDestinationRead(); + requireStorage(false); + caller.check(); + return job; + } + + + /** Recheck table, credential and channel permissions after both row customizers. */ + private static void requireDestinationRead() throws QException + { + for(String table : List.of(PrintingTables.PRINTER, PrintingTables.DEVICE)) + { + GetInput input = new GetInput(); + input.setTableName(table); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + PermissionsHelper.checkTablePermissionThrowing(input, TablePermissionSubType.READ); + } + } + + + /*************************************************************************** + ** Snapshot only authority and routing context, never session credentials. + ***************************************************************************/ + private static CallContext capture() throws QException + { + String actor = principal(); + QSession session = QContext.getQSession(); + Map> keys = new HashMap<>(); + if(session.getSecurityKeyValues() != null) + { + session.getSecurityKeyValues().forEach((key, values) -> keys.put(key, values == null ? null : new ArrayList<>(values))); + } + return new CallContext(QContext.getQInstance(), session, session.getUser(), actor, PrintingProcesses.currentService(), keys, + session.getBackendVariants() == null ? null : new HashMap<>(session.getBackendVariants())); + } + + + /*************************************************************************** + ** Native customizers cannot replace the caller or its row-security scope. + ** Permissions and credential/channel ceilings are separately checked live. + ***************************************************************************/ + private record CallContext(QInstance instance, QSession session, QUser user, String actor, PrintingService service, + Map> keys, Map variants) + { + /** Fail before consuming callback results under a different native authority. */ + void check() throws QException + { + if(instance != QContext.getQInstance() || session != QContext.getQSession() || user != session.getUser() + || !actor.equals(principal()) || service != PrintingProcesses.currentService() + || !keys.equals(session.getSecurityKeyValues() == null ? Map.of() : session.getSecurityKeyValues()) + || !Objects.equals(variants, session.getBackendVariants())) + { + throw new QPermissionDeniedException("Printing authority changed during the operation"); + } + } + } + + + /*************************************************************************** + ** Trusted domain writes retain process, credential and channel ceilings. + ***************************************************************************/ + public static void requireSubmission() throws QException + { + principal(); + PermissionsHelper.checkProcessPermissionThrowing(new RunBackendStepInput().withProcessName(PrintingOperatorProcesses.SUBMIT), PrintingOperatorProcesses.SUBMIT); + requireStorage(true); + } + + + /*************************************************************************** + ** Scope ceilings do not grant generic job table reads or writes. + ***************************************************************************/ + private static void requireStorage(boolean mutation) throws QException + { + var instance = QContext.getQInstance(); + var table = instance.getTable(PrintingTables.JOB); + if(table == null) + { + throw new QException("Printing job metadata is unavailable"); + } + var rules = PermissionsHelper.getEffectivePermissionRules(table, instance); + if(PermissionsHelper.isDeniedForClientChannel(table, rules) + || (mutation && PermissionsHelper.isWriteDeniedForClientChannel(table, rules)) + || PermissionsHelper.isOutsideCredentialScope(table, mutation ? TablePermissionSubType.INSERT : TablePermissionSubType.READ)) + { + throw new QPermissionDeniedException("Current credential or client channel cannot access print acceptance"); + } + } + + + /*************************************************************************** + ** Stable actor namespace protects request lookup without storing user credentials. + ***************************************************************************/ + private static String requestKey(String actor, String requestId) throws Exception + { + if(!requestId.matches("[A-Za-z0-9._:-]{1,128}")) + { + throw new PrintingException(400, "Invalid print request identity"); + } + return "operator:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(actor.getBytes(StandardCharsets.UTF_8))) + ":" + requestId; + } + + + /*************************************************************************** + ** Legacy unnamed rows retain an honest identity fallback, never guessed media. + ***************************************************************************/ + private static String label(QRecord record, String... fields) + { + for(String field : fields) + { + String value = record.getValueString(field); + if(value != null && !value.isBlank()) + { + return value; + } + } + throw new PrintingException(409, "Print destination has no stable identity"); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java new file mode 100644 index 0000000..bb0eebf --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java @@ -0,0 +1,56 @@ +/* + * 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. + */ + +package com.kingsrook.qbits.printing; + + +import com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; + + +/******************************************************************************* + ** Submit permission grants no device management or general job history access. + *******************************************************************************/ +public class PrintingOperatorPermission extends UseOtherPermissionNameCustomPermissionChecker +{ + /*************************************************************************** + ** Expose the role to standard host permission assignment. + ***************************************************************************/ + public PrintingOperatorPermission() + { + withPermissionName("printing_operator"); + } + + + /*************************************************************************** + ** Administrators retain submission access; operators receive no other role. + ***************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + if(QContext.getQSession() == null || (!QContext.getQSession().hasPermission("printing_operator") && !QContext.getQSession().hasPermission("printing_admin"))) + { + throw new QPermissionDeniedException("Printing operator permission is required"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorProcesses.java new file mode 100644 index 0000000..fd26da6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorProcesses.java @@ -0,0 +1,95 @@ +/* + * 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. + */ + +package com.kingsrook.qbits.printing; + + +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.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import org.json.JSONObject; + + +/******************************************************************************* + ** Explicit operator backend process for host-generated document submissions. + *******************************************************************************/ +public class PrintingOperatorProcesses implements BackendStep +{ + public static final String SUBMIT = "printingSubmit"; + + + /*************************************************************************** + ** Host workflows call this typed input process; it grants no management role. + ***************************************************************************/ + public static void register(QInstance instance) + { + instance.addProcess(new QProcessMetaData().withName(SUBMIT).withLabel("Submit a print document") + .withIsHidden(true) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) + .withCustomPermissionChecker(new QCodeReference(PrintingOperatorPermission.class))) + .withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(PrintingOperatorProcesses.class)) + .withInputData(new QFunctionInputMetaData().withFields(List.of(new QFieldMetaData("requestId", QFieldType.STRING), + new QFieldMetaData("printerRowId", QFieldType.STRING).withPossibleValueSourceName(PrintingTables.PRINTER), + new QFieldMetaData("title", QFieldType.STRING), new QFieldMetaData("format", QFieldType.STRING).withPossibleValueSourceName("printing_format"), + new QFieldMetaData("contentBase64", QFieldType.STRING), new QFieldMetaData("options", QFieldType.STRING)))))); + } + + + /*************************************************************************** + ** Direct step invocation receives the same current-actor checks as native HTTP. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + try + { + JSONObject request = new JSONObject(); + for(String field : List.of("requestId", "printerRowId", "title", "format", "contentBase64")) + { + request.put(field, input.getValueString(field)); + } + String options = input.getValueString("options"); + if(options != null) + { + request.put("options", new JSONObject(options)); + } + output.addValue("job", PrintingOperatorAuthorization.submit(request).toString()); + } + catch(QException exception) + { + throw exception; + } + catch(Exception exception) + { + throw new QException("Print submission failed", exception); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java index a0b0efa..458b7fe 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java @@ -36,6 +36,22 @@ public class PrintingQBitConfig implements QBitConfig private String storageTableName; private String schedulerName; private String facilityPossibleValueSourceName; + private transient RetainedPrintSourceAuthority retainedSourceAuthority; + + + /** Explicit trusted host integration; absent means retained submissions are disabled. */ + public PrintingQBitConfig withRetainedSourceAuthority(RetainedPrintSourceAuthority value) + { + retainedSourceAuthority = java.util.Objects.requireNonNull(value); + return (this); + } + + + /** Never serialized into client metadata or supplied by a request. */ + public RetainedPrintSourceAuthority getRetainedSourceAuthority() + { + return (retainedSourceAuthority); + } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java index af3f693..a787149 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java @@ -111,6 +111,7 @@ public class PrintingQBitProducer implements QBitProducer 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")); + printers.withField(new QFieldMetaData("media_profiles_json", QFieldType.STRING).withLabel("Reported queue default media")); 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")); @@ -173,6 +174,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); + PrintingOperatorProcesses.register(instance); PrintingListProcesses.register(instance); PrintingDeviceProcesses.register(instance); PrintingUiApi.register(instance); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java index 04ac760..ffed478 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java @@ -43,7 +43,15 @@ public class PrintingReadPermission extends PrintingAdminPermission @Override public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException { - super.checkPermissionsThrowing(input, metadata); + if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table + && (PrintingTables.DEVICE.equals(table.getName()) || PrintingTables.PRINTER.equals(table.getName()))) + { + new PrintingOperatorPermission().checkPermissionsThrowing(input, metadata); + } + else + { + super.checkPermissionsThrowing(input, metadata); + } if(input != null && !(input instanceof QueryInput) && !(input instanceof GetInput) && !(input instanceof CountInput) && !(input instanceof MetaDataInput) && !(input instanceof TableMetaDataInput)) { diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java index 44834b5..ce7f90e 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java @@ -437,8 +437,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf { 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())) + || payload.getJSONArray("capabilities").length() > 4 + || !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").containsAll(payload.getJSONArray("capabilities").toList())) { throw new PrintingException(409, "Realtime capability is required"); } @@ -545,7 +545,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf JSONArray capabilities = new JSONArray(); for(Object value : hello.getJSONArray("capabilities")) { - if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL").contains(value)) + if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").contains(value)) { capabilities.put(value); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index 9543efd..956a671 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -277,9 +277,14 @@ public class PrintingService "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", + "ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS media_profiles_json TEXT DEFAULT '[]' 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 ADD COLUMN IF NOT EXISTS retained_actor_id VARCHAR(200)", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS retained_binding_json VARCHAR(16000)", + "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS accepted_event_id VARCHAR(128)", + "ALTER TABLE printing_event ADD COLUMN IF NOT EXISTS event_origin VARCHAR(40)", "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)", @@ -780,6 +785,7 @@ public class PrintingService update(connection, "INSERT INTO printing_printer (id, tenant_id, device_id, printer_id, name, driver_name, port_name, status, formats, enabled, last_seen_at, available, queue_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?)", UUID.randomUUID().toString(), tenantId, deviceId, printerId, name, driver, port, status, formats, now, name); } String rowId = query(connection, "SELECT id FROM printing_printer WHERE device_id=? AND printer_id=?", deviceId, printerId).getFirst().getString("id"); + update(connection, "UPDATE printing_printer SET media_profiles_json=? WHERE id=?", PrintingMediaProfile.inventory(printer).toString(), rowId); updateProfile(connection, "printing_printer", rowId, printer, "displayName", "queue_name"); } update(connection, "UPDATE printing_device SET last_seen_at=? WHERE id=?", Instant.now().toString(), deviceId); @@ -818,6 +824,15 @@ public class PrintingService ***************************************************************************/ public JSONObject submit(JSONObject request) throws SQLException { + return (submitAuthorized(request, null, null)); + } + + + /** Native operators retain current authority throughout shared queue admission. */ + JSONObject submitAuthorized(JSONObject request, String printerRowId, SubmissionAuthorization authorize) throws SQLException + { + SubmissionGuard guard = authorize == null ? null : new SubmissionGuard(printerRowId, authorize); + checkSubmission(guard); String format = required(request.optString("format"), "format"); long maximum = realtimePolicy().documentLimit(format); if(request.getString("contentBase64").length() > ((maximum + 2) / 3) * 4) @@ -845,7 +860,47 @@ public class PrintingService { throw new PrintingException(400, "PDF header is missing"); } - return (submitPrepared(request, hash(content), content.length, connection -> content)); + return (submitPrepared(request, hash(content), content.length, connection -> content, null, guard)); + } + + + /** A caller-owned current authorization check, never externally asserted proof. */ + @FunctionalInterface + interface SubmissionAuthorization + { + /** Recheck caller identity, current ceilings and exact destination. */ + void check() throws com.kingsrook.qqq.backend.core.exceptions.QException; + } + + + /** Native row identity and live authority are independent of retained-source proof. */ + private record SubmissionGuard(String printerRowId, SubmissionAuthorization authorize) + { + /** Host callbacks cannot retarget an authorized native row to another queue. */ + void requirePrinter(JSONObject row, String field) + { + if(!printerRowId.equals(row.optString(field))) + { + throw new PrintingException(409, "The authorized print destination changed"); + } + } + } + + + /** Preserve checked native authorization through the SQL transaction boundary. */ + private static void checkSubmission(SubmissionGuard guard) throws SQLException + { + if(guard != null) + { + try + { + guard.authorize().check(); + } + catch(com.kingsrook.qqq.backend.core.exceptions.QException exception) + { + throw new SQLException("Print submission authorization failed", exception); + } + } } @@ -869,6 +924,58 @@ public class PrintingService ***************************************************************************/ private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader) throws SQLException { + return (submitPrepared(request, digest, contentLength, loader, null, null)); + } + + + /** Only the native facade supplies retained bindings and current-authority checks. */ + JSONObject submitRetained(JSONObject request, JSONObject binding, String actor, RetainedPrintFacade.Checked authorize, RetainedContent loader) throws SQLException + { + binding.put("options", printOptions(request)); + if(binding.toString().length() > 16000) + { + throw new PrintingException(400, "Retained print binding is too large"); + } + JSONObject source = binding.getJSONObject("source"); + BoundSubmission bound = new BoundSubmission(binding.toString(), actor); + return (submitPrepared(request, source.getString("sha256"), source.getInt("sizeBytes"), connection -> + { + try + { + byte[] bytes = loader.read(); + if("PDF".equals(request.getString("format")) && !new String(bytes, 0, Math.min(5, bytes.length), StandardCharsets.US_ASCII).equals("%PDF-")) + { + throw new PrintingException(400, "PDF header is missing"); + } + return bytes; + } + catch(com.kingsrook.qqq.backend.core.exceptions.QException exception) + { + throw new SQLException("Retained content authorization failed", exception); + } + }, bound, new SubmissionGuard(binding.getString("printerRecordId"), authorize::run))); + } + + + /** Deferred current-owner content read after shared capacity admission. */ + @FunctionalInterface + interface RetainedContent + { + /** Return only authorized exact bytes. */ + byte[] read() throws com.kingsrook.qqq.backend.core.exceptions.QException; + } + + + /** Request-local immutable binding; never an externally accepted JSON proof. */ + private record BoundSubmission(String json, String actor) + { + } + + + /** Reuse the existing storage reservation, queue admission, and commit path. */ + private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader, BoundSubmission bound, SubmissionGuard guard) throws SQLException + { + checkSubmission(guard); String deviceId = required(request.optString("deviceId"), "deviceId"); String printerId = required(request.optString("printerId"), "printerId"); String key = required(request.optString("idempotencyKey"), "idempotencyKey"); @@ -898,16 +1005,32 @@ public class PrintingService 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); + checkSubmission(guard); + List previous = query(connection, "SELECT " + JOB_COLUMNS + ", retained_binding_json, retained_actor_id, accepted_event_id FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key); if(!previous.isEmpty()) { JSONObject old = previous.getFirst(); + if(guard != null) + { + guard.requirePrinter(old, "printer_row_id"); + } + if(key.startsWith("operator:") && !old.getString("title").equals(title)) + { + throw new PrintingException(409, "Idempotency key was already used for a different request"); + } + if((bound == null) != old.isNull("retained_binding_json") + || (bound != null && (!new JSONObject(bound.json()).similar(new JSONObject(old.getString("retained_binding_json"))) || !bound.actor().equals(old.getString("retained_actor_id"))))) + { + throw new PrintingException(409, "Idempotency key belongs to a different accepted source"); + } 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"); } + JSONObject replay = bound == null ? job(old) : accepted(connection, old); + checkSubmission(guard); connection.commit(); - return (job(old)); + return (replay); } if(query(connection, "SELECT id FROM printing_document_orphan WHERE tenant_id=? AND idempotency_key=? LIMIT 1", tenantId, key).isEmpty()) { @@ -933,10 +1056,16 @@ public class PrintingService { throw new PrintingException(400, "Printer is not enabled for this format"); } + if(guard != null) + { + guard.requirePrinter(printers.getFirst(), "id"); + } if(contentLength > contentLimit) { throw new PrintingException(413, "Document exceeds the current configured format limit"); } + PrintingMediaProfile.requireCurrent(printers.getFirst(), options); + checkSubmission(guard); PrintingAdmission.reserve(connection, tenantId, deviceId, id, contentLength, new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).effective(connection)); if(storageTableName != null) { @@ -955,26 +1084,44 @@ public class PrintingService { throw new PrintingException(400, "Printer is not enabled for this format"); } + if(guard != null) + { + guard.requirePrinter(printers.getFirst(), "id"); + } } + PrintingMediaProfile.requireCurrent(printers.getFirst(), options); + checkSubmission(guard); byte[] content = loader.load(connection); if(content.length != contentLength || !hash(content).equals(digest)) { throw new PrintingException(409, "Stored print document integrity validation failed"); } + checkSubmission(guard); if(storageTableName != null) { documentStorage.put(storageTableName, reference, format, content); } + checkSubmission(guard); 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); + if(bound != null) + { + String eventId = UUID.randomUUID().toString(); + String acceptedAt = Instant.now().toString(); + update(connection, "UPDATE printing_job SET retained_actor_id=?, retained_binding_json=?, accepted_event_id=? WHERE id=?", bound.actor(), bound.json(), eventId, id); + JSONObject event = new JSONObject().put("type", "ACCEPTED").put("origin", "SERVER_ACCEPTANCE").put("status", "QUEUED"); + update(connection, "INSERT INTO printing_event (id, job_id, status, event_json, created_at, event_origin) VALUES (?, ?, 'QUEUED', ?, ?, 'SERVER_ACCEPTANCE')", eventId, id, event.toString(), acceptedAt); + } 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()); + JSONObject stored = query(connection, "SELECT " + JOB_COLUMNS + ", retained_binding_json, retained_actor_id, accepted_event_id FROM printing_job WHERE id=?", id).getFirst(); + JSONObject result = bound == null ? job(stored) : accepted(connection, stored); + checkSubmission(guard); connection.commit(); realtimeBus.publish(tenantId, deviceId); return (result); } catch(Exception exception) { - connection.rollback(); + rollbackAfterFailure(connection, exception); if(uploadedId != null) { try @@ -983,7 +1130,10 @@ public class PrintingService } catch(Exception cleanupFailure) { - exception.addSuppressed(cleanupFailure); + if(cleanupFailure != exception) + { + exception.addSuppressed(cleanupFailure); + } } } throw exception; @@ -997,6 +1147,13 @@ public class PrintingService ** Preserve request intent: reject malformed types instead of coercing them. ***************************************************************************/ private JSONObject printOptions(JSONObject request) throws SQLException + { + return (printOptions(request, Math.toIntExact(realtimePolicy().get("maxCopies")))); + } + + + /** Normalize an immutable intent without reading or creating tenant settings during reconciliation. */ + private static JSONObject printOptions(JSONObject request, int maximumCopies) { if(request.has("options") && !(request.opt("options") instanceof JSONObject)) { @@ -1021,7 +1178,7 @@ public class PrintingService } } Object scale = options.has("scale") ? options.opt("scale") : "ACTUAL_SIZE"; - if(count < 1 || count > realtimePolicy().get("maxCopies") || !(scale instanceof String) || !Set.of("ACTUAL_SIZE", "FIT").contains(scale)) + if(count < 1 || count > maximumCopies || !(scale instanceof String) || !Set.of("ACTUAL_SIZE", "FIT").contains(scale)) { throw new PrintingException(400, "Invalid print options"); } @@ -1029,6 +1186,7 @@ public class PrintingService { throw new PrintingException(400, "Custom paper dimensions are not supported yet; configure the Windows queue"); } + PrintingMediaProfile.selection(options); options.put("copies", count).put("scale", scale); if(options.toString().length() > 2000) { @@ -1179,7 +1337,7 @@ public class PrintingService requireDevice(connection, deviceId); query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId); requireDevice(connection, deviceId); - JSONObject control = query(connection, "SELECT dispatch_paused,connection_state FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst(); + JSONObject control = query(connection, "SELECT dispatch_paused,connection_state,control_capabilities 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(); @@ -1192,6 +1350,11 @@ public class PrintingService return (new JSONObject().put("job", JSONObject.NULL)); } JSONObject selected = candidates.getFirst(); + if(new JSONObject(selected.getString("options_json")).has("mediaProfile") + && !new JSONArray(control.optString("control_capabilities", "[]")).toList().contains("printer-media-v1")) + { + throw new PrintingException(409, "This job requires an agent that validates printer media profiles"); + } if("QUEUED".equals(selected.getString("status"))) { update(connection, "UPDATE printing_job SET status='CLAIMED' WHERE id=?", selected.getString("id")); @@ -1313,7 +1476,7 @@ public class PrintingService throw new PrintingException(409, "Invalid state transition"); } String eventTime = Instant.now().toString(); - update(connection, "INSERT INTO printing_event VALUES (?, ?, ?, ?, ?)", eventId, jobId, status, event.toString(), eventTime); + update(connection, "INSERT INTO printing_event (id, job_id, status, event_json, created_at, event_origin) VALUES (?, ?, ?, ?, ?, 'DEVICE_OBSERVATION')", 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); @@ -1390,7 +1553,7 @@ public class PrintingService 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"))); + .put("mediaProfiles", new JSONArray(row.getString("media_profiles_json"))).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"))); } JSONObject response = new JSONObject().put("printers", result).put("nextCursor", JSONObject.NULL); if(rows.size() > limit) @@ -1496,6 +1659,126 @@ public class PrintingService + /** Cleanup never replaces the acceptance/authorization failure that caused rollback. */ + static void rollbackAfterFailure(Connection connection, Exception original) + { + try + { + connection.rollback(); + } + catch(Exception cleanupFailure) + { + if(cleanupFailure != original) + { + original.addSuppressed(cleanupFailure); + } + } + } + + + /** Exact accepted-source lookup; unresolved object reservations never mean absence. */ + JSONObject findRetained(String key, JSONObject expected) throws SQLException + { + if(expected != null) + { + expected.put("options", printOptions(new JSONObject().put("options", expected.getJSONObject("options")), 32767)); + } + try(Connection connection = dataSource.getConnection()) + { + connection.setAutoCommit(false); + try + { + PrintingAdmission.lock(connection, tenantId); + JSONObject result = findRetained(connection, key, expected); + connection.commit(); + return (result); + } + catch(Exception exception) + { + rollbackAfterFailure(connection, exception); + throw exception; + } + } + } + + + /** The existing admission lock makes absence consistent with in-flight submissions. */ + private JSONObject findRetained(Connection connection, String key, JSONObject expected) throws SQLException + { + List rows = query(connection, "SELECT " + JOB_COLUMNS + ", retained_binding_json, retained_actor_id, accepted_event_id FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key); + if(rows.isEmpty()) + { + if(!query(connection, "SELECT id FROM printing_document_orphan WHERE tenant_id=? AND idempotency_key=?", tenantId, key).isEmpty()) + { + throw new PrintingException(409, "Retained print acceptance is pending recovery"); + } + return null; + } + JSONObject row = rows.getFirst(); + if(row.isNull("retained_binding_json") || (expected != null && !expected.similar(new JSONObject(row.getString("retained_binding_json"))))) + { + throw new PrintingException(409, "Accepted source binding differs"); + } + return (accepted(connection, row)); + } + + + /** Verify the server acceptance event and immutable job content/destination before returning proof. */ + private JSONObject accepted(Connection connection, JSONObject row) throws SQLException + { + if(row.isNull("retained_binding_json") || row.isNull("accepted_event_id") || row.isNull("retained_actor_id")) + { + throw new PrintingException(409, "Job has no retained acceptance binding"); + } + JSONObject binding = new JSONObject(row.getString("retained_binding_json")); + JSONObject source = binding.getJSONObject("source"); + if(!tenantId.equals(binding.getString("tenantId")) || !row.getString("retained_actor_id").equals(binding.getString("actorId")) + || !row.getString("idempotency_key").equals(RetainedPrintFacade.key(binding.getString("requestId"))) + || !row.getString("device_id").equals(binding.getString("deviceId")) || !row.getString("printer_id").equals(binding.getString("printerId")) + || !row.getString("printer_row_id").equals(binding.getString("printerRecordId")) || !row.getString("title").equals(binding.getString("title")) + || !row.getString("content_hash").equals(source.getString("sha256")) || row.getLong("content_length") != source.getLong("sizeBytes") + || !row.getString("format").equals("application/pdf".equals(source.getString("mediaType")) ? "PDF" : "ZPL") + || !new JSONObject(row.getString("options_json")).similar(binding.getJSONObject("options"))) + { + throw new PrintingException(409, "Accepted job binding is inconsistent"); + } + List events = query(connection, "SELECT * FROM printing_event WHERE id=? AND job_id=? AND event_origin='SERVER_ACCEPTANCE' AND status='QUEUED'", row.getString("accepted_event_id"), row.getString("id")); + if(events.size() != 1 || !new JSONObject(events.getFirst().getString("event_json")).similar(new JSONObject().put("type", "ACCEPTED").put("origin", "SERVER_ACCEPTANCE").put("status", "QUEUED"))) + { + throw new PrintingException(409, "Committed server acceptance event is absent or inconsistent"); + } + return (new JSONObject().put("jobId", row.getString("id")).put("acceptedEventId", row.getString("accepted_event_id")) + .put("acceptedAt", events.getFirst().getString("created_at")).put("binding", binding)); + } + + + /** Old unclassified events and server acceptance are not device observation evidence. */ + JSONObject retainedObservation(String key, String eventId) throws SQLException + { + JSONObject acceptance = findRetained(key, null); + if(acceptance == null) + { + throw new PrintingException(404, "Accepted print intent not found"); + } + try(Connection connection = dataSource.getConnection()) + { + List events = query(connection, "SELECT * FROM printing_event WHERE id=? AND job_id=? AND event_origin='DEVICE_OBSERVATION'", eventId, acceptance.getString("jobId")); + if(events.size() != 1) + { + throw new PrintingException(404, "Committed device observation not found"); + } + JSONObject row = events.getFirst(); + JSONObject event = new JSONObject(row.getString("event_json")); + if(!eventId.equals(event.getString("eventId")) || !row.getString("status").equals(event.getString("status"))) + { + throw new PrintingException(409, "Device observation is inconsistent"); + } + return (new JSONObject().put("acceptance", acceptance).put("recordedAt", row.getString("created_at")) + .put("state", row.getString("status")).put("spoolerJobId", event.opt("spoolerJobId") == null ? JSONObject.NULL : event.opt("spoolerJobId"))); + } + } + + /*************************************************************************** ** Find an accepted request during durable-effect reconciliation. ***************************************************************************/ @@ -1509,6 +1792,32 @@ public class PrintingService } + /** The native row accepted with an operator request is private authorization data. */ + record OperatorAcceptance(JSONObject job, String printerRowId) + { + } + + + /** Read the exact committed row binding; a replacement queue cannot inherit history. */ + OperatorAcceptance findOperatorAcceptance(String key) throws SQLException + { + try(Connection connection = dataSource.getConnection()) + { + List rows = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key); + if(rows.isEmpty()) + { + return null; + } + JSONObject row = rows.getFirst(); + if(row.isNull("printer_row_id") || row.getString("printer_row_id").isBlank()) + { + throw new PrintingException(409, "Accepted print destination binding is unavailable"); + } + return new OperatorAcceptance(job(row), row.getString("printer_row_id")); + } + } + + /*************************************************************************** ** Host-authorized maintenance only, never called by device routes. Purge a diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java index a5ed708..885fc9e 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; 2.1.0 adds the record, table-list and text print processes. */ + /** Semantic extension 2.2.0 adds authorized operator submission and reported queue-default media. */ public static MiddlewareSpecExtension extension() { - 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.") + return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.2.0") + .withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; least-privilege operator catalog and printer-media-v1.") .withSpec(Session.class) .withSpec(Bind.class) .withSpec(Enroll.class) @@ -70,7 +70,43 @@ public final class PrintingUiApi .withSpec(Receipt.class) .withSpec(Action.class) .withSpec(Commands.class) - .withSpec(Command.class)); + .withSpec(Command.class) + .withSpec(OperatorCatalog.class) + .withSpec(OperatorSubmit.class) + .withSpec(OperatorRequest.class)); + } + + + /** Current-actor, approved/exposed printer catalog; no administrative permission. */ + public static class OperatorCatalog extends PrintingUiApiSpec + { + /** Public registry constructor. */ + public OperatorCatalog() + { + super("OperatorCatalog", HttpMethod.GET, "/operator/printers", null, "OperatorCatalog"); + } + } + + + /** Operator document submission uses the existing durable queue. */ + public static class OperatorSubmit extends PrintingUiApiSpec + { + /** Public registry constructor. */ + public OperatorSubmit() + { + super("OperatorSubmit", HttpMethod.POST, "/operator/jobs", "OperatorSubmit", "Job"); + } + } + + + /** Exact actor/request lookup replaces general job history access. */ + public static class OperatorRequest extends PrintingUiApiSpec + { + /** Public registry constructor. */ + public OperatorRequest() + { + super("OperatorRequest", HttpMethod.GET, "/operator/requests/{requestId}", null, "Job"); + } } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java index 1e4a7ce..d0f557b 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java @@ -72,7 +72,8 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec { 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.")); + .withLongDescription(operation.startsWith("Operator") ? "Requires the current QQQ user session, printing_operator or printing_admin, current tenant and native destination record access. Submissions additionally require printingSubmit process and credential/channel write authority." + : "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.")); } @@ -111,7 +112,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec 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")) + for(String name : List.of("deviceId", "jobId", "commandId", "requestId")) { if(path.contains("{" + name + "}")) { @@ -123,6 +124,11 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec 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))); } + if("OperatorCatalog".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("after").withIn(In.QUERY).withSchema(PrintingApiSchemas.text(200))); + } return (parameters); } @@ -132,8 +138,22 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec public JsonInput buildInput(Context context) throws Exception { context.header("Cache-Control", "no-store"); - PrintingControlAuthorization.principal(); - if(context.bodyAsBytes().length > ("Inventory".equals(operation) ? 2097152 : 32768)) + if(operation.startsWith("Operator")) + { + PrintingOperatorAuthorization.principal(); + } + else + { + PrintingControlAuthorization.principal(); + } + long maximum = "Inventory".equals(operation) ? 2097152 : 32768; + if("OperatorSubmit".equals(operation)) + { + PrintingOperatorAuthorization.requireSubmission(); + PrintingRealtimePolicy policy = PrintingProcesses.currentService().realtimePolicy(); + maximum = ((Math.max(policy.get("maxPdfBytes"), policy.get("maxZplBytes")) + 2) / 3) * 4 + 65536; + } + if(context.bodyAsBytes().length > maximum) { throw new PrintingException(413, "Printing request exceeds its size limit"); } @@ -157,6 +177,17 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec protected Object handle(JsonInput input) throws Exception { PrintingService service = PrintingProcesses.currentService(); + if(operation.startsWith("Operator")) + { + JSONObject result = switch(operation) + { + case "OperatorCatalog" -> PrintingOperatorAuthorization.catalog(input.getQueryParamInteger("limit") == null ? 50 : input.getQueryParamInteger("limit"), input.getQueryParam("after")); + case "OperatorSubmit" -> PrintingOperatorAuthorization.submit(new JSONObject(input.getBody())); + case "OperatorRequest" -> PrintingOperatorAuthorization.observe(input.getPathParam("requestId")); + default -> throw new PrintingException(400, "Unknown operator operation"); + }; + return result.toMap(); + } String principal = PrintingControlAuthorization.principal(); String device = input.getPathParam("_verifiedDevice"); JSONObject body = new JSONObject(input.getBody()); diff --git a/src/main/java/com/kingsrook/qbits/printing/RetainedPrintFacade.java b/src/main/java/com/kingsrook/qbits/printing/RetainedPrintFacade.java new file mode 100644 index 0000000..e9a64ee --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/RetainedPrintFacade.java @@ -0,0 +1,358 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-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. + */ + +package com.kingsrook.qbits.printing; + + +import java.sql.SQLException; +import java.time.Instant; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.json.JSONObject; + + +/******************************************************************************* + ** Native authorized acceptance of exact retained originals. No device wire DTO + ** changes, owner-specific dependencies, alternate queue, or impersonation. + *******************************************************************************/ +public final class RetainedPrintFacade +{ + /** Immutable owner metadata; the canonical reference binds source/version/generation/actor/tenant, never credentials. */ + public record SourceReference(String owner, String opaqueReference, String sha256, long sizeBytes, String mediaType) + { + /** Validate before allocation, persistence or native dispatch. */ + public SourceReference + { + text(owner, 100); + text(opaqueReference, 4000); + if(sizeBytes < 1 || sizeBytes > Integer.MAX_VALUE || sha256 == null || !sha256.matches("[a-f0-9]{64}") + || !("application/pdf".equals(mediaType) || "application/vnd.zebra-zpl".equals(mediaType))) + { + throw new PrintingException(400, "Invalid retained source metadata"); + } + } + + /** Fixed fields, not an arbitrary caller JSON document. */ + JSONObject json() + { + return (new JSONObject().put("owner", owner).put("opaqueReference", opaqueReference).put("sha256", sha256) + .put("sizeBytes", sizeBytes).put("mediaType", mediaType)); + } + } + + /** Caller chooses only an intent, owner locator and native destination, not an actor. */ + public record Request(String requestId, String sourceLocator, String printerRecordId, String title, String optionsJson) + { + /** Freeze and bound the complete input before invoking trusted adapters. */ + public Request + { + requestIdentity(requestId); + text(sourceLocator, 200); + text(printerRecordId, 36); + text(title, 1024); + if(optionsJson == null || optionsJson.length() > 2000) + { + throw new PrintingException(400, "Invalid print options"); + } + new JSONObject(optionsJson); + } + } + + /** Server acceptance is queue durability, not device or physical-output evidence. */ + public record Acceptance(String requestId, String jobId, String acceptedEventId, Instant acceptedAt, String tenantId, + String actorId, SourceReference source, String deviceId, String printerId, String printerRecordId, String title, String optionsJson) + { + } + + /** Exact immutable device observation, separate from the server acceptance event. */ + public record Observation(Acceptance acceptance, String eventId, Instant recordedAt, String state, String spoolerJobId) + { + } + + /** Resolve current source and destination authority, then use the existing queue transaction. */ + public Acceptance submit(Request request) throws QException + { + Prepared prepared = prepare(request, true); + try + { + JSONObject result = prepared.service().submitRetained(prepared.wire(), prepared.binding(), prepared.actor(), + () -> prepared.check(), () -> + { + prepared.check(); + byte[] bytes = prepared.authority().read(prepared.source(), request.requestId()); + prepared.check(); + if(bytes == null || bytes.length != prepared.source().sizeBytes()) + { + throw new QException("Retained source changed its content length"); + } + return bytes; + }); + Acceptance accepted = acceptance(result); + checkDestination(accepted); + prepared.check(); + return (accepted); + } + catch(SQLException exception) + { + throw new QException("Retained print acceptance unavailable; reconcile the same request identity", exception); + } + } + + /** Absence is returned only when neither an accepted job nor an unresolved upload exists. */ + public Acceptance find(Request request) throws QException + { + Prepared prepared = prepare(request, false); + try + { + JSONObject row = prepared.service().findRetained(key(request.requestId()), prepared.binding()); + prepared.check(); + if(row == null) + { + return null; + } + Acceptance accepted = acceptance(row); + checkDestination(accepted); + prepared.check(); + return (accepted); + } + catch(SQLException exception) + { + throw new QException("Retained print lookup unavailable", exception); + } + } + + /** Current operator needs owner observation authority and the exact destination, never generic job access. */ + public Observation observation(String requestId, String eventId) throws QException + { + requestIdentity(requestId); + text(eventId, 128); + String reporter = PrintingOperatorAuthorization.principal(); + requireStorage(false); + PrintingService service = PrintingProcesses.currentService(); + RetainedPrintSourceAuthority authority = authority(service); + var instance = QContext.getQInstance(); + var session = QContext.getQSession(); + try + { + JSONObject stored = service.findRetained(key(requestId), null); + if(stored == null) + { + throw new PrintingException(404, "Accepted print intent not found"); + } + Acceptance accepted = acceptance(stored); + checkDestination(accepted); + JSONObject proof = service.retainedObservation(key(requestId), eventId); + if(!accepted.equals(acceptance(proof.getJSONObject("acceptance")))) + { + throw new PrintingException(409, "Accepted print binding changed"); + } + authority.authorizeObservation(accepted.source(), requestId); + if(instance != QContext.getQInstance() || session != QContext.getQSession() + || !reporter.equals(PrintingOperatorAuthorization.principal()) || service != PrintingProcesses.currentService() + || authority != authority(service)) + { + throw new QException("Printing reporter authority changed"); + } + requireStorage(false); + checkDestination(accepted); + return (new Observation(accepted, eventId, Instant.parse(proof.getString("recordedAt")), proof.getString("state"), + proof.isNull("spoolerJobId") ? null : proof.getString("spoolerJobId"))); + } + catch(SQLException exception) + { + throw new QException("Committed print observation unavailable", exception); + } + } + + /** Capture one native request context and trusted configuration; callbacks cannot retarget it. */ + private Prepared prepare(Request request, boolean mutation) throws QException + { + Objects.requireNonNull(request); + if(QContext.getQBackendTransaction() != null) + { + throw new QException("Retained print acceptance requires its own committed transaction"); + } + String actor = PrintingOperatorAuthorization.principal(); + text(actor, 200); + requireStorage(mutation); + if(mutation) + { + PrintingOperatorAuthorization.requireSubmission(); + } + PrintingService service = PrintingProcesses.currentService(); + var instance = QContext.getQInstance(); + var session = QContext.getQSession(); + RetainedPrintSourceAuthority authority = authority(service); + QRecord printer = mutation ? PrintingOperatorAuthorization.destination(request.printerRecordId()) + : PrintingOperatorAuthorization.authorizeDestinationHistory(request.printerRecordId()); + String device = printer.getValueString("device_id"); + String printerId = printer.getValueString("printer_id"); + SourceReference source = Objects.requireNonNull(authority.resolve(request.sourceLocator(), request.requestId())); + JSONObject wire = new JSONObject().put("deviceId", device).put("printerId", printerId).put("idempotencyKey", key(request.requestId())) + .put("title", request.title()).put("format", "application/pdf".equals(source.mediaType()) ? "PDF" : "ZPL") + .put("options", new JSONObject(request.optionsJson())); + JSONObject binding = new JSONObject().put("requestId", request.requestId()).put("actorId", actor).put("source", source.json()) + .put("printerRecordId", request.printerRecordId()).put("deviceId", device).put("printerId", printerId).put("title", request.title()) + .put("options", new JSONObject(request.optionsJson())).put("tenantId", service.getTenantId()); + Checked check = () -> + { + requireStorage(mutation); + if(mutation) + { + PrintingOperatorAuthorization.requireSubmission(); + } + if(instance != QContext.getQInstance() || session != QContext.getQSession() || QContext.getQBackendTransaction() != null + || service != PrintingProcesses.currentService() || authority != authority(service) || !actor.equals(PrintingOperatorAuthorization.principal())) + { + throw new QException("Retained print authority changed"); + } + QRecord current = mutation ? PrintingOperatorAuthorization.printer(request.printerRecordId(), wire.getString("format")) + : PrintingOperatorAuthorization.authorizeDestinationHistory(request.printerRecordId()); + if(!device.equals(current.getValueString("device_id")) || !printerId.equals(current.getValueString("printer_id"))) + { + throw new QException("Retained print destination changed"); + } + }; + check.run(); + Prepared prepared = new Prepared(service, authority, source, actor, wire, binding, () -> + { + check.run(); + SourceReference current = authority.resolve(request.sourceLocator(), request.requestId()); + check.run(); + if(!source.equals(current)) + { + throw new QException("Retained print source changed"); + } + }); + prepared.check(); + return (prepared); + } + + + /** Historical evidence requires current record authority without granting generic queue reads. */ + private static void checkDestination(Acceptance accepted) throws QException + { + QRecord printer = PrintingOperatorAuthorization.authorizeDestinationHistory(accepted.printerRecordId()); + if(!accepted.deviceId().equals(printer.getValueString("device_id")) || !accepted.printerId().equals(printer.getValueString("printer_id"))) + { + throw new QException("Accepted print destination changed"); + } + } + + /** Keep owner service writes within native credential/channel ceilings, without generic edit roles. */ + private static void requireStorage(boolean mutation) throws QException + { + var instance = QContext.getQInstance(); + var table = instance == null ? null : instance.getTable(PrintingTables.JOB); + if(table == null) + { + throw new QException("Printing job metadata is unavailable"); + } + var rules = PermissionsHelper.getEffectivePermissionRules(table, instance); + if(PermissionsHelper.isDeniedForClientChannel(table, rules) + || (mutation && PermissionsHelper.isWriteDeniedForClientChannel(table, rules)) + || PermissionsHelper.isOutsideCredentialScope(table, mutation ? TablePermissionSubType.INSERT : TablePermissionSubType.READ)) + { + throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("Current credential or client channel cannot access print acceptance"); + } + } + + /** Only the host's matching instance configuration can supply owner authority. */ + private static RetainedPrintSourceAuthority authority(PrintingService service) throws QException + { + for(var metadata : QContext.getQInstance().getQBits().values()) + { + if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() == service && config.getRetainedSourceAuthority() != null) + { + return (config.getRetainedSourceAuthority()); + } + } + throw new QException("Retained printing is not configured"); + } + + /** Package-private checked transaction callbacks never originate in request JSON. */ + @FunctionalInterface + interface Checked + { + /** Reauthorize without committing any printing state. */ + void run() throws QException; + } + + /** One request-local immutable capture; JSON never escapes to callers. */ + private record Prepared(PrintingService service, RetainedPrintSourceAuthority authority, SourceReference source, + String actor, JSONObject wire, JSONObject binding, Checked verifier) + { + /** Run the exact capture's current-authority check. */ + void check() throws QException + { + verifier.run(); + } + } + + /** Stable namespace prevents accidental collision with ordinary producer keys. */ + static String key(String requestId) + { + return ("retained:" + requestId); + } + + + /** Shared stable intent syntax supports owner namespaces such as echo:scan-id. */ + private static void requestIdentity(String value) + { + if(value == null || !value.matches("[A-Za-z0-9._:-]{1,128}")) + { + throw new PrintingException(400, "Invalid retained print request identity"); + } + } + + /** Decode only a server-built accepted proof, never a wire job or caller payload. */ + private static Acceptance acceptance(JSONObject row) + { + JSONObject binding = row.getJSONObject("binding"); + JSONObject source = binding.getJSONObject("source"); + return (new Acceptance(binding.getString("requestId"), row.getString("jobId"), row.getString("acceptedEventId"), + Instant.parse(row.getString("acceptedAt")), binding.getString("tenantId"), binding.getString("actorId"), + new SourceReference(source.getString("owner"), source.getString("opaqueReference"), source.getString("sha256"), + source.getLong("sizeBytes"), source.getString("mediaType")), binding.getString("deviceId"), binding.getString("printerId"), binding.getString("printerRecordId"), + binding.getString("title"), binding.getJSONObject("options").toString())); + } + + /** Reject malformed Unicode, controls and oversized identifiers without logging values. */ + private static void text(String value, int maximum) + { + if(value == null || value.isBlank() || value.length() > maximum) + { + throw new PrintingException(400, "Invalid retained print identifier"); + } + for(int i = 0; i < value.length(); i++) + { + char c = value.charAt(i); + if(Character.isISOControl(c) || Character.isLowSurrogate(c) + || (Character.isHighSurrogate(c) && (++i >= value.length() || !Character.isLowSurrogate(value.charAt(i))))) + { + throw new PrintingException(400, "Invalid retained print identifier"); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/RetainedPrintSourceAuthority.java b/src/main/java/com/kingsrook/qbits/printing/RetainedPrintSourceAuthority.java new file mode 100644 index 0000000..2c0c4f0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/RetainedPrintSourceAuthority.java @@ -0,0 +1,54 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-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. + */ + +package com.kingsrook.qbits.printing; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; + + +/******************************************************************************* + ** Trusted host adapter to an independent retained-document owner. Both methods + ** must authorize the CURRENT native actor and exact eligible source. A locator + ** is correlation, never permission. Do not reconstruct or replace sessions. + *******************************************************************************/ +public interface RetainedPrintSourceAuthority +{ + /** + ** Resolve a canonical immutable version under the owner's current admission policy. + ** The opaque reference binds source association, version, generation request, + ** initiating actor and tenant; printing adds no release or quarantine override. + ** Resolution is read-only: it must not generate, dispatch, release, or reconcile work. + */ + RetainedPrintFacade.SourceReference resolve(String locator, String requestId) throws QException; + + /** Read only that version, with current owner authorization and bounded bytes. */ + byte[] read(RetainedPrintFacade.SourceReference source, String requestId) throws QException; + + /** + ** Authorize exposing an accepted source's metadata to the current reporter. + ** This grants neither byte access nor impersonation of the initiating actor. + ** Owners may apply their observation policy independently of release state. + */ + default void authorizeObservation(RetainedPrintFacade.SourceReference source, String requestId) throws QException + { + throw new QException("Retained source observation is not configured"); + } +} diff --git a/src/main/resources/openapi/qqq-v1/printing.yaml b/src/main/resources/openapi/qqq-v1/printing.yaml index 33ce14d..c88241a 100644 --- a/src/main/resources/openapi/qqq-v1/printing.yaml +++ b/src/main/resources/openapi/qqq-v1/printing.yaml @@ -207,6 +207,7 @@ components: enum: - "REFRESH_INVENTORY" - "CANCEL_SPOOL" + - "printer-media-v1" type: "string" type: "array" lastAppliedRevision: @@ -416,6 +417,145 @@ components: required: - "jobs" type: "object" + PrintingMediaProfileV1: + properties: + id: + maxLength: 128 + type: "string" + revision: + maxLength: 64 + type: "string" + name: + maxLength: 512 + type: "string" + widthMicrons: + type: "integer" + heightMicrons: + type: "integer" + dpiX: + type: "integer" + dpiY: + type: "integer" + source: + enum: + - "WINDOWS_QUEUE_DEFAULT" + type: "string" + applyMode: + enum: + - "DRIVER_DEFAULT" + type: "string" + required: + - "id" + - "revision" + - "name" + - "widthMicrons" + - "heightMicrons" + - "dpiX" + - "dpiY" + - "source" + - "applyMode" + type: "object" + PrintingMediaSelectionV1: + properties: + id: + maxLength: 128 + type: "string" + revision: + maxLength: 64 + type: "string" + required: + - "id" + - "revision" + type: "object" + PrintingOperatorCatalogV1: + properties: + printers: + items: + $ref: "#/components/schemas/PrintingOperatorPrinterV1" + type: "array" + nextCursor: + maxLength: 200 + nullable: true + type: "string" + required: + - "printers" + - "nextCursor" + type: "object" + PrintingOperatorPrinterV1: + properties: + printerRowId: + maxLength: 128 + type: "string" + deviceId: + maxLength: 128 + type: "string" + printerId: + maxLength: 128 + type: "string" + name: + maxLength: 1024 + type: "string" + deviceName: + maxLength: 1024 + type: "string" + description: + maxLength: 4096 + type: "string" + location: + maxLength: 512 + type: "string" + status: + maxLength: 40 + type: "string" + formats: + items: + enum: + - "PDF" + - "ZPL" + type: "string" + type: "array" + mediaProfiles: + items: + $ref: "#/components/schemas/PrintingMediaProfileV1" + type: "array" + required: + - "printerRowId" + - "deviceId" + - "printerId" + - "name" + - "deviceName" + - "status" + - "formats" + - "mediaProfiles" + type: "object" + PrintingOperatorSubmitV1: + properties: + requestId: + maxLength: 128 + type: "string" + printerRowId: + maxLength: 128 + type: "string" + title: + maxLength: 1024 + type: "string" + format: + enum: + - "PDF" + - "ZPL" + type: "string" + contentBase64: + maxLength: 2147483647 + type: "string" + options: + $ref: "#/components/schemas/PrintingOptionsV1" + required: + - "requestId" + - "printerRowId" + - "title" + - "format" + - "contentBase64" + type: "object" PrintingOptionsV1: properties: copies: @@ -425,6 +565,8 @@ components: - "ACTUAL_SIZE" - "FIT" type: "string" + mediaProfile: + $ref: "#/components/schemas/PrintingMediaSelectionV1" paperWidthMm: type: "number" paperHeightMm: @@ -459,6 +601,10 @@ components: - "ZPL" type: "string" type: "array" + mediaProfiles: + items: + $ref: "#/components/schemas/PrintingMediaProfileV1" + type: "array" displayName: maxLength: 512 type: "string" @@ -605,9 +751,10 @@ info: contact: email: "contact@kingsrook.com" description: "Native current-user printing with secondary device proof, device-control-v1\ - \ and realtime-v1; record, table-list and text printing processes." + \ and realtime-v1; record, table-list and text printing processes; least-privilege\ + \ operator catalog and printer-media-v1." title: "QQQ Middleware API extension: printing" - version: "2.1.0" + version: "2.2.0" openapi: "3.0.3" paths: /qqq/v1/qbit/printing/session: @@ -2071,6 +2218,231 @@ paths: summary: "Printing Command" tags: - "printing" + /qqq/v1/qbit/printing/operator/printers: + get: + description: "Requires the current QQQ user session, printing_operator or printing_admin,\ + \ current tenant and native destination record access. Submissions additionally\ + \ require printingSubmit process and credential/channel write authority." + operationId: "printingOperatorCatalog" + parameters: + - description: "1 through 200; default 50." + in: "query" + name: "limit" + schema: + type: "integer" + - in: "query" + name: "after" + schema: + maxLength: 200 + 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/PrintingOperatorCatalogV1" + 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 OperatorCatalog" + tags: + - "printing" + /qqq/v1/qbit/printing/operator/jobs: + post: + description: "Requires the current QQQ user session, printing_operator or printing_admin,\ + \ current tenant and native destination record access. Submissions additionally\ + \ require printingSubmit process and credential/channel write authority." + operationId: "printingOperatorSubmit" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrintingOperatorSubmitV1" + 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 OperatorSubmit" + tags: + - "printing" + /qqq/v1/qbit/printing/operator/requests/{requestId}: + get: + description: "Requires the current QQQ user session, printing_operator or printing_admin,\ + \ current tenant and native destination record access. Submissions additionally\ + \ require printingSubmit process and credential/channel write authority." + operationId: "printingOperatorRequest" + parameters: + - in: "path" + name: "requestId" + 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/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 OperatorRequest" + tags: + - "printing" servers: - description: "The QQQ server that serves this document (paths include /qqq/v1)." url: "/" diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorityTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorityTest.java new file mode 100644 index 0000000..6786782 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorAuthorityTest.java @@ -0,0 +1,378 @@ +/* + * 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. + */ + +package com.kingsrook.qbits.printing; + + +import java.nio.charset.StandardCharsets; +import java.sql.DriverManager; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +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.QueryOrGetInputInterface; +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.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QCredentialScope; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +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.rdbms.model.metadata.RDBMSBackendMetaData; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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; + + +/******************************************************************************* + ** Real native post-Get hooks must not carry stale authority into trusted writes. + *******************************************************************************/ +class PrintingOperatorAuthorityTest +{ + private static final ThreadLocal AFTER_GET = new ThreadLocal<>(); + private static final ThreadLocal DEVICE_QUERIES = new ThreadLocal<>(); + private static final ThreadLocal RETARGET_DEVICE = new ThreadLocal<>(); + + + /*************************************************************************** + ** Each case owns its native session and synthetic database. + ***************************************************************************/ + @AfterEach + void clear() + { + AFTER_GET.remove(); + DEVICE_QUERIES.remove(); + RETARGET_DEVICE.remove(); + QContext.clear(); + } + + + /*************************************************************************** + ** Independent mutable authority components must all retain current meaning. + ***************************************************************************/ + enum Change + { + ACTOR, SESSION, CREDENTIAL, PROCESS, ROW_SCOPE, CHANNEL + } + + + /*************************************************************************** + ** Authorization changes in the last destination hook precede any admission. + ***************************************************************************/ + @ParameterizedTest + @EnumSource(Change.class) + void submissionRejectsPostGetAuthorityChanges(Change change) throws Exception + { + var fixture = fixture(); + mutateAfterGet(fixture, change, true); + Exception denied = null; + try + { + PrintingOperatorAuthorization.submit(request(fixture)); + } + catch(Exception exception) + { + denied = exception; + } + assertEquals(null, AFTER_GET.get(), "The real native Get callback must run before assessing denial"); + assertTrue(denied instanceof QException, "Submission must reject changed authority"); + assertCounts(fixture, 0); + } + + + /*************************************************************************** + ** Receipt lookup cannot return the old actor's result after a native hook. + ***************************************************************************/ + @ParameterizedTest + @EnumSource(value = Change.class, names = { "ACTOR", "SESSION", "CREDENTIAL", "ROW_SCOPE", "CHANNEL" }) + void observationRejectsPostGetAuthorityChanges(Change change) throws Exception + { + var fixture = fixture(); + PrintingOperatorAuthorization.submit(request(fixture)); + mutateAfterGet(fixture, change, false); + Exception denied = null; + try + { + PrintingOperatorAuthorization.observe("echo:hook"); + } + catch(Exception exception) + { + denied = exception; + } + assertEquals(null, AFTER_GET.get(), "The real native Get callback must run before assessing denial"); + assertTrue(denied instanceof QException, "Observation must reject changed authority"); + assertCounts(fixture, 1); + } + + + /*************************************************************************** + ** A read-only credential may still observe its own authorized accepted job. + ***************************************************************************/ + @Test + void readOnlyObservationAndExactReplayRemainAvailable() throws Exception + { + var fixture = fixture(); + JSONObject first = PrintingOperatorAuthorization.submit(request(fixture)); + assertEquals(first.getString("jobId"), PrintingOperatorAuthorization.submit(request(fixture)).getString("jobId")); + QContext.getQSession().setCredentialScope(new QCredentialScope().withReadOnly(true)); + assertEquals(first.getString("jobId"), PrintingOperatorAuthorization.observe("echo:hook").getString("jobId")); + assertCounts(fixture, 1); + } + + + /*************************************************************************** + ** A late storage callback cannot commit with authority revoked during upload. + ***************************************************************************/ + @ParameterizedTest + @EnumSource(value = Change.class, names = { "ACTOR", "CREDENTIAL", "PROCESS", "CHANNEL" }) + void storageCallbackDenialRollsBackJobAndCleansOwnedUpload(Change change) throws Exception + { + var fixture = fixture(); + RevokingStorage storage = new RevokingStorage(() -> change(fixture, change, true)); + fixture.service().configureStorage(storage, "printing_documents"); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(fixture))); + assertEquals(1, storage.writes); + assertTrue(storage.objects.isEmpty(), "The unaccepted owned upload is cleaned up"); + assertCounts(fixture, 0); + } + + + /*************************************************************************** + ** Even a consistently retargeting host hook cannot change the native row intent. + ***************************************************************************/ + @Test + void postQueryDestinationRewriteCannotSelectAnotherQueue() throws Exception + { + var fixture = fixture(); + RETARGET_DEVICE.set(fixture.otherDevice()); + fixture.instance().getTable(PrintingTables.PRINTER).withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(RetargetingPrinter.class)); + assertEquals(409, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(fixture))).getStatus()); + assertCounts(fixture, 0); + } + + + /** History belongs to the accepted native row, even if its wire pair is reused. */ + @Test + void observationKeepsOriginalPrinterRowIdentity() throws Exception + { + var fixture = fixture(); + JSONObject accepted = PrintingOperatorAuthorization.submit(request(fixture)); + fixture.service().enablePrinter(fixture.device(), "laser", false); + assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.observe("echo:hook").getString("jobId")); + try(var connection = connection(fixture); var statement = connection.prepareStatement("DELETE FROM printing_printer WHERE id=?")) + { + statement.setString(1, fixture.printerId()); + assertEquals(1, statement.executeUpdate()); + } + fixture.service().inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Replacement queue") + .put("formats", new JSONArray().put("PDF")))); + fixture.service().enablePrinter(fixture.device(), "laser", true); + try(var connection = connection(fixture); var statement = connection.prepareStatement("SELECT id FROM printing_printer WHERE device_id=? AND printer_id='laser'")) + { + statement.setString(1, fixture.device()); + try(var rows = statement.executeQuery()) + { + assertTrue(rows.next()); + assertNotEquals(fixture.printerId(), rows.getString(1)); + } + } + assertThrows(QException.class, () -> PrintingOperatorAuthorization.observe("echo:hook")); + assertCounts(fixture, 1); + } + + + /*************************************************************************** + ** Install only the last native destination callback, after its permission check. + ***************************************************************************/ + private void mutateAfterGet(PrintingProcessesTest.Fixture fixture, Change change, boolean mutation) + { + if(change == Change.ROW_SCOPE) + { + fixture.instance().addSecurityKeyType(new com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType().withName("revoked_destination")); + fixture.instance().getTable(PrintingTables.DEVICE).withRecordSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock() + .withFieldName("id").withSecurityKeyType("revoked_destination")); + QContext.getQSession().withSecurityKeyValue("revoked_destination", fixture.device()); + } + fixture.instance().getTable(PrintingTables.DEVICE).getCustomizers().put(TableCustomizers.POST_QUERY_RECORD.getRole(), new QCodeReference(ChangingGet.class)); + DEVICE_QUERIES.set(new AtomicInteger()); + AFTER_GET.set(() -> change(fixture, change, mutation)); + } + + + /** Apply the same change either in a native Get hook or in the storage callback. */ + private static void change(PrintingProcessesTest.Fixture fixture, Change change, boolean mutation) + { + switch(change) + { + case ACTOR -> QContext.getQSession().setUser(new QUser().withIdReference("another-actor")); + case SESSION -> QContext.init(fixture.instance(), new QSession().withUser(new QUser().withIdReference("operator")) + .withPermissions("printing_operator").withSecurityKeyValue("printing_tenant", "tenant-a")); + case CREDENTIAL -> QContext.getQSession().setCredentialScope(mutation ? new QCredentialScope().withReadOnly(true) + : new QCredentialScope().withTables(Set.of(PrintingTables.DEVICE, PrintingTables.PRINTER))); + case PROCESS -> QContext.getQSession().setCredentialScope(new QCredentialScope().withProcesses(Set.of())); + case ROW_SCOPE -> QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("tenant-a"), "revoked_destination", List.of("none"))); + case CHANNEL -> + { + QContext.getQSession().setClientChannel("api/test"); + fixture.instance().getTable(PrintingTables.JOB).getPermissionRules().withDeniedClients(Set.of("api")); + } + default -> throw new IllegalArgumentException("Unknown authority mutation"); + } + } + + + /*************************************************************************** + ** QQQ invokes this same hook for real Get actions, not a mocked facade call. + ***************************************************************************/ + public static class ChangingGet implements TableCustomizerInterface + { + @Override + public List postQuery(QueryOrGetInputInterface input, List records) + { + // RC20's RDBMS Get delegates to Query; its hook is the second device query. + if(AFTER_GET.get() != null && (input instanceof GetInput || DEVICE_QUERIES.get().incrementAndGet() == 2)) + { + Runnable action = AFTER_GET.get(); + AFTER_GET.remove(); + action.run(); + } + return records; + } + } + + + /** Return altered display data while preserving the requested row ID. */ + public static class RetargetingPrinter implements TableCustomizerInterface + { + @Override + public List postQuery(QueryOrGetInputInterface input, List records) + { + records.forEach(record -> record.setValue("device_id", RETARGET_DEVICE.get())); + return records; + } + } + + + /** External IO is replaced, while production binding and cleanup logic remain. */ + private static class RevokingStorage extends PrintingDocumentStorage + { + private final Map objects = new HashMap<>(); + private final Runnable revoke; + private int writes; + + /** This fixture has no network target or real bucket. */ + RevokingStorage(Runnable revoke) + { + super(storageInstance(), "tenant-a"); + this.revoke = revoke; + } + + /** Publish the synthetic object, then change the caller before acceptance. */ + @Override + public void put(String table, String reference, String format, byte[] content) + { + objects.put(reference, content.clone()); + writes++; + revoke.run(); + } + + /** Observe actual service compensation after the denied database transaction. */ + @Override + public void delete(String table, String reference) + { + objects.remove(reference); + } + + /** Real S3 metadata exercises the production immutable storage binding. */ + private static QInstance storageInstance() + { + QInstance instance = new QInstance(); + instance.addBackend(new S3BackendMetaData().withName("objects").withBucketName("synthetic-operator").withRegion("us-east-1")); + instance.addTable(new QTableMetaData().withName("printing_documents").withBackendName("objects").withBackendDetails(new S3TableBackendDetails())); + return instance; + } + } + + + /*************************************************************************** + ** Shared native fixture starts with an operator, without administration rights. + ***************************************************************************/ + private PrintingProcessesTest.Fixture fixture() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + return fixture; + } + + + /*************************************************************************** + ** Tiny synthetic bytes never leave the in-memory queue. + ***************************************************************************/ + private JSONObject request(PrintingProcessesTest.Fixture fixture) + { + return new JSONObject().put("printerRowId", fixture.printerId()).put("requestId", "echo:hook").put("title", "Synthetic") + .put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-synthetic".getBytes(StandardCharsets.US_ASCII))); + } + + + /*************************************************************************** + ** Independent SQL proves denial created no queue, event or upload reservation. + ***************************************************************************/ + private void assertCounts(PrintingProcessesTest.Fixture fixture, int expectedJobs) throws Exception + { + try(var connection = connection(fixture)) + { + for(String table : List.of("printing_job", "printing_event", "printing_document_orphan")) + { + try(var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM " + table)) + { + rows.next(); + assertEquals("printing_job".equals(table) ? expectedJobs : 0, rows.getInt(1), table); + } + } + } + } + + + /** Inspect only the current test's synthetic database, without a native permission bypass. */ + private java.sql.Connection connection(PrintingProcessesTest.Fixture fixture) throws Exception + { + PrintingQBitConfig config = fixture.instance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance) + .map(PrintingQBitConfig.class::cast).findFirst().orElseThrow(); + RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config.getBackendName()); + return DriverManager.getConnection(backend.getJdbcUrl(), "sa", ""); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java new file mode 100644 index 0000000..48b179c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java @@ -0,0 +1,405 @@ +/* + * 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. + */ + +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.Set; +import java.util.concurrent.atomic.AtomicReference; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +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.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +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.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.session.QCredentialScope; +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.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 native row security, operator isolation and immutable queue media facts. + *******************************************************************************/ +class PrintingOperatorTest +{ + @TempDir + Path directory; + + /*************************************************************************** + ** Each fixture owns its native context. + ***************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + + /*************************************************************************** + ** Operators can submit and reconcile their own request, never manage devices. + ***************************************************************************/ + @Test + void operatorSubmitsWithoutAdminAndCannotEnumerateJobsOrOtherActors() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + assertEquals(2, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length()); + assertThrows(QException.class, () -> PrintingControlAuthorization.principal()); + QueryInput denied = new QueryInput().withTableName(PrintingTables.JOB); + denied.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + assertThrows(QException.class, () -> new QueryAction().execute(denied)); + JSONObject request = request(f.printerId(), "echo:scan-1"); + JSONObject accepted = PrintingOperatorAuthorization.submit(request); + assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.submit(request).getString("jobId")); + assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.observe("echo:scan-1").getString("jobId")); + assertTrue(PrintingOperatorAuthorization.submit(request(f.printerId(), "x".repeat(128))).has("jobId")); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "x".repeat(129)))).getStatus()); + assertEquals(409, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(new JSONObject(request.toString()).put("title", "Changed"))).getStatus()); + QContext.getQSession().setUser(new QUser().withIdReference("another-operator")); + assertEquals(404, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.observe("echo:scan-1")).getStatus()); + assertFalse(accepted.getString("jobId").equals(PrintingOperatorAuthorization.submit(request).getString("jobId"))); + QContext.getQSession().setPermissions(Set.of()); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request)); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.observe("echo:scan-1")); + } + + + /*************************************************************************** + ** Tenant and parent device locks apply to catalog, submit and replay reads. + ***************************************************************************/ + @Test + void nativeLocksAndExposureFilterCatalogAndPreventForgedDestinations() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.foreignPrinterId(), "foreign"))); + f.service().enablePrinter(f.otherDevice(), "laser", false); + assertEquals(1, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length()); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.otherPrinterId(), "disabled"))); + f.instance().addSecurityKeyType(new QSecurityKeyType().withName("operator_device")); + f.instance().getTable(PrintingTables.DEVICE).withRecordSecurityLock(new RecordSecurityLock().withFieldName("id").withSecurityKeyType("operator_device")); + QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("tenant-a"), "operator_device", List.of(f.otherDevice()))); + assertEquals(0, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length()); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "locked"))); + QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("tenant-b"))); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.catalog(50, null)); + } + + + /*************************************************************************** + ** Changed default media refuses new work while exact accepted replay survives. + ***************************************************************************/ + @Test + void mediaFactsRoundTripAndRevisionChecksPrecedeAdmission() throws Exception + { + var f = PrintingProcessesTest.fixture(); + JSONObject media = media(); + f.service().inventory(f.device(), new JSONArray().put(printer(media))); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject item = PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").toList().stream() + .map(value -> new JSONObject((Map) value)).filter(value -> value.getString("printerRowId").equals(f.printerId())).findFirst().orElseThrow(); + assertTrue(media.similar(item.getJSONArray("mediaProfiles").getJSONObject(0))); + JSONObject request = request(f.printerId(), "media").put("options", new JSONObject().put("mediaProfile", new JSONObject().put("id", "default").put("revision", "a".repeat(64)))); + JSONObject accepted = PrintingOperatorAuthorization.submit(request); + f.service().inventory(f.device(), new JSONArray().put(printer(media.put("revision", "b".repeat(64))))); + assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.submit(request).getString("jobId")); + assertEquals(409, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(new JSONObject(request.toString()).put("requestId", "stale"))).getStatus()); + assertEquals(1, f.service().queuedJobs(f.device()).length()); + assertEquals("a".repeat(64), f.service().getJob(accepted.getString("jobId")).getJSONObject("options").getJSONObject("mediaProfile").getString("revision")); + f.service().inventory(f.device(), new JSONArray().put(printer(null))); + assertEquals(0, f.service().printers(f.device()).getJSONObject(0).getJSONArray("mediaProfiles").length()); + } + + + /*************************************************************************** + ** A malformed inventory rolls back rather than retaining invented stock data. + ***************************************************************************/ + @Test + void invalidMediaAndAmbiguousDimensionsAreRejected() throws Exception + { + var f = PrintingProcessesTest.fixture(); + for(JSONObject bad : List.of(media().put("widthMicrons", 0), media().put("dpiX", 9601), media().put("revision", "A".repeat(64)), media().put("applyMode", "CUSTOM"), media().put("heightMicrons", 152400.5))) + { + assertThrows(PrintingException.class, () -> f.service().inventory(f.device(), new JSONArray().put(printer(bad)))); + } + assertThrows(PrintingException.class, () -> f.service().inventory(f.device(), new JSONArray().put(printer(media()).put("mediaProfiles", new JSONArray().put(media()).put(media()))))); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject request = request(f.printerId(), "bad").put("options", new JSONObject().put("paperWidthMm", 101.6).put("mediaProfile", new JSONObject().put("id", "default").put("revision", "a".repeat(64)))); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request)).getStatus()); + assertEquals(0, f.service().queuedJobs(f.device()).length()); + } + + + /*************************************************************************** + ** Additive facts remain compatible, but cannot broaden the stored selection. + ***************************************************************************/ + @Test + void futureReportedMetadataIsIgnoredButSelectionRemainsExact() throws Exception + { + var f = PrintingProcessesTest.fixture(); + f.service().inventory(f.device(), new JSONArray().put(printer(media().put("futureFact", new JSONObject().put("label", "future"))))); + JSONObject stored = f.service().printers(f.device()).getJSONObject(0).getJSONArray("mediaProfiles").getJSONObject(0); + assertTrue(media().similar(stored)); + assertFalse(stored.has("futureFact")); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject selected = new JSONObject().put("id", "default").put("revision", "a".repeat(64)).put("futureFact", true); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "future") + .put("options", new JSONObject().put("mediaProfile", selected)))).getStatus()); + assertEquals(0, f.service().queuedJobs(f.device()).length()); + } + + + /** Both inventory and immutable selections use the shared 128-character ID bound. */ + @Test + void mediaIdentityBoundsMatchTheSharedWireContract() throws Exception + { + var f = PrintingProcessesTest.fixture(); + String maximum = "m".repeat(128); + f.service().inventory(f.device(), new JSONArray().put(printer(media().put("id", maximum)))); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject options = new JSONObject().put("mediaProfile", new JSONObject().put("id", maximum).put("revision", "a".repeat(64))); + assertTrue(PrintingOperatorAuthorization.submit(request(f.printerId(), "maximum-media").put("options", options)).has("jobId")); + assertEquals(400, assertThrows(PrintingException.class, () -> f.service().inventory(f.device(), + new JSONArray().put(printer(media().put("id", maximum + "m"))))).getStatus()); + options.getJSONObject("mediaProfile").put("id", maximum + "m"); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "oversized-media") + .put("options", options))).getStatus()); + assertEquals(1, f.service().queuedJobs(f.device()).length()); + } + + + /*************************************************************************** + ** The native backend process uses the same caller and stable request boundary. + ***************************************************************************/ + @Test + void processUsesOperatorPermissionAndReplaysSameRequest() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + RunBackendStepInput input = new RunBackendStepInput().withProcessName(PrintingOperatorProcesses.SUBMIT); + JSONObject request = request(f.printerId(), "process"); + request.keySet().forEach(key -> input.addValue(key, request.getString(key))); + RunBackendStepOutput first = new RunBackendStepOutput(); + new PrintingOperatorProcesses().run(input, first); + new PrintingOperatorProcesses().run(input, new RunBackendStepOutput()); + assertEquals(1, f.service().queuedJobs(f.device()).length()); + assertTrue(first.getValueString("job").contains("jobId")); + } + + + /*************************************************************************** + ** A read-only credential cannot bypass the domain write ceiling via processes. + ***************************************************************************/ + @Test + void scopeAndChannelCeilingsRemainEffective() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + QContext.getQSession().setCredentialScope(new QCredentialScope().withReadOnly(true).withProcesses(Set.of(PrintingOperatorProcesses.SUBMIT))); + assertEquals(2, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length()); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "scope"))); + QContext.getQSession().setCredentialScope(null); + QContext.getQSession().setClientChannel("api/test"); + f.instance().getTable(PrintingTables.JOB).getPermissionRules().withDeniedClients(Set.of("api")); + assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "channel"))); + assertEquals(0, f.service().queuedJobs(f.device()).length()); + } + + + /*************************************************************************** + ** Bounded pages retain cursor progress and old agents report no invented stock. + ***************************************************************************/ + @Test + void catalogPagesAndLegacyProfilesRemainHonest() throws Exception + { + var f = PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + JSONObject first = PrintingOperatorAuthorization.catalog(1, null); + JSONObject last = PrintingOperatorAuthorization.catalog(1, first.getString("nextCursor")); + assertEquals(1, first.getJSONArray("printers").length()); + assertEquals(1, last.getJSONArray("printers").length()); + assertTrue(last.isNull("nextCursor")); + JSONObject row = first.getJSONArray("printers").getJSONObject(0); + assertEquals(0, row.getJSONArray("mediaProfiles").length()); + assertFalse(row.getString("name").isBlank()); + assertFalse(row.getString("deviceName").isBlank()); + assertFalse(row.getString("printerRowId").equals(last.getJSONArray("printers").getJSONObject(0).getString("printerRowId"))); + assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.catalog(201, null)).getStatus()); + } + + + /*************************************************************************** + ** Real native HTTP authenticates a non-admin and keeps mutation Origin checks. + ***************************************************************************/ + @Test + void nativeHttpOperatorBoundaryUsesStandardSessionAndReadOnlyLookup() throws Exception + { + String jdbc = "jdbc:h2:file:" + directory.resolve("operator-http").toString().replace('\\', '/'); + String password = "synthetic-operator-password-1234"; + PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", password)) + { + @Override + public QInstance defineQInstance() throws QException + { + QInstance instance = super.defineQInstance(); + instance.getAuthentication().withModuleCodeReference(new QCodeReference(OperatorAuthentication.class)); + return instance; + } + }; + PrintingService service = new PrintingService(new DriverDataSource(jdbc, "sa", ""), "demo"); + String device = service.enroll(service.createEnrollment("default", "Synthetic").put("deviceName", "Synthetic").put("agentVersion", "test")).getString("deviceId"); + service.inventory(device, new JSONArray().put(printer(media()))); + service.enablePrinter(device, "laser", true); + AtomicReference server = new AtomicReference<>(); + QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(value -> + { + host.configure(value, false); + value.withJavalinConfigurationCustomizer(server::set); + })); + String base = "http://127.0.0.1:" + server.get().port(); + String prefix = "/qqq/v1/qbit/printing"; + try(HttpClient client = HttpClient.newHttpClient()) + { + assertEquals(401, http(client, base, prefix + "/operator/printers", null, null, null).statusCode()); + 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 signedIn = client.send(login, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, signedIn.statusCode()); + String cookie = signedIn.headers().allValues("set-cookie").stream().filter(value -> value.startsWith("sessionUUID=")).findFirst().orElseThrow().split(";", 2)[0]; + HttpResponse catalog = http(client, base, prefix + "/operator/printers", cookie, null, null); + assertEquals(200, catalog.statusCode()); + String printer = new JSONObject(catalog.body()).getJSONArray("printers").getJSONObject(0).getString("printerRowId"); + assertEquals(404, http(client, base, prefix + "/operator/requests/echo:missing", cookie, null, null).statusCode()); + assertEquals(0, service.queuedJobs(device).length()); + JSONObject request = request(printer, "echo:http"); + assertEquals(403, http(client, base, prefix + "/operator/jobs", cookie, request, null).statusCode()); + assertEquals(403, http(client, base, prefix + "/operator/jobs", cookie, request, "https://foreign.invalid").statusCode()); + HttpResponse submitted = http(client, base, prefix + "/operator/jobs", cookie, request, base); + assertEquals(200, submitted.statusCode(), submitted::body); + assertEquals(200, http(client, base, prefix + "/operator/requests/echo:http", cookie, null, null).statusCode()); + assertEquals(403, http(client, base, prefix + "/session", cookie, null, null).statusCode()); + assertEquals(1, service.queuedJobs(device).length()); + } + finally + { + launcher.stop(); + } + } + + + /*************************************************************************** + ** Test-only role mapping follows the real TABLE authentication first. + ***************************************************************************/ + public static class OperatorAuthentication extends PrintingLocalAuthentication + { + @Override + public QSession createSession(QInstance instance, Map context) throws QAuthenticationException + { + QSession session = super.createSession(instance, context); + session.setPermissions(Set.of("printing_operator")); + return session; + } + } + + + /*************************************************************************** + ** Fixed test-only same-origin HTTP; no device proof or real network target. + ***************************************************************************/ + private static HttpResponse http(HttpClient client, String base, String path, String cookie, JSONObject body, String origin) throws Exception + { + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(base + path)).timeout(java.time.Duration.ofSeconds(10)); + if(cookie != null) + { + request.header("Cookie", cookie); + } + if(origin != null) + { + request.header("Origin", origin); + } + if(body != null) + { + request.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body.toString())); + } + return client.send(request.build(), HttpResponse.BodyHandlers.ofString()); + } + + + /*************************************************************************** + ** Synthetic PDF bytes are never sent to a printer in this suite. + ***************************************************************************/ + private static JSONObject request(String printer, String id) + { + return new JSONObject().put("printerRowId", printer).put("requestId", id).put("title", "Synthetic") + .put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-synthetic".getBytes(java.nio.charset.StandardCharsets.US_ASCII))); + } + + + /*************************************************************************** + ** Test facts represent one real-format queue default, not a stock mutation. + ***************************************************************************/ + private static JSONObject media() + { + return new JSONObject().put("id", "default").put("revision", "a".repeat(64)).put("name", "4 x 6") + .put("widthMicrons", 101600).put("heightMicrons", 152400).put("dpiX", 300).put("dpiY", 300) + .put("source", "WINDOWS_QUEUE_DEFAULT").put("applyMode", "DRIVER_DEFAULT"); + } + + + /*************************************************************************** + ** Existing queue identity is preserved through inventory refreshes. + ***************************************************************************/ + private static JSONObject printer(JSONObject media) + { + JSONObject printer = new JSONObject().put("printerId", "laser").put("name", "Synthetic").put("formats", new JSONArray(List.of("PDF"))); + if(media != null) + { + printer.put("mediaProfiles", new JSONArray().put(media)); + } + return printer; + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java index 7821981..193505f 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java @@ -53,6 +53,55 @@ public class PrintingRealtimeTest Path directory; + /** An old client cannot ignore media intent, including after replacing a capable connection. */ + @Test + void mediaCapabilityIsReplacedOnEveryAuthenticatedHello() throws Exception + { + String jdbc = "jdbc:h2:file:" + directory.resolve("media-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", "Media fixture").put("deviceName", "Media fixture").put("agentVersion", "test"), "operator"); + String device = enrolled.getString("deviceId"); + String proof = enrolled.getString("deviceToken"); + JSONObject media = new PrintingMediaProfile("default", "a".repeat(64), "4 x 6", 101600, 152400, 300, 300).json(); + service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue").put("formats", new JSONArray().put("ZPL")) + .put("mediaProfiles", new JSONArray().put(media)))); + service.enablePrinter(device, "queue", true); + String job = service.submit(request(device, "media").put("options", new JSONObject().put("mediaProfile", new JSONObject().put("id", "default").put("revision", "a".repeat(64))))).getString("jobId"); + try(HttpClient http = HttpClient.newHttpClient()) + { + String cookie = login(http, base); + try(PrintingSocketFixture old = PrintingSocketFixture.connect(http, base, cookie, device, proof)) + { + assertEquals(409, assertThrows(PrintingException.class, () -> service.claim(device)).getStatus()); + assertEquals("QUEUED", service.getJob(job).getString("status")); + } + try(PrintingSocketFixture capable = PrintingSocketFixture.open(http, base, cookie, device, proof)) + { + capable.initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL").put("printer-media-v1")); + assertEquals(job, service.claim(device).getJSONObject("job").getString("jobId")); + } + try(PrintingSocketFixture downgraded = PrintingSocketFixture.connect(http, base, cookie, device, proof)) + { + assertEquals(409, assertThrows(PrintingException.class, () -> service.claim(device)).getStatus()); + assertEquals("CLAIMED", service.getJob(job).getString("status")); + } + } + finally + { + launcher.stop(); + } + } + + /** 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 { diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java index f40c7f3..dae7c5b 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java @@ -67,7 +67,14 @@ final class PrintingSocketFixture implements WebSocket.Listener, AutoCloseable /** 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")) + initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL")); + } + + + /** Explicit capabilities permit downgrade/reconnect qualification without changing old clients. */ + void initialize(JSONArray capabilities) throws Exception + { + send("HELLO", "hello", new JSONObject().put("agentVersion", "synthetic-test").put("capabilities", capabilities) .put("lastAppliedRevision", 0).put("lastSettingsRevision", 0).put("limits", PrintingRealtimePolicy.defaults().toJson(0))); JSONObject welcome = take("WELCOME").getJSONObject("payload"); apply(welcome.getJSONObject("desiredState").getLong("revision"), welcome.getJSONObject("settings").getLong("revision")); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java index c31bef0..aa22fa7 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java @@ -59,9 +59,9 @@ class PrintingUiApiContractTest 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()); + assertEquals(20, model.getPaths().size()); assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/"))); - assertEquals("2.1.0", extension.getSemanticVersion()); + assertEquals("2.2.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/RetainedPrintFacadeTest.java b/src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java new file mode 100644 index 0000000..30c9ef0 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java @@ -0,0 +1,713 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-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. + */ + +package com.kingsrook.qbits.printing; + + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +/******************************************************************************* + ** Actual owner transaction, native authorization and migration; no device output. + *******************************************************************************/ +class RetainedPrintFacadeTest +{ + private final RetainedPrintFacade facade = new RetainedPrintFacade(); + private final byte[] bytes = "%PDF-1.4\nretained fixture".getBytes(StandardCharsets.US_ASCII); + private final AtomicInteger reads = new AtomicInteger(); + private byte[] readOverride; + private long declaredSize; + private String opaque = "immutable-version-1"; + private boolean observationAllowed = true; + private Runnable onObservation = () -> + { + // No default callback side effects. + }; + private Runnable onRead = () -> + { + // No default callback side effects. + }; + + /** Native row metadata and real H2 share the printing service database. */ + private PrintingProcessesTest.Fixture fixture() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + config(fixture).withRetainedSourceAuthority(new RetainedPrintSourceAuthority() + { + @Override + public RetainedPrintFacade.SourceReference resolve(String locator, String requestId) throws QException + { + if(!"source-1".equals(locator)) + { + throw new QException("Source denied"); + } + try + { + return new RetainedPrintFacade.SourceReference("test-owner", opaque, HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), + declaredSize == 0 ? bytes.length : declaredSize, "application/pdf"); + } + catch(java.security.NoSuchAlgorithmException exception) + { + throw new QException("Fixture hash failed", exception); + } + } + + @Override + public byte[] read(RetainedPrintFacade.SourceReference source, String requestId) + { + reads.incrementAndGet(); + onRead.run(); + return readOverride == null ? bytes.clone() : readOverride.clone(); + } + + @Override + public void authorizeObservation(RetainedPrintFacade.SourceReference source, String requestId) throws QException + { + if(!observationAllowed || !opaque.equals(source.opaqueReference())) + { + throw new QException("Current owner observation denied"); + } + onObservation.run(); + } + }); + return fixture; + } + + /** Resolve the production configured integration, not an invocation-supplied authority. */ + private PrintingQBitConfig config(PrintingProcessesTest.Fixture fixture) + { + return fixture.instance().getQBits().values().stream().map(bit -> bit.getConfig()).filter(PrintingQBitConfig.class::isInstance) + .map(PrintingQBitConfig.class::cast).findFirst().orElseThrow(); + } + + /** Current synthetic native administrator; no arbitrary caller actor field exists. */ + private QSession session(String actor) + { + return new QSession().withUser(new QUser().withIdReference(actor)).withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-a"); + } + + /** Same stable request in every replay. */ + private RetainedPrintFacade.Request request(PrintingProcessesTest.Fixture fixture) + { + return new RetainedPrintFacade.Request("intent-1", "source-1", fixture.printerId(), "Retained document", "{}"); + } + + /** Independent SQL connection observes actual committed storage. */ + private Connection connection(PrintingProcessesTest.Fixture fixture) throws Exception + { + RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config(fixture).getBackendName()); + return DriverManager.getConnection(backend.getJdbcUrl(), "sa", ""); + } + + /** Read only a fixed test-owned table count. */ + private int count(PrintingProcessesTest.Fixture fixture, String table) throws Exception + { + try(Connection connection = connection(fixture); var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM " + table)) + { + rows.next(); + return rows.getInt(1); + } + } + + /** Replay and lost-response lookup preserve complete binding without another byte read. */ + @Test + void acceptedReplayAndLookupReturnSameCommittedProof() throws Exception + { + var fixture = fixture(); + assertNull(facade.find(request(fixture))); + var accepted = facade.submit(request(fixture)); + assertEquals("operator", accepted.actorId()); + assertEquals("Retained document", accepted.title()); + assertEquals(1, new JSONObject(accepted.optionsJson()).getInt("copies")); + assertEquals(opaque, accepted.source().opaqueReference()); + assertEquals(accepted, facade.submit(request(fixture))); + assertEquals(accepted, facade.find(request(fixture))); + assertEquals(1, reads.get()); + assertEquals(1, count(fixture, "printing_job")); + assertEquals(1, count(fixture, "printing_event")); + assertEquals("QUEUED", fixture.service().getJob(accepted.jobId()).getString("status")); + } + + /** A committed transaction with a lost acknowledgment is recovered without a second job. */ + @Test + void commitThenThrowRecoversExactAcceptance() throws Exception + { + var fixture = fixture(); + RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config(fixture).getBackendName()); + DriverDataSource actual = new DriverDataSource(backend.getJdbcUrl(), "sa", ""); + var fail = new java.util.concurrent.atomic.AtomicBoolean(true); + javax.sql.DataSource source = (javax.sql.DataSource) java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] { javax.sql.DataSource.class }, (proxy, method, args) -> + { + try + { + Object result = method.invoke(actual, args); + if(result instanceof Connection connection) + { + return java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] { Connection.class }, (connectionProxy, operation, values) -> + { + try + { + Object returned = operation.invoke(connection, values); + if("commit".equals(operation.getName()) && fail.get()) + { + try(var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM printing_job WHERE accepted_event_id IS NOT NULL")) + { + rows.next(); + if(rows.getInt(1) == 1 && fail.compareAndSet(true, false)) + { + throw new java.sql.SQLException("Synthetic lost commit acknowledgment", "08007"); + } + } + } + return returned; + } + catch(java.lang.reflect.InvocationTargetException exception) + { + throw exception.getCause(); + } + }); + } + return result; + } + catch(java.lang.reflect.InvocationTargetException exception) + { + throw exception.getCause(); + } + }); + config(fixture).withService(new PrintingService(source, "tenant-a")); + assertThrows(QException.class, () -> facade.submit(request(fixture))); + var recovered = facade.find(request(fixture)); + assertNotNull(recovered); + assertEquals(recovered, facade.submit(request(fixture))); + assertEquals(1, reads.get()); + assertEquals(1, count(fixture, "printing_job")); + assertEquals(1, count(fixture, "printing_event")); + } + + /** Unknown owner configuration, parent denial, tenant denial and callback revocation do not enqueue. */ + @Test + void currentAuthorityIsRequiredBeforeAndAfterOwnerCallbacks() throws Exception + { + var unconfigured = PrintingProcessesTest.fixture(); + assertThrows(QException.class, () -> facade.submit(request(unconfigured))); + var fixture = fixture(); + assertThrows(QException.class, () -> facade.submit(new RetainedPrintFacade.Request("bad", "denied", fixture.printerId(), "Test", "{}"))); + assertThrows(QException.class, () -> facade.submit(new RetainedPrintFacade.Request("foreign", "source-1", fixture.foreignPrinterId(), "Test", "{}"))); + onRead = () -> QContext.getQSession().setPermissions(Set.of()); + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(0, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + } + + /** Read-only credentials and write-denied channels permit lookup, never queue mutation. */ + @Test + void nativeMutationCeilingsCannotBeBypassedByAdministratorRole() throws Exception + { + var fixture = fixture(); + var accepted = facade.submit(request(fixture)); + QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true)); + assertEquals(accepted, facade.find(request(fixture))); + assertThrows(QException.class, () -> facade.submit(request(fixture))); + QContext.getQSession().withCredentialScope(null).withClientChannel("api"); + com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData.ofOrWithNew(fixture.instance()).withDeniedWritePattern("api", PrintingTables.JOB); + assertEquals(accepted, facade.find(request(fixture))); + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(1, reads.get()); + assertEquals(1, count(fixture, "printing_job")); + } + + /** Initialization upgrades the original 300-character column; spaces and Unicode remain exact. */ + @Test + void titleAt1024CharactersIsStoredAndReplayedWithoutNormalization() throws Exception + { + var fixture = fixture(); + try(Connection connection = connection(fixture); var statement = connection.createStatement()) + { + statement.execute("ALTER TABLE printing_job ALTER COLUMN title VARCHAR(300)"); + } + fixture.service().initialize(); + String title = " " + "é".repeat(1020) + " "; + var request = new RetainedPrintFacade.Request("long-title", "source-1", fixture.printerId(), title, "{}"); + var accepted = facade.submit(request); + assertEquals(title, fixture.service().getJob(accepted.jobId()).getString("title")); + assertEquals(accepted, facade.find(request)); + assertEquals(accepted, facade.submit(request)); + assertThrows(PrintingException.class, () -> new RetainedPrintFacade.Request("long-title", "source-1", fixture.printerId(), title + "x", "{}")); + } + + /** Current destination approval is checked again after the retained owner read. */ + @Test + void printerDisabledDuringOwnerReadCannotBeAccepted() throws Exception + { + var fixture = fixture(); + onRead = () -> + { + try + { + fixture.service().enablePrinter(fixture.device(), "laser", false); + } + catch(java.sql.SQLException exception) + { + throw new IllegalStateException(exception); + } + }; + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(1, reads.get()); + assertEquals(0, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + } + + /** Stable key cannot be rebound by another actor, version, title, options or destination. */ + @Test + void changedIntentAndUnboundCollisionRefuse() throws Exception + { + var fixture = fixture(); + var accepted = facade.submit(request(fixture)); + opaque = "immutable-version-2"; + assertThrows(PrintingException.class, () -> facade.submit(request(fixture))); + opaque = "immutable-version-1"; + QContext.init(fixture.instance(), session("another-operator")); + assertThrows(PrintingException.class, () -> facade.submit(request(fixture))); + QContext.init(fixture.instance(), session("operator")); + assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("intent-1", "source-1", fixture.printerId(), "Changed", "{}"))); + assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("intent-1", "source-1", fixture.otherPrinterId(), "Retained document", "{}"))); + assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("intent-1", "source-1", fixture.printerId(), "Retained document", "{\"copies\":2}"))); + assertEquals(accepted, facade.find(request(fixture))); + JSONObject raw = new JSONObject().put("deviceId", fixture.device()).put("printerId", "laser").put("title", "Raw") + .put("format", "PDF").put("contentBase64", java.util.Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", "retained:raw-key"); + fixture.service().submit(raw); + assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("raw-key", "source-1", fixture.printerId(), "Raw", "{}"))); + raw.put("idempotencyKey", "retained:intent-1"); + assertThrows(PrintingException.class, () -> fixture.service().submit(raw)); + } + + /** A real database refusal of acceptance evidence rolls the actual job insert back. */ + @Test + void failedAcceptanceEventRollsBackJob() throws Exception + { + var fixture = fixture(); + try(Connection connection = connection(fixture); var statement = connection.createStatement()) + { + statement.execute("ALTER TABLE printing_event ADD CONSTRAINT refuse_acceptance CHECK (event_origin IS NULL OR event_origin <> 'SERVER_ACCEPTANCE')"); + } + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(0, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + assertNull(facade.find(request(fixture))); + } + + /** Actual transaction cleanup preserves the owner failure, including reused exception identity. */ + @Test + void rollbackFailureNeverMasksOwnerFailure() throws Exception + { + for(boolean sameFailure : List.of(false, true)) + { + var fixture = fixture(); + PrintingException original = new PrintingException(403, "Synthetic owner denial"); + Exception cleanup = sameFailure ? original : new java.sql.SQLException("Synthetic rollback failure"); + RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config(fixture).getBackendName()); + DriverDataSource actual = new DriverDataSource(backend.getJdbcUrl(), "sa", ""); + javax.sql.DataSource source = (javax.sql.DataSource) java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] { javax.sql.DataSource.class }, (proxy, method, args) -> + { + try + { + Object result = method.invoke(actual, args); + if(result instanceof Connection connection) + { + return java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] { Connection.class }, (connectionProxy, operation, values) -> + { + try + { + Object returned = operation.invoke(connection, values); + if("rollback".equals(operation.getName())) + { + throw cleanup; + } + return returned; + } + catch(java.lang.reflect.InvocationTargetException exception) + { + throw exception.getCause(); + } + }); + } + return result; + } + catch(java.lang.reflect.InvocationTargetException exception) + { + throw exception.getCause(); + } + }); + config(fixture).withService(new PrintingService(source, "tenant-a")); + onRead = () -> + { + throw original; + }; + org.junit.jupiter.api.Assertions.assertSame(original, assertThrows(PrintingException.class, () -> facade.submit(request(fixture)))); + assertEquals(sameFailure ? 0 : 1, original.getSuppressed().length); + if(!sameFailure) + { + org.junit.jupiter.api.Assertions.assertSame(cleanup, original.getSuppressed()[0]); + } + assertEquals(0, count(fixture, "printing_job")); + } + } + + /** Competing callers share the existing tenant admission lock and unique request identity. */ + @Test + void concurrentIdenticalRequestsShareOneJobAndAcceptance() throws Exception + { + var fixture = fixture(); + try(var executor = Executors.newFixedThreadPool(2)) + { + java.util.concurrent.Callable task = () -> + { + QContext.init(fixture.instance(), session("operator")); + try + { + return facade.submit(request(fixture)); + } + finally + { + QContext.clear(); + } + }; + var futures = executor.invokeAll(List.of(task, task), 20, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(futures.get(0).get(), futures.get(1).get()); + } + assertEquals(1, reads.get()); + assertEquals(1, count(fixture, "printing_job")); + assertEquals(1, count(fixture, "printing_event")); + } + + /** Device receipts are exact committed events; server acceptance cannot masquerade as one. */ + @Test + void deviceObservationRequiresExactEventAndCurrentDestinationAccess() throws Exception + { + var fixture = fixture(); + var accepted = facade.submit(request(fixture)); + assertThrows(PrintingException.class, () -> facade.observation("intent-1", accepted.acceptedEventId())); + fixture.service().claim(fixture.device()); + String eventId = UUID.randomUUID().toString(); + fixture.service().event(fixture.device(), accepted.jobId(), new JSONObject().put("eventId", eventId).put("status", "RECEIVED")); + var observed = facade.observation("intent-1", eventId); + assertEquals(accepted, observed.acceptance()); + assertEquals("RECEIVED", observed.state()); + assertNotNull(observed.recordedAt()); + observationAllowed = false; + assertThrows(QException.class, () -> facade.observation("intent-1", eventId)); + observationAllowed = true; + var configured = config(fixture).getRetainedSourceAuthority(); + config(fixture).withRetainedSourceAuthority(new RetainedPrintSourceAuthority() + { + @Override + public RetainedPrintFacade.SourceReference resolve(String locator, String requestId) throws QException + { + return configured.resolve(locator, requestId); + } + + @Override + public byte[] read(RetainedPrintFacade.SourceReference source, String requestId) throws QException + { + return configured.read(source, requestId); + } + }); + assertThrows(QException.class, () -> facade.observation("intent-1", eventId)); + config(fixture).withRetainedSourceAuthority(configured); + QContext.getQSession().setPermissions(Set.of()); + assertThrows(QException.class, () -> facade.observation("intent-1", eventId)); + } + + /** A least-privilege operator can accept its exact source without generic job or management access. */ + @Test + void operatorAcceptanceDoesNotGrantQueueEnumeration() throws Exception + { + var fixture = fixture(); + QContext.init(fixture.instance(), session("operator")); + QContext.getQSession().setPermissions(Set.of("printing_operator")); + var request = new RetainedPrintFacade.Request("echo:scan-1", "source-1", fixture.printerId(), "Retained document", "{}"); + var accepted = facade.submit(request); + assertEquals(accepted, facade.find(request)); + assertThrows(QException.class, () -> new PrintingAdminPermission().checkPermissionsThrowing( + new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput(), null)); + var query = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput().withTableName(PrintingTables.JOB); + query.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE); + assertThrows(QException.class, () -> new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query)); + assertEquals(1, count(fixture, "printing_job")); + } + + /** Owner callbacks cannot return evidence after narrowing the reporter's current credential or row authority. */ + @Test + void observationRechecksCurrentCeilingAndDestinationAfterOwnerCallback() throws Exception + { + for(String change : List.of("credential", "destination")) + { + var fixture = fixture(); + var accepted = facade.submit(request(fixture)); + fixture.service().claim(fixture.device()); + String eventId = UUID.randomUUID().toString(); + fixture.service().event(fixture.device(), accepted.jobId(), new JSONObject().put("eventId", eventId).put("status", "RECEIVED")); + onObservation = () -> + { + if("credential".equals(change)) + { + QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope() + .withTables(Set.of(PrintingTables.PRINTER, PrintingTables.DEVICE))); + } + else + { + try(Connection connection = connection(fixture); var statement = connection.prepareStatement("UPDATE printing_printer SET tenant_id='tenant-b' WHERE id=?")) + { + statement.setString(1, fixture.printerId()); + statement.executeUpdate(); + } + catch(Exception exception) + { + throw new IllegalStateException(exception); + } + } + }; + assertThrows(QException.class, () -> facade.observation("intent-1", eventId)); + assertEquals(1, count(fixture, "printing_job")); + assertEquals(2, count(fixture, "printing_event")); + } + } + + /** Content returned by an owner must match both the accepted hash and declared length. */ + @Test + void changedReadBytesCannotCommitAcceptance() throws Exception + { + for(boolean lengthMismatch : List.of(false, true)) + { + var fixture = fixture(); + readOverride = java.util.Arrays.copyOf(bytes, bytes.length + (lengthMismatch ? 1 : 0)); + readOverride[readOverride.length - 1] ^= 1; + if(lengthMismatch) + { + assertThrows(QException.class, () -> facade.submit(request(fixture))); + } + else + { + assertEquals(409, assertThrows(PrintingException.class, () -> facade.submit(request(fixture))).getStatus()); + } + assertEquals(0, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + } + } + + /** The current format policy rejects before reading; the obsolete fixed 20 MiB limit does not. */ + @Test + void configuredDocumentLimitGatesOwnerRead() throws Exception + { + var fixture = fixture(); + JSONObject settings = fixture.service().getRealtimeSettings(); + settings.remove("revision"); + settings.put("maxPdfBytes", bytes.length - 1); + fixture.service().saveRealtimeSettings(settings); + assertEquals(413, assertThrows(PrintingException.class, () -> facade.submit(request(fixture))).getStatus()); + assertEquals(0, reads.get()); + settings.put("maxPdfBytes", 32 * 1024 * 1024); + fixture.service().saveRealtimeSettings(settings); + declaredSize = 21 * 1024 * 1024; + // The tiny synthetic reader intentionally cannot satisfy this larger declaration. + // Reaching it proves admission uses current policy without allocating a large fixture. + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(1, reads.get()); + assertEquals(0, count(fixture, "printing_job")); + } + + /** Reconciliation is read-only and preserves the accepted intent after dispatch settings change. */ + @Test + void lookupDoesNotReadBytesOrInitializeSettingsAndAllowsHistoricalDestination() throws Exception + { + var fixture = fixture(); + try(Connection connection = connection(fixture); var statement = connection.createStatement()) + { + statement.executeUpdate("DELETE FROM printing_settings"); + } + assertNull(facade.find(request(fixture))); + assertEquals(0, count(fixture, "printing_settings")); + assertEquals(0, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + assertEquals(0, reads.get()); + var request = new RetainedPrintFacade.Request("lookup-1", "source-1", fixture.printerId(), "Retained document", "{\"copies\":2}"); + var accepted = facade.submit(request); + JSONObject settings = fixture.service().getRealtimeSettings(); + settings.remove("revision"); + settings.put("maxCopies", 1); + fixture.service().saveRealtimeSettings(settings); + fixture.service().enablePrinter(fixture.device(), "laser", false); + assertEquals(accepted, facade.find(request)); + assertEquals(1, reads.get()); + assertEquals(1, count(fixture, "printing_job")); + assertEquals(1, count(fixture, "printing_event")); + assertThrows(QException.class, () -> facade.submit(request)); + } + + /** An exact accepted media revision remains immutable when the reported queue default changes. */ + @Test + void acceptedMediaRevisionIsReconciledWithoutReinterpretingCurrentInventory() throws Exception + { + var fixture = fixture(); + var profile = new PrintingMediaProfile("default", "a".repeat(64), "4 by 6 inches", 101600, 152400, 300, 300); + JSONObject printer = new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("status", "READY") + .put("formats", new org.json.JSONArray().put("PDF")).put("mediaProfiles", new org.json.JSONArray().put(profile.json())); + fixture.service().inventory(fixture.device(), new org.json.JSONArray().put(printer)); + JSONObject options = new JSONObject().put("mediaProfile", new JSONObject().put("id", profile.id()).put("revision", profile.revision())); + var request = new RetainedPrintFacade.Request("echo:media-1", "source-1", fixture.printerId(), "Retained document", options.toString()); + var accepted = facade.submit(request); + assertEquals(profile.revision(), new JSONObject(accepted.optionsJson()).getJSONObject("mediaProfile").getString("revision")); + printer.put("mediaProfiles", new org.json.JSONArray().put(new PrintingMediaProfile("default", "b".repeat(64), "Letter", 215900, 279400, 600, 600).json())); + fixture.service().inventory(fixture.device(), new org.json.JSONArray().put(printer)); + assertEquals(accepted, facade.find(request)); + assertEquals(1, reads.get()); + var newIntent = new RetainedPrintFacade.Request("echo:media-2", "source-1", fixture.printerId(), "Retained document", options.toString()); + assertEquals(409, assertThrows(PrintingException.class, () -> facade.submit(newIntent)).getStatus()); + options.getJSONObject("mediaProfile").put("revision", "b".repeat(64)); + var changedReplay = new RetainedPrintFacade.Request("echo:media-1", "source-1", fixture.printerId(), "Retained document", options.toString()); + assertEquals(409, assertThrows(PrintingException.class, () -> facade.find(changedReplay)).getStatus()); + assertEquals(1, count(fixture, "printing_job")); + assertEquals(1, count(fixture, "printing_event")); + } + + /** A caller transaction cannot roll back an independently acknowledged queue acceptance. */ + @Test + void ambientTransactionRefusesBeforeOwnerReadOrQueueMutation() throws Exception + { + var fixture = fixture(); + try(var transaction = new com.kingsrook.qqq.backend.core.actions.QBackendTransaction()) + { + QContext.setTransaction(transaction); + try + { + assertThrows(QException.class, () -> facade.submit(request(fixture))); + assertEquals(0, reads.get()); + assertEquals(0, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + } + finally + { + QContext.setTransaction(null); + } + } + assertNotNull(facade.submit(request(fixture))); + } + + /** Additive upgrade preserves existing ordinary jobs without inventing accepted bindings. */ + @Test + void upgradePreservesOldUnboundJobs() throws Exception + { + var fixture = fixture(); + String old = PrintingProcesses.submitTest(PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()), true); + try(Connection connection = connection(fixture); var statement = connection.createStatement()) + { + statement.execute("ALTER TABLE printing_job DROP COLUMN retained_actor_id"); + statement.execute("ALTER TABLE printing_job DROP COLUMN retained_binding_json"); + statement.execute("ALTER TABLE printing_job DROP COLUMN accepted_event_id"); + statement.execute("ALTER TABLE printing_event DROP COLUMN event_origin"); + } + fixture.service().initialize(); + fixture.service().initialize(); + assertEquals(old, fixture.service().getJob(old).getString("jobId")); + assertEquals(1, count(fixture, "printing_job")); + assertEquals(0, count(fixture, "printing_event")); + assertNotNull(facade.submit(request(fixture))); + } + + /** Unresolved storage reservations cannot be reported as an absent accepted request. */ + @Test + void pendingUploadLookupRefusesFalseAbsence() throws Exception + { + var fixture = fixture(); + try(Connection connection = connection(fixture); var statement = connection.prepareStatement("INSERT INTO printing_document_orphan (id,tenant_id,storage_table,reference,binding,created_at,device_id,content_length,idempotency_key) VALUES (?,'tenant-a','fixture','ref','hash',?, ?,1,'retained:intent-1')")) + { + statement.setString(1, UUID.randomUUID().toString()); + statement.setString(2, java.time.Instant.now().toString()); + statement.setString(3, fixture.device()); + statement.executeUpdate(); + } + assertThrows(PrintingException.class, () -> facade.find(request(fixture))); + assertEquals(0, reads.get()); + } + + /** Lookup cannot report absence while another owner transaction holds admission. */ + @Test + void lookupWaitsForExistingAdmissionTransaction() throws Exception + { + var fixture = fixture(); + try(Connection connection = connection(fixture); var executor = Executors.newSingleThreadExecutor()) + { + connection.setAutoCommit(false); + PrintingAdmission.lock(connection, "tenant-a"); + var entered = new java.util.concurrent.CountDownLatch(1); + var result = executor.submit(() -> + { + QContext.init(fixture.instance(), session("operator")); + try + { + entered.countDown(); + return facade.find(request(fixture)); + } + finally + { + QContext.clear(); + } + }); + try + { + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertThrows(java.util.concurrent.TimeoutException.class, () -> result.get(150, java.util.concurrent.TimeUnit.MILLISECONDS)); + } + finally + { + connection.rollback(); + } + assertNull(result.get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + + /** Release only this test's thread-local identity. */ + @AfterEach + void clear() + { + QContext.clear(); + } +} -- 2.54.0 From 4eee28e9db8c76fad8a809da0bad74990643538c Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 13:10:23 -0600 Subject: [PATCH 2/7] ci: pin the reviewed media protocol contract Use protected protocol source a26c18c for compatible consumer verification. Fail local Windows verification early when required shared fixtures are missing instead of building a test assembly with incomplete inputs. Co-Authored-By: Codex GPT-6 Signed-off-by: James Maes --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a41bce..08c17ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: QRun-IO/qqq-print-protocol - ref: f3cbc02e9b0fb4fdfefaa59d976c04df747cc589 + ref: a26c18c90c7d6102bbe26eb50cbb90fcd1bd46e3 path: qqq-print-protocol persist-credentials: false - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 -- 2.54.0 From a514a70953cf4d3475b3fa0180ecfec6d8778767 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 1 Nov 2026 13:32:37 -0600 Subject: [PATCH 3/7] feat(printing): render to PDF for the caller; app header and footer on every page (#31) printingRenderToPdf renders a record sheet, table list, text page or content sections and delivers the PDF through core's standard process download with the caller's permissions, no printer and no queued job. Every rendered document carries an app header (app name, breadcrumb, server, user, time) and footer (client and server, filter/sort line, Page n of m) from values the client sends, with server defaults. Extension 2.2 (additive). Signed-off-by: James Maes Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 17 ++ .../qbits/printing/PrintingChrome.java | 149 +++++++++++++++ .../qbits/printing/PrintingLayout.java | 106 +++++++++-- .../qbits/printing/PrintingListProcesses.java | 145 +++++++++++---- .../qbits/printing/PrintingQBitProducer.java | 1 + .../printing/PrintingRecordDocuments.java | 12 +- .../printing/PrintingRecordProcesses.java | 22 ++- .../printing/PrintingRenderProcesses.java | 150 +++++++++++++++ .../printing/PrintingTableDocuments.java | 6 +- .../qbits/printing/PrintingTextDocuments.java | 70 ++++++- .../printing/PrintingListProcessesTest.java | 7 +- .../printing/PrintingRenderProcessesTest.java | 172 ++++++++++++++++++ 12 files changed, 790 insertions(+), 67 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingChrome.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingRenderProcesses.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingRenderProcessesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 469ae87..7dfa0af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ Printing extension **2.1.0** (additive; the agent contract is unchanged). +- The app's header and footer on every page of every document (record sheets, + table lists, text and content pages): app name, title or breadcrumb, server, + user and printed-at at the top; the client's footer (e.g. `qqq ui 1.0.1 · + server`), a list's filter and sort, and "Page n of m" at the bottom. Clients + send `headerApp`, `headerTitle`, `headerServer`, `headerUser`, `headerPrintedAt`, + `footerLeft` and `footerNote`; missing ones default to the instance branding, + the session user, the current instant and "QQQ printing". +- Content documents: `printingPrintText` and `printingRenderToPdf` (`TEXT`) take + `sections` (JSON `[{title, lines}]`), a screen's panels without navigation or + borders, rendered as headings with their lines wrapped to the page width. +- Save as PDF: `printingRenderToPdf` renders the document a print would carry - + a record sheet (`kind` `RECORD`, `tableName`, `recordId`), a table list (`LIST`, + the `printingPrintTableList` values) or a text page (`TEXT`) - and returns it to + the caller as the standard process download (`downloadFileName`, + `serverFilePath`) instead of queueing a job. No printer is needed; reads enforce + the caller's permissions, and a list over the limit says so in + `printingMessage`. Clients offer it as "Save as PDF…" in their printer pickers. - 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 diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingChrome.java b/src/main/java/com/kingsrook/qbits/printing/PrintingChrome.java new file mode 100644 index 0000000..dde6b33 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingChrome.java @@ -0,0 +1,149 @@ +/* + * 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.temporal.ChronoUnit; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; + + +/******************************************************************************* + ** The header and footer every printed page carries, whatever the document: + ** + ** · · + ** + ** ... + ** <footer note: a grid's filter and sort> + ** <footer: "qqq ui 1.0.0 · server"> Page n of m + ** + ** Clients send the fields as process values (headerApp, headerTitle, + ** headerServer, headerUser, headerPrintedAt, footerLeft, footerNote) so the + ** text matches what the user saw (their date format, the app's name, the + ** breadcrumb). Missing fields fall back to the instance's branding name, the + ** session user, the current instant and "QQQ printing". + *******************************************************************************/ +public record PrintingChrome(String app, String title, String server, String user, String printedAt, String footerLeft, String footerNote) +{ + /*************************************************************************** + ** The fields from process values, with server-side defaults. + ***************************************************************************/ + public static PrintingChrome from(RunBackendStepInput input, String fallbackTitle) + { + return (new PrintingChrome( + value(input, "headerApp", defaultApp()), + value(input, "headerTitle", fallbackTitle), + value(input, "headerServer", ""), + value(input, "headerUser", defaultUser()), + value(input, "headerPrintedAt", Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()), + value(input, "footerLeft", "QQQ printing"), + value(input, "footerNote", ""))); + } + + + + /*************************************************************************** + ** Defaults only (documents rendered outside a process). + ***************************************************************************/ + public static PrintingChrome defaults(String title) + { + return (new PrintingChrome(defaultApp(), title == null ? "" : title, "", defaultUser(), Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), "QQQ printing", "")); + } + + + + /*************************************************************************** + ** The same chrome with a footer note (a grid's filter and sort). + ***************************************************************************/ + public PrintingChrome withFooterNote(String note) + { + return (new PrintingChrome(app, title, server, user, printedAt, footerLeft, footerNote == null || footerNote.isBlank() ? (note == null ? "" : note) : footerNote)); + } + + + + /*************************************************************************** + ** The same chrome with this title when it has none. + ***************************************************************************/ + public PrintingChrome withTitleIfBlank(String fallback) + { + return (title != null && !title.isBlank() ? this : new PrintingChrome(app, fallback, server, user, printedAt, footerLeft, footerNote)); + } + + + + /*************************************************************************** + ** The right side of the first header line. + ***************************************************************************/ + String headerRight() + { + StringBuilder out = new StringBuilder(); + for(String part : new String[] {server, user, printedAt}) + { + if(part != null && !part.isBlank()) + { + out.append(out.isEmpty() ? "" : " · ").append(part); + } + } + return (out.toString()); + } + + + + /*************************************************************************** + ** A string value, or the default when absent or blank (bounded length). + ***************************************************************************/ + private static String value(RunBackendStepInput input, String name, String fallback) + { + String value = input == null ? null : input.getValueString(name); + String chosen = value == null || value.isBlank() ? fallback : value; + return (chosen == null ? "" : PrintingRecordDocuments.truncate(chosen.strip(), 300)); + } + + + + /*************************************************************************** + ** The instance's branding name, else "QQQ". + ***************************************************************************/ + private static String defaultApp() + { + try + { + var branding = QContext.getQInstance() == null ? null : QContext.getQInstance().getBranding(); + return (branding != null && branding.getAppName() != null ? branding.getAppName() : "QQQ"); + } + catch(RuntimeException exception) + { + return ("QQQ"); + } + } + + + + /*************************************************************************** + ** The session user's name, else blank. + ***************************************************************************/ + private static String defaultUser() + { + return (QContext.getQSession() == null || QContext.getQSession().getUser() == null || QContext.getQSession().getUser().getFullName() == null ? "" : QContext.getQSession().getUser().getFullName()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java b/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java index fd5723a..caea1ec 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java @@ -54,7 +54,7 @@ final class PrintingLayout implements AutoCloseable private float y; private List<String> repeatHeader = List.of(); private int repeatSize = 8; - private String footer = ""; + private PrintingChrome chrome; private float leading = 1.35f; @@ -65,6 +65,17 @@ final class PrintingLayout implements AutoCloseable ***************************************************************************/ PrintingLayout(String pageSize, boolean landscape, String title, String subtitle) throws QException { + this(pageSize, landscape, title, subtitle, null); + } + + + + /*************************************************************************** + ** With the app's header and footer on every page (defaults when null). + ***************************************************************************/ + PrintingLayout(String pageSize, boolean landscape, String title, String subtitle, PrintingChrome chrome) throws QException + { + this.chrome = chrome == null ? PrintingChrome.defaults(title) : chrome; try(InputStream stream = PrintingLayout.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE)) { if(stream == null) @@ -117,11 +128,11 @@ final class PrintingLayout implements AutoCloseable /*************************************************************************** - ** Text at the bottom left of every page (who printed it and when). + ** A note above the footer of every page (a grid's filter and sort). ***************************************************************************/ void footer(String text) { - footer = text == null ? "" : text; + chrome = chrome.withFooterNote(text); } @@ -143,7 +154,7 @@ final class PrintingLayout implements AutoCloseable void line(String text, float fontSize) throws IOException { float step = fontSize * leading; - if(y - step < MARGIN + 14) + if(y - step < bottom()) { newPage(); for(String header : repeatHeader) @@ -161,7 +172,7 @@ final class PrintingLayout implements AutoCloseable ***************************************************************************/ void keep(int lines, int fontSize) throws IOException { - if(y - lines * fontSize * 1.35f < MARGIN + 14) + if(y - lines * fontSize * leading < bottom()) { newPage(); } @@ -208,19 +219,17 @@ final class PrintingLayout implements AutoCloseable 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()) + float base = MARGIN - 14; + footer.setLineWidth(0.5f); + footer.moveTo(MARGIN, base + (hasNote() ? 20 : 10)); + footer.lineTo(size.getWidth() - MARGIN, base + (hasNote() ? 20 : 10)); + footer.stroke(); + if(hasNote()) { - 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(); + at(footer, MARGIN, base + 10, PrintingRecordDocuments.truncate(chrome.footerNote(), columns(7.5f)), 7.5f); } + at(footer, size.getWidth() - MARGIN - text.length() * 4.8f, base, text, 8); + at(footer, MARGIN, base, PrintingRecordDocuments.truncate(chrome.footerLeft(), Math.max(10, columns(8) - text.length() - 2)), 8); } } ByteArrayOutputStream output = new ByteArrayOutputStream(); @@ -273,7 +282,8 @@ final class PrintingLayout implements AutoCloseable PDPage page = new PDPage(size); document.addPage(page); content = new PDPageContentStream(document, page); - y = size.getHeight() - MARGIN; + y = size.getHeight() - MARGIN + 14; + header(); if(document.getNumberOfPages() > 1) { draw(PrintingRecordDocuments.truncate(title, columns(8)) + " (continued)", 8, 12); @@ -283,6 +293,68 @@ final class PrintingLayout implements AutoCloseable + /*************************************************************************** + ** The app's header band: the app and server/user/time, then the title or + ** breadcrumb, then a rule. + ***************************************************************************/ + private void header() throws IOException + { + String right = chrome.headerRight(); + int rightChars = Math.min(right.length(), columns(7.5f) / 2 + 10); + String rightText = PrintingRecordDocuments.truncate(right, rightChars); + y -= 9; + at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.app(), Math.max(10, columns(9) - rightText.length())), 9); + at(content, size.getWidth() - MARGIN - rightText.length() * 4.5f, y, rightText, 7.5f); + if(chrome.title() != null && !chrome.title().isBlank()) + { + y -= 10; + at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.title(), columns(7.5f)), 7.5f); + } + y -= 4; + content.setLineWidth(0.5f); + content.moveTo(MARGIN, y); + content.lineTo(size.getWidth() - MARGIN, y); + content.stroke(); + content.setLineWidth(1f); + y -= 6; + } + + + + /*************************************************************************** + ** Whether pages carry a footer note. + ***************************************************************************/ + private boolean hasNote() + { + return (chrome.footerNote() != null && !chrome.footerNote().isBlank()); + } + + + + /*************************************************************************** + ** The lowest baseline body text may use, above the footer band. + ***************************************************************************/ + private float bottom() + { + return (MARGIN + (hasNote() ? 22 : 12)); + } + + + + /*************************************************************************** + ** Text at a position on a content stream. + ***************************************************************************/ + private void at(PDPageContentStream stream, float x, float baseline, String text, float fontSize) throws IOException + { + stream.beginText(); + stream.setFont(font, fontSize); + stream.newLineAtOffset(x, baseline); + stream.showText(safe(text)); + stream.endText(); + } + + + /*************************************************************************** ** Draw one line at the current position. ***************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java index 82bd24e..358d38b 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingListProcesses.java @@ -326,6 +326,108 @@ public final class PrintingListProcesses + /*************************************************************************** + ** A rendered document: its title, the PDF, and a note (rows left out). + ***************************************************************************/ + public record Rendered(String title, byte[] bytes, String message) + { + } + + + + /*************************************************************************** + ** Render a table list (list true) or a text page from the process values. + ***************************************************************************/ + static Rendered render(RunBackendStepInput input, boolean list) throws QException + { + String pageSize = input.getValueString("pageSize") == null ? "LETTER" : input.getValueString("pageSize").toUpperCase(Locale.ROOT); + if(!list) + { + String title = input.getValueString("title") == null || input.getValueString("title").isBlank() ? "Screen" : input.getValueString("title"); + String subtitle = input.getValueString("subtitle"); + boolean landscape = "LANDSCAPE".equalsIgnoreCase(input.getValueString("orientation")); + PrintingChrome chrome = PrintingChrome.from(input, title); + String sections = input.getValueString("sections"); + if(sections != null && !sections.isBlank()) + { + return (new Rendered(title, PrintingTextDocuments.sectionsPdf(title, subtitle, sections(sections), pageSize, landscape, chrome), null)); + } + return (new Rendered(title, PrintingTextDocuments.pdf(title, subtitle, input.getValueString("text"), pageSize, landscape, chrome), null)); + } + QTableMetaData table = table(input); + List<QFieldMetaData> columns = columns(table, input.getValueString("columns")); + QQueryFilter filter = filter(input); + boolean all = "ALL".equalsIgnoreCase(input.getValueString("scope")); + List<QRecord> records = read(table, filter, all); + List<List<String>> rows = new ArrayList<>(); + for(QRecord record : records) + { + List<String> row = new ArrayList<>(); + columns.forEach(field -> row.add(PrintingRecordProcesses.text(record, field))); + rows.add(row); + } + String title = input.getValueString("title") != null ? input.getValueString("title") : table.getLabel(); + Integer total = input.getValueInteger("printingRowCount"); + if(total == null) + { + CountInput count = new CountInput(); + count.setTableName(table.getName()); + count.setFilter(filter.clone().withSkip(null).withLimit(null)); + count.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + total = new CountAction().execute(count).getCount(); + } + 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); + String note = (described != null && !described.isBlank() ? "Filter: " + described : "No filter") + (sort.isEmpty() ? "" : " - sort: " + sort); + byte[] bytes = PrintingTableDocuments.pdf(title, subtitle, note, + columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), rows, widths(input.getValueString("columnWidths")), pageSize, orientation(input), + PrintingChrome.from(input, table.getLabel() == null ? table.getName() : table.getLabel())); + String message = all && total != null && total > records.size() + ? "Printed the first " + records.size() + " of " + total + " rows: the print limit is " + filter.getLimit() + " rows. Narrow the filter to print the rest." : null; + return (new Rendered(title, bytes, message)); + } + + + + /*************************************************************************** + ** The "sections" value: a JSON array of {title, lines: [..]} (at most 500 + ** sections), as clients send a screen's content. + ***************************************************************************/ + static List<PrintingTextDocuments.Section> sections(String json) throws QException + { + try + { + org.json.JSONArray array = new org.json.JSONArray(json); + if(array.length() > 500) + { + throw new QException("Too many sections to print (at most 500)"); + } + List<PrintingTextDocuments.Section> out = new ArrayList<>(); + for(int i = 0; i < array.length(); i++) + { + org.json.JSONObject section = array.getJSONObject(i); + List<String> lines = new ArrayList<>(); + org.json.JSONArray given = section.optJSONArray("lines"); + for(int j = 0; given != null && j < given.length(); j++) + { + lines.add(given.optString(j, "")); + } + out.add(new PrintingTextDocuments.Section(section.optString("title", ""), lines)); + } + return (out); + } + catch(org.json.JSONException exception) + { + throw new QException("The sections are not valid: " + exception.getMessage(), exception); + } + } + + + /*************************************************************************** ** Request identity, and for a table list the number of rows it would print. ***************************************************************************/ @@ -378,45 +480,14 @@ public final class PrintingListProcesses { 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())) + Rendered rendered = render(input, TABLE_LIST.equals(input.getProcessName())); + if(rendered.message() != null) { - QTableMetaData table = table(input); - List<QFieldMetaData> columns = columns(table, input.getValueString("columns")); - QQueryFilter filter = filter(input); - boolean all = "ALL".equalsIgnoreCase(input.getValueString("scope")); - List<QRecord> records = read(table, filter, all); - List<List<String>> rows = new ArrayList<>(); - for(QRecord record : records) - { - List<String> 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"))); + output.addValue("printingTruncated", true); + output.addValue("printingMessage", rendered.message()); } + String title = rendered.title(); + byte[] bytes = rendered.bytes(); 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 af3f693..4f11063 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitProducer.java @@ -174,6 +174,7 @@ public class PrintingQBitProducer implements QBitProducer app.withPermissionRules(PrintingProcesses.permissionRules()); PrintingProcesses.register(instance, app); PrintingListProcesses.register(instance); + PrintingRenderProcesses.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 29b7fc6..7546b7b 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordDocuments.java @@ -86,7 +86,17 @@ public final class PrintingRecordDocuments ***************************************************************************/ public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children) throws QException { - try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle)) + return (pdf(title, subtitle, sections, children, null)); + } + + + + /*************************************************************************** + ** Render sections and child list summaries under the app's header/footer. + ***************************************************************************/ + public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children, PrintingChrome chrome) throws QException + { + try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle, chrome)) { 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)); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java index 4ed48dd..dda9ce6 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRecordProcesses.java @@ -216,9 +216,20 @@ public final class PrintingRecordProcesses /*************************************************************************** ** 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 + static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request, PrintingChrome chrome) throws QException { Serializable primaryKey = selected.getValue(table.getPrimaryKeyField()); + PrintingListProcesses.Rendered rendered = render(table, primaryKey, chrome); + return (queue(printer, rendered.title(), rendered.bytes(), copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id"))); + } + + + + /*************************************************************************** + ** The record sheet of one record (read with the caller's permissions). + ***************************************************************************/ + static PrintingListProcesses.Rendered render(QTableMetaData table, Serializable primaryKey, PrintingChrome chrome) throws QException + { GetInput get = new GetInput(); get.setPermissionEnforcement(PermissionEnforcement.ENFORCE); get.setTableName(table.getName()); @@ -232,9 +243,10 @@ public final class PrintingRecordProcesses } 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"))); + String subtitle = tableLabel + " " + primaryKey; + PrintingChrome pageChrome = chrome == null ? PrintingChrome.defaults(tableLabel + " › " + label) : chrome.withTitleIfBlank(tableLabel + " › " + label); + byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, sections(table, record), children(table, record), pageChrome); + return (new PrintingListProcesses.Rendered(tableLabel + ": " + label, bytes, null)); } @@ -489,7 +501,7 @@ public final class PrintingRecordProcesses QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE)); for(QRecord record : input.getRecords()) { - jobIds.add(submit(table, record, printer, copies, request)); + jobIds.add(submit(table, record, printer, copies, request, PrintingChrome.from(input, null))); done.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField())); output.getRecords().add(record); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRenderProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRenderProcesses.java new file mode 100644 index 0000000..40d9a8d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRenderProcesses.java @@ -0,0 +1,150 @@ +/* + * 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.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Locale; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.processes.ProcessFileDownload; +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.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; + + +/******************************************************************************* + ** printingRenderToPdf: render the same document a print job would carry - a + ** record sheet, a table list or a text page - and hand it to the caller as a + ** download instead of queueing it on a printer ("Save as PDF"). + ** + ** Values: kind RECORD (tableName, recordId), LIST (the printingPrintTableList + ** values: tableName, columns, columnWidths, filterJSON, scope, maxRows, + ** pageSize, orientation, title, filterDescription) or TEXT (title, subtitle, + ** text, orientation, pageSize). The result carries downloadFileName and + ** serverFilePath for GET /download/{file} (the standard process download), + ** and printingMessage when a list left rows out. No printer is needed; reads + ** enforce the caller's table and record permissions. + *******************************************************************************/ +public final class PrintingRenderProcesses +{ + public static final String RENDER = "printingRenderToPdf"; + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private PrintingRenderProcesses() + { + } + + + + /*************************************************************************** + ** Register the process. + ***************************************************************************/ + public static void register(QInstance instance) + { + instance.addProcess(new QProcessMetaData().withName(RENDER).withLabel("Save as PDF").withPermissionRules(PrintingProcesses.permissionRules()) + .withStep(new QBackendStepMetaData().withName("render").withCode(new QCodeReference(Render.class))) + .withStep(new QFrontendStepMetaData().withName("download").withLabel("Download") + .withViewFields(List.of(new QFieldMetaData("downloadFileName", QFieldType.STRING).withLabel("File"))) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.DOWNLOAD_FORM)))); + } + + + + /*************************************************************************** + ** The download name: the document title made file-safe, with .pdf. + ***************************************************************************/ + static String fileName(String title) + { + String stem = (title == null ? "" : title).replaceAll("[^A-Za-z0-9._-]+", "-").replaceAll("^[-.]+|-+$", ""); + return ((stem.isEmpty() ? "document" : PrintingRecordDocuments.truncate(stem, 80).replace("~", "")) + ".pdf"); + } + + + + /*************************************************************************** + ** Render and register the file for download. + ***************************************************************************/ + public static class Render implements BackendStep + { + /*************************************************************************** + ** One PDF, delivered through the standard process download. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new PrintingAdminPermission().checkPermissionsThrowing(input, null); + PrintingProcesses.currentService(); + String kind = input.getValueString("kind") == null ? "" : input.getValueString("kind").toUpperCase(Locale.ROOT); + PrintingListProcesses.Rendered rendered = switch(kind) + { + case "RECORD" -> + { + QTableMetaData table = PrintingListProcesses.table(input); + String id = input.getValueString("recordId"); + if(id == null || id.isBlank()) + { + throw new QException("Give the record to save (recordId)"); + } + yield PrintingRecordProcesses.render(table, com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), id), PrintingChrome.from(input, null)); + } + case "LIST" -> PrintingListProcesses.render(input, true); + case "TEXT" -> PrintingListProcesses.render(input, false); + default -> throw new QException("kind must be RECORD, LIST or TEXT"); + }; + try + { + File file = Files.createTempFile("qqq-print-", ".pdf").toFile(); + file.deleteOnExit(); + Files.write(file.toPath(), rendered.bytes()); + output.addValue("downloadFileName", fileName(rendered.title())); + output.addValue("serverFilePath", ProcessFileDownload.register(file)); + output.addValue("printingTitle", rendered.title()); + output.addValue("printingBytes", rendered.bytes().length); + if(rendered.message() != null) + { + output.addValue("printingTruncated", true); + output.addValue("printingMessage", rendered.message().replace("Printed the first", "Saved the first").replace("the print limit", "the limit").replace("to print the rest", "to save the rest")); + } + } + catch(IOException exception) + { + throw new QException("Could not write the PDF for download", exception); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java index 2a45b56..a276182 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTableDocuments.java @@ -51,7 +51,7 @@ public final class PrintingTableDocuments ***************************************************************************/ public static byte[] pdf(String title, String subtitle, List<String> columns, List<List<String>> rows, String pageSize, boolean landscape) throws QException { - return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT")); + return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT", null)); } @@ -63,7 +63,7 @@ public final class PrintingTableDocuments ** 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<String> columns, List<List<String>> rows, int[] preferredWidths, String pageSize, String orientation) throws QException + public static byte[] pdf(String title, String subtitle, String footer, List<String> columns, List<List<String>> rows, int[] preferredWidths, String pageSize, String orientation, PrintingChrome chrome) throws QException { if(columns.isEmpty()) { @@ -78,7 +78,7 @@ public final class PrintingTableDocuments } } boolean landscape = isLandscape(orientation, natural, pageSize); - try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle)) + try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome)) { layout.footer(footer); int[] widths = PrintingLayout.fit(natural, layout.columns(FONT_SIZE)); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java b/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java index 5e2f83a..fdf6f58 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingTextDocuments.java @@ -50,6 +50,16 @@ public final class 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 + { + return (pdf(title, subtitle, text, pageSize, landscape, null)); + } + + + + /*************************************************************************** + ** Render text under the app's header/footer. + ***************************************************************************/ + public static byte[] pdf(String title, String subtitle, String text, String pageSize, boolean landscape, PrintingChrome chrome) throws QException { if(text == null || text.isEmpty()) { @@ -59,7 +69,7 @@ public final class PrintingTextDocuments { throw new QException("The text is too long to print (at most " + MAX_CHARACTERS + " characters)"); } - try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle)) + try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome)) { String[] lines = text.replace("\r\n", "\n").replace('\r', '\n').replace("\t", " ").split("\n", -1); float size = fontSize(lines, layout.columns(1) * 1f); @@ -99,4 +109,62 @@ public final class PrintingTextDocuments float fits = charactersAtOnePoint / widest; return (Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2))); } + + + + /*************************************************************************** + ** One section of a content document: a title (blank for none) and its + ** lines, as the client read them from a screen panel. + ***************************************************************************/ + public record Section(String title, java.util.List<String> lines) + { + } + + + + /*************************************************************************** + ** A screen's content as a document: each section's title as a heading, + ** its lines in the monospace face, long lines wrapped at spaces to the page + ** width (no borders, no navigation). + ***************************************************************************/ + public static byte[] sectionsPdf(String title, String subtitle, java.util.List<Section> sections, String pageSize, boolean landscape, PrintingChrome chrome) throws QException + { + if(sections.isEmpty() || sections.stream().allMatch(section -> section.lines().stream().allMatch(String::isBlank) && (section.title() == null || section.title().isBlank()))) + { + throw new QException("There is no content to print"); + } + int total = sections.stream().mapToInt(section -> section.lines().stream().mapToInt(String::length).sum()).sum(); + if(total > MAX_CHARACTERS) + { + throw new QException("The content is too long to print (at most " + MAX_CHARACTERS + " characters)"); + } + try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome)) + { + int width = layout.columns(FONT_SIZE); + boolean first = true; + for(Section section : sections) + { + if(section.title() != null && !section.title().isBlank()) + { + layout.gap(first ? 0 : 8); + layout.keep(3, FONT_SIZE); + layout.line(section.title(), 11); + layout.gap(2); + } + first = false; + for(String line : section.lines()) + { + for(String wrapped : PrintingRecordDocuments.wrap(line.stripTrailing(), width)) + { + layout.line(wrapped, FONT_SIZE); + } + } + } + return (layout.finish()); + } + catch(IOException exception) + { + throw new QException("Could not render the content: " + exception.getMessage(), exception); + } + } } diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java index c716a82..9fe2bca 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingListProcessesTest.java @@ -138,7 +138,8 @@ class PrintingListProcessesTest 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("No filter - sort: Last name"), "the footer note carries the filter and sort"); + assertTrue(text.contains("QQQ printing"), "the default footer"); assertTrue(text.contains("2 rows"), text); assertTrue(fixture.service().jobs().getJSONObject(0).getString("title").startsWith("Person")); } @@ -283,7 +284,7 @@ class PrintingListProcessesTest /*************************************************************************** ** Run a process straight through with values. ***************************************************************************/ - private static RunProcessOutput run(String process, Map<String, Serializable> values) throws QException + static RunProcessOutput run(String process, Map<String, Serializable> 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 @@ -318,7 +319,7 @@ class PrintingListProcessesTest ** 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 + static PrintingProcessesTest.Fixture fixture() throws Exception { PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture(); QInstance instance = fixture.instance(); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRenderProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRenderProcessesTest.java new file mode 100644 index 0000000..e013ca1 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRenderProcessesTest.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.io.InputStream; +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.processes.ProcessFileDownload; +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.RunProcessOutput; +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.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; + + +/******************************************************************************* + ** Save as PDF: the record sheet, table list and text page rendered for the + ** caller as a process download, with no printer and no print job. + *******************************************************************************/ +class PrintingRenderProcessesTest +{ + /*************************************************************************** + ** Clear the memory store and context. + ***************************************************************************/ + @AfterEach + void clear() + { + MemoryRecordStore.getInstance().reset(); + QContext.clear(); + } + + + + /*************************************************************************** + ** A record sheet comes back as a download named after the record. + ***************************************************************************/ + @Test + void recordSheetIsADownloadNotAJob() throws Exception + { + PrintingProcessesTest.Fixture fixture = PrintingListProcessesTest.fixture(); + RunProcessOutput output = PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "RECORD", "tableName", "person", "recordId", "1")); + assertEquals("Person-Ada-Lovelace.pdf", output.getValues().get("downloadFileName")); + String text = text(output); + assertTrue(text.contains("Person: Ada Lovelace") && text.contains("Pets (2)"), text); + assertEquals(0, fixture.service().jobs().length(), "saving does not queue a print job"); + } + + + + /*************************************************************************** + ** A table list and a text page render the same way their prints do. + ***************************************************************************/ + @Test + void tableListAndTextPage() throws Exception + { + PrintingListProcessesTest.fixture(); + RunProcessOutput list = PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "LIST", "tableName", "person", "columns", "lastName,firstName", "scope", "ALL", + "filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"lastName\",\"isAscending\":true}]}")); + assertEquals("Person.pdf", list.getValues().get("downloadFileName")); + String rows = text(list); + assertTrue(rows.contains("all 2 rows of 2") || rows.contains("all 2 rows"), rows); + assertTrue(rows.indexOf("Hopper") < rows.indexOf("Lovelace"), rows); + RunProcessOutput page = PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home / Screen", "text", "| hello |")); + assertEquals("Home-Screen.pdf", page.getValues().get("downloadFileName")); + assertTrue(text(page).contains("| hello |")); + } + + + + /*************************************************************************** + ** Every document carries the app's header and footer from the client's + ** values: app, breadcrumb, server, user, printed-at; footer and page n of m; + ** a list adds its filter and sort above the footer. + ***************************************************************************/ + @Test + void headerAndFooterOnEveryDocument() throws Exception + { + PrintingListProcessesTest.fixture(); + Map<String, java.io.Serializable> chrome = Map.of("headerApp", "QQQ All", "headerTitle", "Sales › Customer › Ada Lovelace", "headerServer", "local (localhost:8078)", + "headerUser", "Admin", "headerPrintedAt", "2026-11-01 12:03", "footerLeft", "qqq ui 1.0.1 · local"); + for(Map<String, java.io.Serializable> values : java.util.List.<Map<String, java.io.Serializable>>of( + Map.of("kind", "RECORD", "tableName", "person", "recordId", "1"), + Map.of("kind", "LIST", "tableName", "person", "filterDescription", "lastName = Hopper", "filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}]}"), + Map.of("kind", "TEXT", "title", "Home", "text", "hello"))) + { + java.util.HashMap<String, java.io.Serializable> all = new java.util.HashMap<>(values); + all.putAll(chrome); + String text = text(PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, all)); + for(String expected : java.util.List.of("QQQ All", "Sales › Customer › Ada Lovelace", "local (localhost:8078) · Admin · 2026-11-01 12:03", "qqq ui 1.0.1 · local", "Page 1 of 1")) + { + assertTrue(text.contains(expected), values.get("kind") + " lacks " + expected + ":\n" + text); + } + if("LIST".equals(values.get("kind"))) + { + assertTrue(text.contains("Filter: lastName = Hopper - sort: Id descending"), text); + } + } + String defaults = text(PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "RECORD", "tableName", "person", "recordId", "2"))); + assertTrue(defaults.contains("Person › Grace Hopper") && defaults.contains("QQQ printing"), defaults); + } + + + + /*************************************************************************** + ** Content documents: each section's title as a heading and its lines, + ** long lines wrapped, no borders. + ***************************************************************************/ + @Test + void contentSectionsDocument() throws Exception + { + PrintingListProcessesTest.fixture(); + String sections = "[{\"title\":\"\",\"lines\":[\"Good morning, Admin\"]},{\"title\":\"Recently viewed\",\"lines\":[\"Ada Lovelace Customer · 1m ago\"]},{\"title\":\"Quick actions\",\"lines\":[\"" + "word ".repeat(60) + "\"]}]"; + String text = text(PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home", "sections", sections))); + assertTrue(text.contains("Good morning, Admin") && text.contains("Recently viewed") && text.contains("Quick actions"), text); + assertTrue(text.indexOf("Recently viewed") < text.indexOf("Ada Lovelace"), text); + assertTrue(!text.contains("│") && !text.contains("┌"), text); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home", "sections", "[]"))); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home", "sections", "{nope"))); + } + + + + /*************************************************************************** + ** Unknown kinds, missing records and opted-out tables are refused. + ***************************************************************************/ + @Test + void refusals() throws Exception + { + PrintingListProcessesTest.fixture(); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "NOPE"))); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "RECORD", "tableName", "person", "recordId", "99"))); + assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "LIST", "tableName", "secretThing"))); + assertEquals("document.pdf", PrintingRenderProcesses.fileName("///")); + } + + + + /*************************************************************************** + ** The PDF text of a render's download. + ***************************************************************************/ + private static String text(RunProcessOutput output) throws Exception + { + try(InputStream stream = ProcessFileDownload.open(String.valueOf(output.getValues().get("serverFilePath"))); PDDocument document = PDDocument.load(stream)) + { + return (new PDFTextStripper().getText(document)); + } + } +} -- 2.54.0 From 5f022541b14c272d78c525b20265bfebc6da483e Mon Sep 17 00:00:00 2001 From: James Maes <james@kof22.com> Date: Sun, 1 Nov 2026 13:39:11 -0600 Subject: [PATCH 4/7] chore(api): printing extension 2.2.0 (#32) Declares extension 2.2.0 for the additive features merged in #31 (printingRenderToPdf, header/footer values, content sections): PrintingUiApi, the regenerated OpenAPI document, the contract test, CHANGELOG and README. Clients detect features by process presence, not by version. Signed-off-by: James Maes <jmaes@greatergoods.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- CHANGELOG.md | 6 +++++- README.md | 2 +- .../java/com/kingsrook/qbits/printing/PrintingUiApi.java | 6 +++--- src/main/resources/openapi/qqq-v1/printing.yaml | 5 +++-- .../kingsrook/qbits/printing/PrintingUiApiContractTest.java | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfa0af..0b5dfec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ ## Unreleased (0.4.0-SNAPSHOT) -Printing extension **2.1.0** (additive; the agent contract is unchanged). +Printing extension **2.2.0** (additive; the agent contract is unchanged). 2.1.0 added +the record, table-list and text print processes; 2.2.0 adds `printingRenderToPdf`, +the header and footer values on every document and content sections. Clients +should offer a feature when its process is in the metadata, not by comparing +versions. - The app's header and footer on every page of every document (record sheets, table lists, text and content pages): app name, title or breadcrumb, server, diff --git a/README.md b/README.md index db3e486..e72ba0a 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ 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`. +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`. `printingRenderToPdf` (extension 2.2.0) renders the same documents to a PDF download for the caller instead ("Save as PDF"), and every document carries the app header and footer the client sends (`headerApp`, `headerTitle`, `headerServer`, `headerUser`, `headerPrintedAt`, `footerLeft`, `footerNote`). 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. diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApi.java index a5ed708..370f5ba 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; 2.1.0 adds the record, table-list and text print processes. */ + /** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections. */ public static MiddlewareSpecExtension extension() { - 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.") + return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.2.0") + .withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer.") .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 33ce14d..673986a 100644 --- a/src/main/resources/openapi/qqq-v1/printing.yaml +++ b/src/main/resources/openapi/qqq-v1/printing.yaml @@ -605,9 +605,10 @@ info: contact: email: "contact@kingsrook.com" description: "Native current-user printing with secondary device proof, device-control-v1\ - \ and realtime-v1; record, table-list and text printing processes." + \ and realtime-v1; record, table-list and text printing processes; render to PDF\ + \ with the app header and footer." title: "QQQ Middleware API extension: printing" - version: "2.1.0" + version: "2.2.0" openapi: "3.0.3" paths: /qqq/v1/qbit/printing/session: diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiContractTest.java index c31bef0..af138e9 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.1.0", extension.getSemanticVersion()); + assertEquals("2.2.0", extension.getSemanticVersion()); assertTrue(generated.contains("X-QQQ-Device-Token")); assertTrue(generated.contains("application/octet-stream")); assertTrue(generated.contains("principalId")); -- 2.54.0 From f80498513e0e5ded0b6db6e902b5478075707758 Mon Sep 17 00:00:00 2001 From: James Maes <james@kof22.com> Date: Sun, 1 Nov 2026 14:47:49 -0600 Subject: [PATCH 5/7] chore(scripts): enforce locked lint and type quality gates Add strict Python and checked JavaScript tooling, explicit source-scope checks and real rejection probes to CI. Preserve existing runtime behavior and document the limited bundle and test portability changes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Codex GPT-6 <noreply@openai.com> Signed-off-by: James Maes <james@kof22.com> --- .github/pyrightconfig.json | 15 ++ .github/scripts/check-codeql-sarif.py | 138 +++++++++---- .github/scripts/check-maven-inventory.py | 247 ++++++++++++++--------- .github/scripts/check-script-quality.py | 104 ++++++++++ .github/scripts/check-source-headers.py | 36 ++-- .github/scripts/test-maven-inventory.py | 164 ++++++++------- .github/scripts/test-scan-gates.py | 137 +++++++------ .github/scripts/test-script-quality.py | 228 +++++++++++++++++++++ .github/scripts/test-source-headers.py | 38 ++-- .github/workflows/ci.yml | 11 + .gitignore | 5 + .python-version | 1 + CONTRIBUTING.md | 11 + docs/TODO.md | 5 + pyproject.toml | 13 ++ ruff.toml | 56 +++++ uv.lock | 78 +++++++ 17 files changed, 971 insertions(+), 316 deletions(-) create mode 100644 .github/pyrightconfig.json create mode 100644 .github/scripts/check-script-quality.py create mode 100644 .github/scripts/test-script-quality.py create mode 100644 .python-version create mode 100644 pyproject.toml create mode 100644 ruff.toml create mode 100644 uv.lock diff --git a/.github/pyrightconfig.json b/.github/pyrightconfig.json new file mode 100644 index 0000000..6ceb2c0 --- /dev/null +++ b/.github/pyrightconfig.json @@ -0,0 +1,15 @@ +{ + "typeCheckingMode": "strict", + "pythonVersion": "3.13", + "include": [ + "scripts/*.py" + ], + "reportMissingTypeStubs": "error", + "reportUnnecessaryTypeIgnoreComment": "error", + "reportImplicitOverride": "error", + "exclude": [ + "**/__pycache__" + ], + "venvPath": "..", + "venv": ".venv" +} diff --git a/.github/scripts/check-codeql-sarif.py b/.github/scripts/check-codeql-sarif.py index 8705490..7177466 100644 --- a/.github/scripts/check-codeql-sarif.py +++ b/.github/scripts/check-codeql-sarif.py @@ -2,55 +2,107 @@ # SPDX-License-Identifier: Apache-2.0 """Reject missing/malformed CodeQL evidence before enforcing the severity gate.""" + import json import math import os from pathlib import Path +from typing import TypedDict + +RuleProperties = TypedDict("RuleProperties", {"security-severity": str | float | None}, total=False) -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) +class Rule(TypedDict): + """Keep the rule identity and optional severity metadata used by the gate.""" + + id: str + properties: RuleProperties + + +class Component(TypedDict, total=False): + """Describe CodeQL driver or extension rule metadata.""" + + name: str + rules: list[Rule] + + +class Tool(TypedDict, total=False): + """Group the driver and optional extension rule catalogs.""" + + driver: Component + extensions: list[Component] + + +class Result(TypedDict, total=False): + """Describe only the result fields consumed by the severity gate.""" + + ruleId: str + rule: dict[str, str] + suppressions: list[object] + + +class Run(TypedDict, total=False): + """Retain invocation success and findings for one analyzed language.""" + + tool: Tool + results: list[Result] + invocations: list[dict[str, bool]] + + +class Report(TypedDict, total=False): + """Represent SARIF fields while retaining the explicit runtime validation.""" + + version: str + runs: list[Run] + + +MAX_SEVERITY = 10 + + +def check(directory: str | Path, language: str, threshold: float) -> bool: # noqa: PLR0912 -- Keep the ordered evidence checks together; none may be skipped. + """Validate complete CodeQL evidence and report findings at the threshold.""" + if not math.isfinite(threshold) or not 0 <= threshold <= MAX_SEVERITY: + 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: list[tuple[float, str]] = [] + for path in files: + report: 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: dict[str, Rule] = {} + 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 <= MAX_SEVERITY: + 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) + raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0) diff --git a/.github/scripts/check-maven-inventory.py b/.github/scripts/check-maven-inventory.py index ffaac49..616f308 100644 --- a/.github/scripts/check-maven-inventory.py +++ b/.github/scripts/check-maven-inventory.py @@ -2,127 +2,172 @@ # SPDX-License-Identifier: Apache-2.0 """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 collections import Counter +from pathlib import Path +from typing import TypedDict, cast from urllib.parse import unquote, urlsplit +class Bom(TypedDict, total=False): + """Describe the selected CycloneDX coordinate fields.""" + + bomFormat: str + components: list[dict[str, str]] + metadata: dict[str, dict[str, str]] + + +class TrivyReport(TypedDict, total=False): + """Describe package coordinates, leaving runtime shape checks intact.""" + + Results: list[dict[str, list[dict[str, str]]]] + + +class OsvReport(TypedDict, total=False): + """Keep result bodies untrusted until their explicit validation.""" + + results: list[object] + + +class OsvResult(TypedDict, total=False): + """Describe source identity and packages after checking the object shape.""" + + source: dict[str, str] + packages: list[object] + + +class OsvEntry(TypedDict, total=False): + """Describe the coordinate and optional vulnerability array being checked.""" + + package: dict[str, str] + vulnerabilities: object + + REQUIRED = { - "com.kingsrook.qqq:qqq-frontend-next", - "com.kingsrook.qqq:qqq-backend-core", - "com.kingsrook.qqq:qqq-backend-module-filesystem", - "com.google.guava:guava", - "com.h2database:h2", + "com.kingsrook.qqq:qqq-frontend-next", + "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() -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") - # 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 +def resolved(text: str) -> set[tuple[str, str]]: + """Read Maven's selected runtime JARs and enforce the reviewed H2 pin.""" + packages: set[tuple[str, str]] = set() + for raw_line in text.splitlines(): + line = re.sub(r"\x1b\[[0-9;]*m", "", raw_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") + # 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 -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 components(report: object) -> set[tuple[str, str]]: + """Extract exact Maven coordinates from the scanner's CycloneDX inventory.""" + typed_report = cast(Bom, report) + if not isinstance(report, dict) or typed_report.get("bomFormat") != "CycloneDX": + raise ValueError("Expected CycloneDX inventory") + packages: set[tuple[str, str]] = set() + for component in typed_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(expected: set[tuple[str, str]], bom: object, trivy: object = OMITTED) -> int: + """Require exact SBOM coverage and, when supplied, selected Trivy coverage.""" + 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: + typed_trivy = cast(TrivyReport, trivy) + if not isinstance(trivy, dict) or not isinstance(typed_trivy.get("Results"), list): + raise ValueError("Expected Trivy report object with Results array") + scanned = {(p["Name"], p["Version"]) for r in typed_trivy.get("Results", []) for p in r.get("Packages", [])} + # Trivy also reports the root project represented by SBOM metadata. + root = cast(Bom, 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 check_osv(expected: set[tuple[str, str]], report: object) -> int: + """Reject missing, ambiguous, malformed or partial OSV package evidence.""" + typed_report = cast(OsvReport, report) + if not isinstance(report, dict) or not isinstance(typed_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[tuple[str, str]] = Counter() + for raw_result in cast(list[object], typed_report.get("results")): + result = cast(OsvResult, raw_result) + if not isinstance(raw_result, dict) or not isinstance(result.get("source"), dict): + raise ValueError("Missing OSV source") + source = cast(dict[str, str], result.get("source")) + if source.get("type") != "sbom" or not isinstance(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 raw_entry in cast(list[object], result.get("packages")): + entry = cast(OsvEntry, raw_entry) + if not isinstance(raw_entry, dict) or not isinstance(entry.get("package"), dict): + raise ValueError("Malformed OSV package") + package = cast(dict[str, str], entry.get("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.") +def main() -> None: + """Validate the exact evidence files supplied by the required CI job.""" + 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() + main() diff --git a/.github/scripts/check-script-quality.py b/.github/scripts/check-script-quality.py new file mode 100644 index 0000000..0c9549b --- /dev/null +++ b/.github/scripts/check-script-quality.py @@ -0,0 +1,104 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools.""" + +import json +import subprocess +import sys +from pathlib import Path, PurePosixPath +from typing import Any + +PYTHON_SUFFIXES = (".py", ".pyi", ".pyw") +SCRIPT_PATTERN = ".github/scripts/*.py" +PYRIGHT_CONFIG = ".github" +JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx") + + +def tracked_files(root: Path) -> list[str]: + """List tracked and not-yet-ignored new paths so no source file can silently bypass the gates.""" + # Fixed Git inventory operation; the root is an argument, never shell input. + output = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], check=True, capture_output=True).stdout.decode("utf-8") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command. + return [name for name in output.split("\u0000") if name] + + +def python_scope(tracked: list[str]) -> list[str]: + """Return the tracked Python sources, rejecting any outside the one checked script root.""" + candidates = sorted(name for name in tracked if name.endswith(PYTHON_SUFFIXES)) + outside = [name for name in candidates if not PurePosixPath(name).full_match(SCRIPT_PATTERN)] + if outside: + raise ValueError("Tracked Python outside the checked script root: " + ", ".join(outside)) + if not candidates: + raise ValueError("No owned Python scripts selected") + return candidates + + +def javascript_scope(root: Path, tracked: list[str]) -> list[str]: + """Return tracked JavaScript/TypeScript sources, rejecting any outside the tsconfig include list.""" + candidates = sorted(name for name in tracked if name.endswith(JAVASCRIPT_SUFFIXES)) + tsconfig = root / "tsconfig.json" + if not tsconfig.is_file(): + if candidates: + raise ValueError("Tracked JavaScript/TypeScript without a tsconfig.json: " + ", ".join(candidates)) + return [] + includes: list[str] = json.loads(tsconfig.read_text(encoding="utf-8"))["include"] + outside = [name for name in candidates if not any(PurePosixPath(name).full_match(pattern) for pattern in includes)] + if outside: + raise ValueError("Tracked JavaScript/TypeScript outside the tsconfig include list: " + ", ".join(outside)) + if not candidates: + raise ValueError("No owned JavaScript sources selected") + return candidates + + +def run_pyright(root: Path, config: str) -> dict[str, Any]: + """Run strict Pyright with JSON output and return its full report.""" + # Fixed interpreter module invocation; the config is an argument, never shell input. + completed = subprocess.run([sys.executable, "-m", "pyright", "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed interpreter and module; no shell. + try: + report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :]) + int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing. + report["exitCode"] = completed.returncode + except (ValueError, KeyError) as error: + raise ValueError(f"Pyright produced no JSON summary (exit {completed.returncode}): {completed.stdout}{completed.stderr}") from error + return report + + +def check_pyright_report(report: dict[str, Any], expected: int) -> None: + """Require the analyzed-file count to match the selected sources, with no errors or warnings.""" + summary = report["summary"] + analyzed = summary["filesAnalyzed"] + if analyzed == 0 or analyzed != expected: + raise ValueError(f"Pyright analyzed {analyzed} files, expected {expected}") + if summary["errorCount"] or summary["warningCount"]: + details = [f"{item['file']}: {item['message']}" for item in report["generalDiagnostics"]] + raise ValueError("Pyright reported findings:\n" + "\n".join(details)) + if report["exitCode"] != 0: + raise ValueError(f"Pyright exited {report['exitCode']} despite a clean summary") + + +def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None: + """Run Ruff on the explicit source list so no selected file can be skipped.""" + # Fixed interpreter module invocation; paths are arguments, never shell input. + if subprocess.run([sys.executable, "-m", "ruff", *arguments, "--no-cache", *sources], cwd=root, check=False).returncode != 0: # noqa: S603 -- Fixed interpreter and module; no shell. + raise ValueError("Ruff " + " ".join(arguments) + " failed") + + +def main() -> None: + """Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count.""" + root = Path(__file__).resolve().parents[2] + tracked = tracked_files(root) + sources = python_scope(tracked) + javascript = javascript_scope(root, tracked) + run_ruff(root, ["format", "--check"], sources) + run_ruff(root, ["check"], sources) + report = run_pyright(root, PYRIGHT_CONFIG) + check_pyright_report(report, len(sources)) + print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope") + + +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/check-source-headers.py b/.github/scripts/check-source-headers.py index 2392c7e..e98fd8f 100644 --- a/.github/scripts/check-source-headers.py +++ b/.github/scripts/check-source-headers.py @@ -19,14 +19,14 @@ def selected(name: str, policy: dict[str, Any]) -> bool: return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"]) -def leading_comments(text: str, style: str) -> str: +def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch. """Read comments only, preserving required interpreter/XML directive ordering.""" text = text.removeprefix("\ufeff") if text.startswith("<?xml"): declaration = re.match(r"<\?xml[^?]*\?>", text) if style != "xml" or declaration is None: raise ValueError("Invalid XML declaration") - text = text[declaration.end():] + text = text[declaration.end() :] elif text.startswith("#!"): if style not in ("hash", "powershell", "slash"): raise ValueError("Unexpected interpreter directive") @@ -35,7 +35,7 @@ def leading_comments(text: str, style: str) -> str: if style != "powershell": raise ValueError("Unexpected PowerShell directive") text = text.partition("\n")[2] - comments = [] + comments: list[str] = [] while text.strip(): text = text.lstrip() if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE): @@ -46,11 +46,9 @@ def leading_comments(text: str, style: str) -> str: elif style == "slash" and text.startswith("//"): line, _, text = text.partition("\n") comments.append(line[2:]) - elif ((style == "slash" and text.startswith("/*")) - or (style == "xml" and text.startswith("<!--")) - or (style == "powershell" and text.startswith("<#"))): + elif (style == "slash" and text.startswith("/*")) or (style == "xml" and text.startswith("<!--")) or (style == "powershell" and text.startswith("<#")): opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style] - body, separator, rest = text[len(opening):].partition(closing) + body, separator, rest = text[len(opening) :].partition(closing) if not separator: raise ValueError("Unterminated leading comment") comments.append(body) @@ -72,19 +70,22 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None: 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\)\.)?$" + copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\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.", - )) + 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): @@ -94,8 +95,9 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None: 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 = [] + # Fixed Git inventory operation; paths are arguments, never shell input. + tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command. + failures: list[str] = [] count = 0 for name in tracked: if not name or not selected(name, policy): diff --git a/.github/scripts/test-maven-inventory.py b/.github/scripts/test-maven-inventory.py index 8bd5c77..981908a 100644 --- a/.github/scripts/test-maven-inventory.py +++ b/.github/scripts/test-maven-inventory.py @@ -2,107 +2,117 @@ # SPDX-License-Identifier: Apache-2.0 """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 subprocess +import sys +import tempfile import unittest - +from pathlib import Path +from typing import Any, cast, override sys.dont_write_bytecode = True spec = importlib.util.spec_from_file_location("inventory", Path(__file__).with_name("check-maven-inventory.py")) +assert spec is not None and spec.loader is not None inventory = importlib.util.module_from_spec(spec) spec.loader.exec_module(inventory) class InventoryTests(unittest.TestCase): - def setUp(self): - 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": [ - {"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": []}]} + """Exercise selected dependency coverage using isolated synthetic reports.""" - def test_complete_graph_without_findings_passes(self): - self.assertEqual(self.expected, inventory.resolved(self.text)) - self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy)) + @override + def setUp(self) -> None: + """Create isolated synthetic evidence with cleanup after each test.""" + 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: dict[str, Any] = {"bomFormat": "CycloneDX", "components": [{"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"} for name, version in self.expected]} + self.trivy: dict[str, Any] = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]} - 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_complete_graph_without_findings_passes(self) -> None: + """Verify complete graph without findings passes.""" + self.assertEqual(self.expected, inventory.resolved(self.text)) + self.assertEqual(6, 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_missing_or_unreviewed_h2_version_fails(self) -> None: + """Verify missing or unreviewed h2 version fails.""" + 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_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_missing_frontend_and_empty_maven_fail(self) -> None: + """Verify missing frontend and empty maven fail.""" + 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_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_test_scope_or_test_jar_fails(self) -> None: + """Verify test scope or test jar fails.""" + 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_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_malformed_or_missing_component_fails(self) -> None: + """Verify empty malformed or missing component fails.""" + for bom in cast(tuple[object, ...], ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]}, {"bomFormat": "CycloneDX", "components": self.bom["components"][1:]})): + with self.assertRaises(ValueError): + inventory.check(self.expected, bom) - 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_wrong_selected_version_fails(self) -> None: + """Verify wrong selected version fails.""" + 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_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 test_empty_or_dropped_trivy_package_fails(self) -> None: + """Verify empty or dropped trivy package fails.""" + for report in cast(tuple[object, ...], (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]})): + with self.assertRaises(ValueError): + inventory.check(self.expected, self.bom, report) - 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_cli_supplied_null_trivy_fails(self) -> None: + """Verify cli supplied null trivy fails.""" + 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") + assert inventory.__file__ is not None + 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) # noqa: S603 -- Execute this interpreter and the fixed sibling guard using synthetic paths, without a shell. + self.assertNotEqual(0, result.returncode) + self.assertIn("Expected Trivy report object", result.stderr) - def test_osv_all_clean_packages_pass_without_vulnerabilities(self): - self.assertEqual(6, inventory.check_osv(self.expected, self.osv())) + def osv(self) -> dict[str, Any]: + """Build a clean OSV fixture whose identity matches the selected graph.""" + 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_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_all_clean_packages_pass_without_vulnerabilities(self) -> None: + """Verify osv all clean packages pass without vulnerabilities.""" + self.assertEqual(6, inventory.check_osv(self.expected, self.osv())) - def test_osv_ambiguous_group_projection_fails(self): - with self.assertRaises(ValueError): - inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv()) + def test_osv_missing_malformed_or_partial_evidence_fails(self) -> None: + """Verify osv missing malformed or partial evidence fails.""" + 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 cast(tuple[object, ...], (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) -> None: + """Verify osv ambiguous group projection fails.""" + with self.assertRaises(ValueError): + inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv()) if __name__ == "__main__": - unittest.main() + unittest.main() diff --git a/.github/scripts/test-scan-gates.py b/.github/scripts/test-scan-gates.py index c8494eb..2b173dd 100644 --- a/.github/scripts/test-scan-gates.py +++ b/.github/scripts/test-scan-gates.py @@ -2,91 +2,106 @@ # SPDX-License-Identifier: Apache-2.0 """Exercise the exact CI guards using synthetic evidence only.""" + import contextlib import copy -import io import importlib.util +import io import json -from pathlib import Path import sys import tempfile import unittest - +from pathlib import Path +from types import ModuleType +from typing import Any, override 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 +def load(name: str) -> ModuleType: + """Load the real sibling guard for synthetic evidence tests.""" + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) + assert spec is not None and spec.loader is not None + 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": []}]} + """Reject incomplete SARIF evidence and preserve severity enforcement.""" - def save(self, report): - (self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8") + @override + def setUp(self) -> None: + """Create isolated synthetic evidence with cleanup after each test.""" + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + self.report: dict[str, Any] = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]} - def test_clean_report_with_zero_findings_passes(self): - self.save(self.report) - self.assertFalse(codeql.check(self.directory, "java-kotlin", 7)) + def save(self, report: object) -> None: + """Write the test body as SARIF without validating it first.""" + (self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8") - 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_clean_report_with_zero_findings_passes(self) -> None: + """Verify clean report with zero findings passes.""" + self.save(self.report) + self.assertFalse(codeql.check(self.directory, "java-kotlin", 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_report_or_wrong_language_fails(self) -> None: + """Verify missing report or wrong language fails.""" + 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_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_empty_or_malformed_report_fails(self) -> None: + """Verify empty or malformed report fails.""" + 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_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_missing_runs_tool_rules_or_results_fails(self) -> None: + """Verify missing runs tool rules or results fails.""" + variants: list[dict[str, Any]] = [{"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_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")) + def test_high_finding_blocks_and_unknown_rule_fails(self) -> None: + """Verify high finding blocks and unknown rule fails.""" + 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) -> None: + """Verify failed invocation and invalid threshold fail.""" + 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() + unittest.main() diff --git a/.github/scripts/test-script-quality.py b/.github/scripts/test-script-quality.py new file mode 100644 index 0000000..b488e71 --- /dev/null +++ b/.github/scripts/test-script-quality.py @@ -0,0 +1,228 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Prove the quality gates reject real violations, using private temporary fixtures and the locked tools.""" + +import importlib.util +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import ModuleType +from typing import Any, override + +sys.dont_write_bytecode = True + +ROOT = Path(__file__).resolve().parents[2] +GOOD_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n' +UNUSED_IMPORT_PYTHON = '"""Probe module."""\n\nimport os\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n' +TWO_SPACE_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n' +INCOMPATIBLE_PYTHON = '"""Probe module."""\n\nVALUE: int = "text"\n' +UNANNOTATED_PYTHON = '"""Probe module."""\n\n\ndef echo(value):\n """Return the value."""\n return value\n' +GOOD_JAVASCRIPT = "export function addNumbers(left, right)\n{\n return left + right;\n}\n" +BAD_LAYOUT_JAVASCRIPT = "export function addNumbers(left, right) {\n return left + right\n}\n" +GOOD_TYPED_JAVASCRIPT = "/** @type {number} */\nexport const value = 1;\n" +INCOMPATIBLE_JAVASCRIPT = '/** @type {number} */\nexport const value = "text";\n' + + +def load(name: str) -> ModuleType: + """Load the real sibling guard for tests.""" + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py")) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +guard = load("check-script-quality") + + +def run(command: list[str], cwd: Path, stdin: str | None = None) -> subprocess.CompletedProcess[str]: + """Run one real tool invocation without a shell and capture its diagnostics.""" + return subprocess.run(command, cwd=cwd, input=stdin, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed tool paths and private temporary arguments; no shell. + + +def ruff(arguments: list[str], source: Path) -> subprocess.CompletedProcess[str]: + """Run the locked Ruff with the repository configuration on one private fixture.""" + return run([sys.executable, "-m", "ruff", *arguments, "--no-cache", "--config", str(ROOT / "ruff.toml"), str(source)], source.parent) + + +class ScopeTests(unittest.TestCase): + """Reject tracked sources that fall outside the explicit tool roots.""" + + def test_python_outside_script_root_is_rejected(self) -> None: + """Reject a tracked Python file outside the script root instead of letting it disappear.""" + for stray in ("tools/helper.py", ".github/scripts/nested/helper.py", "helper.pyi"): + with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the checked script root"): + guard.python_scope([".github/scripts/check-a.py", stray]) + + def test_python_scope_returns_sorted_script_root_and_rejects_empty(self) -> None: + """Select only the direct script root, sorted, and reject selecting nothing.""" + self.assertEqual([".github/scripts/a.py", ".github/scripts/b.py"], guard.python_scope([".github/scripts/b.py", "README.md", ".github/scripts/a.py"])) + with self.assertRaisesRegex(ValueError, "No owned Python"): + guard.python_scope(["README.md"]) + + def test_javascript_requires_tsconfig_include_scope(self) -> None: + """Reject tracked JavaScript outside tsconfig include patterns, including .js/.cjs/.ts.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + (root / "tsconfig.json").write_text(json.dumps({"include": ["tools/**/*.mjs", "eslint.config.mjs"]}), encoding="utf-8") + self.assertEqual(["eslint.config.mjs", "tools/a/b.mjs", "tools/c.mjs"], guard.javascript_scope(root, ["tools/c.mjs", "tools/a/b.mjs", "eslint.config.mjs", "README.md"])) + for stray in ("src/z.js", "tools/w.cjs", "tools/v.ts", "tools/u.js"): + with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the tsconfig include list"): + guard.javascript_scope(root, ["tools/c.mjs", stray]) + with self.assertRaisesRegex(ValueError, "No owned JavaScript"): + guard.javascript_scope(root, ["README.md"]) + + def test_javascript_without_tsconfig_is_rejected(self) -> None: + """Reject JavaScript in a repository that has no tsconfig.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + self.assertEqual([], guard.javascript_scope(root, ["README.md"])) + with self.assertRaisesRegex(ValueError, "without a tsconfig.json"): + guard.javascript_scope(root, ["tools/a.mjs"]) + + +class PyrightCountTests(unittest.TestCase): + """Reject Pyright reports that analyzed too few files or found anything.""" + + @staticmethod + def report(analyzed: int, errors: int = 0, warnings: int = 0) -> dict[str, Any]: + """Build the exact Pyright JSON shape consumed by the guard.""" + return {"summary": {"filesAnalyzed": analyzed, "errorCount": errors, "warningCount": warnings}, "generalDiagnostics": [{"file": "x.py", "message": "finding"}] * (errors + warnings), "exitCode": 1 if errors or warnings else 0} + + def test_nonzero_exit_fails_even_with_clean_summary(self) -> None: + """Fail a nonzero Pyright exit even when every count is clean.""" + clean = self.report(4) + clean["exitCode"] = 1 + with self.assertRaisesRegex(ValueError, "exited 1"): + guard.check_pyright_report(clean, 4) + + def test_count_must_match_selected_sources(self) -> None: + """Fail zero or mismatched analyzed counts even with no diagnostics.""" + guard.check_pyright_report(self.report(4), 4) + for analyzed in (0, 3, 5): + with self.subTest(analyzed=analyzed), self.assertRaisesRegex(ValueError, "analyzed"): + guard.check_pyright_report(self.report(analyzed), 4) + + def test_errors_and_warnings_fail(self) -> None: + """Fail on errors and on warnings at the exact count.""" + for report in (self.report(4, errors=1), self.report(4, warnings=1)): + with self.subTest(report=report), self.assertRaisesRegex(ValueError, "reported findings"): + guard.check_pyright_report(report, 4) + + +class RuffProbeTests(unittest.TestCase): + """Run the real locked Ruff on bounded private fixtures.""" + + def test_compliant_fixture_passes_and_violations_fail(self) -> None: + """Pass a compliant control and reject unused imports and wrong indentation.""" + with tempfile.TemporaryDirectory() as name: + probe = Path(name) / "probe.py" + probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n") + self.assertEqual(0, ruff(["check"], probe).returncode) + self.assertEqual(0, ruff(["format", "--check"], probe).returncode) + probe.write_text(UNUSED_IMPORT_PYTHON, encoding="utf-8", newline="\n") + unused = ruff(["check", "--output-format", "concise"], probe) + self.assertNotEqual(0, unused.returncode) + self.assertIn("F401", unused.stdout) + probe.write_text(TWO_SPACE_PYTHON, encoding="utf-8", newline="\n") + indented = ruff(["format", "--check"], probe) + self.assertNotEqual(0, indented.returncode) + self.assertIn("would be reformatted", (indented.stdout + indented.stderr).lower()) + + +class PyrightProbeTests(unittest.TestCase): + """Run the real locked Pyright with the repository strict settings on private projects.""" + + @staticmethod + def project(root: Path, include: list[str] | None = None) -> None: + """Write the repository Pyright config, without its venv location, into a private project.""" + config: dict[str, Any] = json.loads((ROOT / ".github/pyrightconfig.json").read_text(encoding="utf-8")) + del config["venvPath"], config["venv"] + if include is not None: + config["include"] = include + (root / "pyrightconfig.json").write_text(json.dumps(config), encoding="utf-8") + + def test_compliant_control_passes_and_strict_violations_fail(self) -> None: + """Analyze the control once with no findings and reject incompatible and unannotated code.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + self.project(root) + scripts = root / "scripts" + scripts.mkdir() + probe = scripts / "probe.py" + probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n") + clean = guard.run_pyright(root, ".") + self.assertEqual(0, clean["exitCode"]) + guard.check_pyright_report(clean, 1) + clean["exitCode"] = 1 + with self.assertRaisesRegex(ValueError, "exited 1"): + guard.check_pyright_report(clean, 1) + probe.write_text(INCOMPATIBLE_PYTHON, encoding="utf-8", newline="\n") + report = guard.run_pyright(root, ".") + self.assertIn("reportAssignmentType", {item.get("rule") for item in report["generalDiagnostics"]}) + with self.assertRaisesRegex(ValueError, "reported findings"): + guard.check_pyright_report(report, 1) + probe.write_text(UNANNOTATED_PYTHON, encoding="utf-8", newline="\n") + strict = guard.run_pyright(root, ".") + self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]}) + + def test_zero_analyzed_files_cannot_pass(self) -> None: + """Reject an empty include and a hidden-directory root config in the wrapper.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + scripts = root / ".github" / "scripts" + scripts.mkdir(parents=True) + (scripts / "probe.py").write_text(GOOD_PYTHON, encoding="utf-8", newline="\n") + for include in (["nothing/*.py"], [".github/scripts/*.py"]): + self.project(root, include) + with self.subTest(include=include), self.assertRaises(ValueError): + guard.check_pyright_report(guard.run_pyright(root, "."), 1) + + +class JavaScriptProbeTests(unittest.TestCase): + """Run the real locked ESLint and TypeScript on bounded private input.""" + + @override + def setUp(self) -> None: + """Skip only where tsconfig is absent, because the scope guard forbids JavaScript there.""" + if not (ROOT / "tsconfig.json").is_file(): + self.skipTest("No JavaScript tooling in this repository") + node = shutil.which("node") + self.assertIsNotNone(node) + self.node = str(node) + + def eslint(self, text: str) -> subprocess.CompletedProcess[str]: + """Lint text as an in-scope tool file using the repository configuration.""" + return run([self.node, str(ROOT / "node_modules/eslint/bin/eslint.js"), "--stdin", "--stdin-filename", "tools/probe.mjs", "--max-warnings", "0", "--format", "json"], ROOT, text) + + def test_eslint_rejects_wrong_layout(self) -> None: + """Pass a compliant control and reject K&R braces, two-space indent and a missing semicolon.""" + good = self.eslint(GOOD_JAVASCRIPT) + self.assertEqual(0, good.returncode, good.stdout + good.stderr) + bad = self.eslint(BAD_LAYOUT_JAVASCRIPT) + self.assertNotEqual(0, bad.returncode) + rules = {message["ruleId"] for message in json.loads(bad.stdout)[0]["messages"]} + self.assertLessEqual({"@stylistic/brace-style", "@stylistic/semi", "@stylistic/indent"}, rules) + + def test_checked_javascript_rejects_incompatible_assignment(self) -> None: + """Reject a real incompatible assignment under the repository strict checkJs settings.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + (root / "tsconfig.json").write_text(json.dumps({"extends": (ROOT / "tsconfig.json").as_posix(), "compilerOptions": {"typeRoots": [(ROOT / "node_modules/@types").as_posix()]}, "include": ["probe.mjs"]}), encoding="utf-8") + tsc = [self.node, str(ROOT / "node_modules/typescript/bin/tsc"), "-p", str(root / "tsconfig.json")] + (root / "probe.mjs").write_text(GOOD_TYPED_JAVASCRIPT, encoding="utf-8", newline="\n") + good = run(tsc, root) + self.assertEqual(0, good.returncode, good.stdout + good.stderr) + (root / "probe.mjs").write_text(INCOMPATIBLE_JAVASCRIPT, encoding="utf-8", newline="\n") + bad = run(tsc, root) + self.assertNotEqual(0, bad.returncode) + self.assertIn("TS2322", bad.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-source-headers.py b/.github/scripts/test-source-headers.py index 8a04b44..20102c8 100644 --- a/.github/scripts/test-source-headers.py +++ b/.github/scripts/test-source-headers.py @@ -11,7 +11,6 @@ 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) @@ -32,8 +31,7 @@ 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/*"]}]} + return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]} class HeaderTests(unittest.TestCase): @@ -51,12 +49,23 @@ class HeaderTests(unittest.TestCase): with self.subTest(name=name): HEADERS.check_notice(name, text, fixture_policy()) + def test_year_range_separator_is_hyphen_or_en_dash_only(self) -> None: + """Accept a hyphen or en dash between years and reject any other separator.""" + for separator, accepted in (("-", True), (chr(0x2013), True), ("/", False), (chr(0x2014), False)): + text = f"# Copyright 2025{separator}2026 James Maes\n# SPDX-License-Identifier: MIT" + with self.subTest(separator=separator): + if accepted: + HEADERS.check_notice("src/a.py", text, fixture_policy()) + else: + with self.assertRaises(ValueError): + HEADERS.check_notice("src/a.py", 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", "<?xml version=\"1.0\"?>\n", "<!--" + MIT + "--><root/>"), + ("src/a.xml", '<?xml version="1.0"?>\n', "<!--" + MIT + "--><root/>"), ("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"), ): with self.subTest(name=name): @@ -67,21 +76,20 @@ class HeaderTests(unittest.TestCase): 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", "<!--" + MIT + "--><?xml version=\"1.0\"?><root/>"), + ("src/a.xml", "<!--" + MIT + '--><?xml version="1.0"?><root/>'), ): 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# ")): + 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 + "\";"): + 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): @@ -89,8 +97,7 @@ class HeaderTests(unittest.TestCase): 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]): + 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()) @@ -103,8 +110,7 @@ class HeaderTests(unittest.TestCase): 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.", "")): + 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) @@ -121,9 +127,7 @@ class HeaderTests(unittest.TestCase): """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"): + 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"])) @@ -140,11 +144,11 @@ class HeaderTests(unittest.TestCase): """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) + subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory. 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) + subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command. 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") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a41bce..cdc788a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,17 @@ jobs: with: distribution: temurin java-version: '21' + - name: Set up uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: '0.13.6' + enable-cache: false + - name: Restore locked Python tools + run: uv sync --locked + - name: Verify script quality gates and rejection paths + run: | + uv run --locked --no-sync python .github/scripts/test-script-quality.py + uv run --locked --no-sync python .github/scripts/check-script-quality.py - name: Verify owned source notices and rejection paths run: | python3 .github/scripts/test-source-headers.py diff --git a/.gitignore b/.gitignore index e47bfdc..5fc5134 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ dist/ *.iml .DS_Store Thumbs.db + +# Local script-quality environments and tool caches. +.venv/ +.ruff_cache/ +__pycache__/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89a49f8..5a2d9b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,3 +9,14 @@ Changes belong in a pull request with a concise behavior description and actual Keep the QBit compatible with QQQ 4.1+ and resolve runtime services through the current QQQ instance. Preserve tenant/device isolation and the distinction between local exposure (`available`) and server approval (`enabled`). Application callers must authorize destinations before submitting or emitting an effect. Maintainers create reviewed version tags. CI produces JAR/POM/checksum assets and publishes GitHub prereleases for hyphenated versions. Maven Central publication, deployment and physical printer tests are separate operations; this workflow does not perform them. + +## Script quality + +Owned `.github/scripts/*.py` follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (config adapted from its `python` profile): three-space indentation, Ruff 0.17.3 format/lint and strict Pyright 1.1.414. Restore with `uv sync --locked`, then run: + +```text +uv run --locked --no-sync python .github/scripts/test-script-quality.py +uv run --locked --no-sync python .github/scripts/check-script-quality.py +``` + +`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or any JavaScript/TypeScript (this repository has no tsconfig), and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print` only in `check-*.py`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass. diff --git a/docs/TODO.md b/docs/TODO.md index 958bdf9..80cb96e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,10 @@ # Follow-up +## Script quality gates + +- [x] Pin Ruff and strict Pyright with a locked restore; reject Python outside `.github/scripts/` and zero-file Pyright runs; negative tool probes pass locally. +- [ ] Observe the new `verify` job steps pass on hosted CI before relying on them. + ## Current local development qualification - [x] Reuse production discovery and the shared major-2 gate in the optional diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ae8f53c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 + +[project] +name = "qbit-printing-script-quality" +version = "0.0.0" +requires-python = ">=3.13" + +[dependency-groups] +dev = ["ruff==0.17.3", "pyright==1.1.414"] + +[tool.uv] +package = false diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..286fa55 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,56 @@ +# Copyright 2026 Kingsrook, LLC +# SPDX-License-Identifier: Apache-2.0 +# Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152. +# KofTwentyTwo Python lint + format configuration (K22-CODE-PY). Copy to the repo root +# (or merge into [tool.ruff] in pyproject.toml). `ruff format --check` and `ruff check` +# run in CI and pre-commit with zero findings allowed. +target-version = "py313" +include = [".github/scripts/*.py"] +extend-exclude = [".venv", "node_modules", "dist", "target", "bin", "obj"] +# Kingsrook: no practical line-length limit (ruff's maximum is 320). +line-length = 320 +# Kingsrook: 3-space indentation. Ruff's formatter supports it fully; pycodestyle's +# "multiple of four" rules (E111/E114) are therefore not selected. +indent-width = 3 + +[format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" +docstring-code-format = true + +[lint] +select = [ + "F", # pyflakes + "E4", "E7", "E9", "W", # pycodestyle errors that are layout-independent + "I", # isort: no wildcard imports, ordered + "N", # pep8-naming + "UP", # pyupgrade + "B", # bugbear + "S", # bandit security + "BLE", # blind except + "T20", # print statements: use logging (Kingsrook logging rule) + "ERA", # commented-out code (Kingsrook: no zombie code) + "FIX", # FIXME/TODO must be resolved or tracked + "TD", # TODO format: author and issue link + "PTH", # pathlib over os.path + "RET", "SIM", "C4", "PIE", "PL", "RUF", + "ANN", # type annotations on every function + "D", # docstrings on modules, classes, functions + "LOG", "G", # logging hygiene +] +ignore = [ + "D203", # conflicts with D211 + "D213", # conflicts with D212 + "TD003", # issue link is enforced by review, not regex +] + +[lint.per-file-ignores] +".github/scripts/check-*.py" = ["T201"] # CLI diagnostics are the public stdout contract, not application logging. +".github/scripts/test-*.py" = ["S101", "PLR2004"] # Synthetic assertions and explicit boundary values. + +[lint.pydocstyle] +convention = "pep257" + +[lint.flake8-tidy-imports] +ban-relative-imports = "all" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6599bf6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,78 @@ +version = 1 +revision = 5 +requires-python = ">=3.13" + +[[package]] +name = "nodeenv" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/99/d27cfc9518f49e4cbc25c683ec7cf8704a9d31311341c291c0e78ddf791b/nodeenv-1.12.0.tar.gz", hash = "sha256:4ea7f88339310afebc3253f2c7a91b94bacf97d73a36a5c108962abdfaca501e", size = 131292, upload-time = "2026-10-31T19:03:18.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/6a/8a9ac7d5e2f7cf53c459bfef90ebc17a2f8cd54b665af96799d4c534840e/nodeenv-1.12.0-py3-none-any.whl", hash = "sha256:80644d65c10cc2be12e3e4b54b3367ff288c7ad24ae75c2dcc65454fc7449d4d", size = 39442, upload-time = "2026-10-31T19:03:17.427Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.414" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, +] + +[[package]] +name = "qbit-printing-script-quality" +version = "0.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = "==1.1.414" }, + { name = "ruff", specifier = "==0.17.3" }, +] + +[[package]] +name = "ruff" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] -- 2.54.0 From 13d6ee1a23d83ca1d3e7055e771de17c55fab392 Mon Sep 17 00:00:00 2001 From: James Maes <james@kof22.com> Date: Sun, 1 Nov 2026 15:08:27 -0600 Subject: [PATCH 6/7] fix(tooling): use official Pyright and patched minimatch Keep strict Python checking at 1.1.414 using Microsoft's npm package, removing the unnecessary Python wrapper and its bootstrap dependencies. Pin minimatch 11.0.1 for the checked JavaScript tools and preserve actual negative quality probes and all license/security checks. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com> --- .github/scripts/check-script-quality.py | 14 +++++-- .github/scripts/test-script-quality.py | 10 +++++ .github/workflows/ci.yml | 6 +++ .gitignore | 1 + CONTRIBUTING.md | 2 +- package-lock.json | 51 +++++++++++++++++++++++++ package.json | 14 +++++++ pyproject.toml | 2 +- uv.lock | 37 +----------------- 9 files changed, 96 insertions(+), 41 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.github/scripts/check-script-quality.py b/.github/scripts/check-script-quality.py index 0c9549b..18385f0 100644 --- a/.github/scripts/check-script-quality.py +++ b/.github/scripts/check-script-quality.py @@ -4,6 +4,7 @@ """Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools.""" import json +import shutil import subprocess import sys from pathlib import Path, PurePosixPath @@ -13,6 +14,8 @@ PYTHON_SUFFIXES = (".py", ".pyi", ".pyw") SCRIPT_PATTERN = ".github/scripts/*.py" PYRIGHT_CONFIG = ".github" JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx") +# Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`. +PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js" def tracked_files(root: Path) -> list[str]: @@ -51,9 +54,14 @@ def javascript_scope(root: Path, tracked: list[str]) -> list[str]: def run_pyright(root: Path, config: str) -> dict[str, Any]: - """Run strict Pyright with JSON output and return its full report.""" - # Fixed interpreter module invocation; the config is an argument, never shell input. - completed = subprocess.run([sys.executable, "-m", "pyright", "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed interpreter and module; no shell. + """Run the locked npm Pyright through Node with JSON output and return its full report.""" + node = shutil.which("node") + if node is None: + raise ValueError("Node.js is required to run the locked Pyright") + if not PYRIGHT_ENTRY.is_file(): + raise ValueError(f"Locked Pyright is missing; run npm ci --ignore-scripts ({PYRIGHT_ENTRY})") + # Resolved Node and locked entry script; the config is an argument, never shell input. + completed = subprocess.run([node, str(PYRIGHT_ENTRY), "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Resolved Node and locked entry script; no shell. try: report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :]) int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing. diff --git a/.github/scripts/test-script-quality.py b/.github/scripts/test-script-quality.py index b488e71..71a12d9 100644 --- a/.github/scripts/test-script-quality.py +++ b/.github/scripts/test-script-quality.py @@ -13,6 +13,7 @@ import unittest from pathlib import Path from types import ModuleType from typing import Any, override +from unittest import mock sys.dont_write_bytecode = True @@ -171,6 +172,15 @@ class PyrightProbeTests(unittest.TestCase): strict = guard.run_pyright(root, ".") self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]}) + def test_missing_locked_pyright_or_node_is_rejected(self) -> None: + """Reject a missing locked Pyright entry or Node instead of silently skipping the type gate.""" + with tempfile.TemporaryDirectory() as name: + root = Path(name) + with mock.patch.object(guard, "PYRIGHT_ENTRY", root / "missing.js"), self.assertRaisesRegex(ValueError, "Locked Pyright is missing"): + guard.run_pyright(root, ".") + with mock.patch.object(guard.shutil, "which", return_value=None), self.assertRaisesRegex(ValueError, "Node.js is required"): + guard.run_pyright(root, ".") + def test_zero_analyzed_files_cannot_pass(self) -> None: """Reject an empty include and a hidden-directory root config in the wrapper.""" with tempfile.TemporaryDirectory() as name: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdc788a..9085e8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,12 @@ jobs: with: distribution: temurin java-version: '21' + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24' + package-manager-cache: false + - name: Restore locked Pyright + run: npm ci --ignore-scripts --no-audit --no-fund - name: Set up uv uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: diff --git a/.gitignore b/.gitignore index 5fc5134..3f84bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ Thumbs.db # Local script-quality environments and tool caches. .venv/ +node_modules/ .ruff_cache/ __pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a2d9b3..044c510 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Maintainers create reviewed version tags. CI produces JAR/POM/checksum assets an ## Script quality -Owned `.github/scripts/*.py` follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (config adapted from its `python` profile): three-space indentation, Ruff 0.17.3 format/lint and strict Pyright 1.1.414. Restore with `uv sync --locked`, then run: +Owned `.github/scripts/*.py` follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (config adapted from its `python` profile): three-space indentation, Ruff 0.17.3 format/lint (locked in `uv.lock`) and strict Pyright 1.1.414 (Microsoft's npm package, a development-only dependency locked in `package-lock.json` and run through Node 24). Restore with `uv sync --locked` and `npm ci --ignore-scripts`, then run: ```text uv run --locked --no-sync python .github/scripts/test-script-quality.py diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..452783d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "@qrun-io/qbit-printing-script-quality", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@qrun-io/qbit-printing-script-quality", + "version": "0.0.0", + "license": "Apache-2.0", + "devDependencies": { + "pyright": "1.1.414" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pyright": { + "version": "1.1.414", + "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.414.tgz", + "integrity": "sha512-FPZZb51jepDX4eP7TEYDeNFtmE3WgwkkEcJpvH3/QmUSsj0EAy3LXu+xB4T/FWDejtsXlCfFr/rbWFjwuwuXww==", + "dev": true, + "license": "MIT", + "bin": { + "pyright": "index.js", + "pyright-langserver": "langserver.index.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6542f49 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "@qrun-io/qbit-printing-script-quality", + "version": "0.0.0", + "private": true, + "description": "Development-only script quality tooling for QBit Printing; no runtime dependency.", + "license": "Apache-2.0", + "engines": { + "node": ">=24" + }, + "devDependencies": { + "pyright": "1.1.414" + }, + "packageManager": "npm@11.19.1" +} diff --git a/pyproject.toml b/pyproject.toml index ae8f53c..4f63273 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ version = "0.0.0" requires-python = ">=3.13" [dependency-groups] -dev = ["ruff==0.17.3", "pyright==1.1.414"] +dev = ["ruff==0.17.3"] [tool.uv] package = false diff --git a/uv.lock b/uv.lock index 6599bf6..112a4bb 100644 --- a/uv.lock +++ b/uv.lock @@ -2,28 +2,6 @@ version = 1 revision = 5 requires-python = ">=3.13" -[[package]] -name = "nodeenv" -version = "1.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/99/d27cfc9518f49e4cbc25c683ec7cf8704a9d31311341c291c0e78ddf791b/nodeenv-1.12.0.tar.gz", hash = "sha256:4ea7f88339310afebc3253f2c7a91b94bacf97d73a36a5c108962abdfaca501e", size = 131292, upload-time = "2026-10-31T19:03:18.847Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/6a/8a9ac7d5e2f7cf53c459bfef90ebc17a2f8cd54b665af96799d4c534840e/nodeenv-1.12.0-py3-none-any.whl", hash = "sha256:80644d65c10cc2be12e3e4b54b3367ff288c7ad24ae75c2dcc65454fc7449d4d", size = 39442, upload-time = "2026-10-31T19:03:17.427Z" }, -] - -[[package]] -name = "pyright" -version = "1.1.414" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, -] - [[package]] name = "qbit-printing-script-quality" version = "0.0.0" @@ -31,17 +9,13 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ - { name = "pyright" }, { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = "==1.1.414" }, - { name = "ruff", specifier = "==0.17.3" }, -] +dev = [{ name = "ruff", specifier = "==0.17.3" }] [[package]] name = "ruff" @@ -67,12 +41,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, ] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] -- 2.54.0 From 2a2eedc89f64afe8473a796abb4a9fc0255a4ab7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:59:34 +0000 Subject: [PATCH 7/7] chore(deps): Bump KofTwentyTwo/standards/.github/workflows/pr.yml Bumps [KofTwentyTwo/standards/.github/workflows/pr.yml](https://github.com/koftwentytwo/standards) from 0.1.2 to 1.2.1. - [Release notes](https://github.com/koftwentytwo/standards/releases) - [Changelog](https://github.com/KofTwentyTwo/standards/blob/main/CHANGELOG.md) - [Commits](https://github.com/koftwentytwo/standards/compare/fdf5ed029603c938615a32b1beca3628b8787945...70b555bd5658273090769991bfc4c9b6681dde18) --- updated-dependencies: - dependency-name: KofTwentyTwo/standards/.github/workflows/pr.yml dependency-version: 1.2.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> --- .github/workflows/pr-gates.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml index e32bcf5..8022a46 100644 --- a/.github/workflows/pr-gates.yml +++ b/.github/workflows/pr-gates.yml @@ -17,7 +17,7 @@ concurrency: jobs: pr: - uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 + uses: KofTwentyTwo/standards/.github/workflows/pr.yml@70b555bd5658273090769991bfc4c9b6681dde18 # v1.2.1 with: # EX-0013: reviewed unmodified H2 2.4.240; docs/H2-LICENSE-REVIEW.md. # The required SCA inventory guard enforces the reviewed version because -- 2.54.0