Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2c16785d
|
||
|
|
e33823bbbd
|
||
|
|
d6edfced8a
|
||
|
|
3fa78decc8
|
||
|
|
0ab07fc87c
|
||
|
|
1f6342e871
|
||
|
|
2906c2bd50
|
||
|
|
fbec343cb2
|
||
|
|
48e6cdb9df
|
||
|
|
9ccd9d9b43
|
||
|
|
a583f91abe
|
||
|
|
d5c32f854e
|
||
|
|
edc1385bd5
|
||
|
|
29d1ae5f38
|
||
|
|
cf57dfb521
|
||
|
|
fc99caf85e
|
||
|
|
aaa4a9ff03
|
||
|
|
38fd06229e
|
||
|
|
111cbede8f
|
||
|
|
43253f6676
|
||
|
|
e4639e3cca
|
||
|
|
9811979d6c
|
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.git
|
||||
**/bin
|
||||
**/obj
|
||||
target
|
||||
.local
|
||||
.venv
|
||||
@@ -0,0 +1,26 @@
|
||||
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
|
||||
name: qrun-release
|
||||
on:
|
||||
push: {}
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
release:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
maven-profiles: ui-demo
|
||||
fixture-repository: QRun-IO/qqq-print-protocol
|
||||
fixture-ref: 42c0d249f81395f00408ec0f92638dcbbf5deb03
|
||||
fixture-path: qqq-print-protocol
|
||||
postpublish:
|
||||
if: ${{ needs.release.outputs.capture == 'true' }}
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright (C) 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Exercise the repository's actual compiler configuration on isolated probes."""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
NS = "{http://maven.apache.org/POM/4.0.0}"
|
||||
LOG = logging.getLogger(__name__)
|
||||
PROBES: dict[str, tuple[str, str | None]] = {
|
||||
"positive": ('public String value() { return "present"; }', None),
|
||||
"nullaway": ("public String value() { return null; }", "[NullAway]"),
|
||||
"errorprone": ("public void value() { new IllegalArgumentException(); }", "[DeadException]"),
|
||||
"warnings": ('public long value() { return java.util.Date.parse("2000-01-01"); }', "warnings found and -Werror specified"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuardContext:
|
||||
"""Everything shared by all probes: repository root, evidence folder, probe POM and Maven command."""
|
||||
|
||||
root: Path
|
||||
output: Path
|
||||
pom: ET.ElementTree
|
||||
command: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Probe:
|
||||
"""One isolated compilation: source root, name, the member source and the diagnostic it must produce (None means it must compile)."""
|
||||
|
||||
phase: str
|
||||
name: str
|
||||
source: str
|
||||
expected: str | None
|
||||
|
||||
|
||||
def probe_pom(source: ET.Element) -> ET.ElementTree:
|
||||
"""Copy compiler configuration, including executions, without changing its flags."""
|
||||
compiler = source.find(f"{NS}build/{NS}plugins/{NS}plugin[{NS}artifactId='maven-compiler-plugin']")
|
||||
if compiler is None:
|
||||
raise ValueError("Repository must declare its compiler plugin")
|
||||
project = ET.Element(f"{NS}project")
|
||||
for name, value in (("modelVersion", "4.0.0"), ("groupId", "com.kingsrook.qbits"), ("artifactId", "compiler-configuration-probe"), ("version", "1")):
|
||||
ET.SubElement(project, f"{NS}{name}").text = value
|
||||
properties = source.find(f"{NS}properties")
|
||||
if properties is not None:
|
||||
project.append(copy.deepcopy(properties))
|
||||
plugins = ET.SubElement(ET.SubElement(project, f"{NS}build"), f"{NS}plugins")
|
||||
plugins.append(copy.deepcopy(compiler))
|
||||
return ET.ElementTree(project)
|
||||
|
||||
|
||||
def run_probe(context: GuardContext, probe: Probe) -> dict[str, str | bool]:
|
||||
"""Require the intended diagnostic, not merely an unrelated Maven failure."""
|
||||
folder = context.output / f"{probe.phase}-{probe.name}"
|
||||
package = folder / "src" / probe.phase / "java" / "com" / "kingsrook" / "qbits" / "printing"
|
||||
package.mkdir(parents=True)
|
||||
(package / "Probe.java").write_text(f"// Generated compiler guard fixture; not application source.\npackage com.kingsrook.qbits.printing;\npublic final class Probe {{ {probe.source} }}\n", encoding="utf-8")
|
||||
context.pom.write(folder / "pom.xml", encoding="utf-8", xml_declaration=True)
|
||||
result = subprocess.run( # noqa: S603 -- argument list (no shell) of the repository Maven wrapper or the operator-supplied executable plus fixed Maven goals
|
||||
[*context.command, "-f", str(folder / "pom.xml"), "test-compile"],
|
||||
cwd=context.root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
check=False,
|
||||
)
|
||||
log = result.stdout + result.stderr
|
||||
(folder / "compiler.log").write_text(log, encoding="utf-8")
|
||||
passed = result.returncode == 0 if probe.expected is None else result.returncode != 0 and probe.expected in log
|
||||
if probe.name == "warnings":
|
||||
passed = passed and "[deprecation]" in log
|
||||
if not passed:
|
||||
raise RuntimeError(f"Compiler guard failed: {probe.phase}/{probe.name}; inspect {folder / 'compiler.log'}")
|
||||
return {"sourceRoot": probe.phase, "probe": probe.name, "expectedRejection": probe.expected is not None, "passed": True}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run positive and negative main/test probes without installing any artifact."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--maven", help="Optional exact Maven executable; otherwise use the repository wrapper")
|
||||
parser.add_argument("--settings", type=Path)
|
||||
parser.add_argument("--local-repository", type=Path)
|
||||
parser.add_argument("--offline", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
command = [args.maven] if args.maven else ([str(root / "mvnw.cmd")] if os.name == "nt" else ["bash", str(root / "mvnw")])
|
||||
command += ["-B", "-ntp"]
|
||||
if args.offline:
|
||||
command.append("-o")
|
||||
if args.settings:
|
||||
command += ["-s", str(args.settings.resolve())]
|
||||
if args.local_repository:
|
||||
command.append(f"-Dmaven.repo.local={args.local_repository.resolve()}")
|
||||
output_root = root / "target" / "compiler-guard"
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
output = Path(tempfile.mkdtemp(prefix="run-", dir=output_root))
|
||||
ET.register_namespace("", NS[1:-1])
|
||||
pom = probe_pom(ET.parse(root / "pom.xml").getroot()) # noqa: S314 -- parses this repository's own checked-in pom.xml, never external input
|
||||
context = GuardContext(root, output, pom, command)
|
||||
results: list[dict[str, str | bool]] = []
|
||||
for phase in ("main", "test"):
|
||||
for name, (source, expected) in PROBES.items():
|
||||
results.append(run_probe(context, Probe(phase, name, source, expected)))
|
||||
(output / "results.json").write_text(json.dumps(results, indent=3) + "\n", encoding="utf-8")
|
||||
LOG.info("Compiler guard passed", extra={"probeCount": len(results), "evidenceDirectory": str(output)})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
main()
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: QRun-IO/qqq-print-protocol
|
||||
ref: a26c18c90c7d6102bbe26eb50cbb90fcd1bd46e3
|
||||
ref: 42c0d249f81395f00408ec0f92638dcbbf5deb03
|
||||
path: qqq-print-protocol
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -59,6 +59,8 @@ jobs:
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
- name: Checkstyle, unit tests and HTTP contract tests
|
||||
run: bash ./mvnw --batch-mode --no-transfer-progress -Pui-demo verify
|
||||
- name: Prove main and test compiler safety gates reject invalid source
|
||||
run: python3 .github/scripts/check-java-compiler.py
|
||||
- name: Prepare JAR, POM and checksums
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -84,6 +86,7 @@ jobs:
|
||||
path: |
|
||||
qbit-printing/target/surefire-reports/
|
||||
qbit-printing/target/checkstyle-result.xml
|
||||
qbit-printing/target/compiler-guard/
|
||||
if-no-files-found: ignore
|
||||
|
||||
release-security:
|
||||
|
||||
@@ -50,6 +50,48 @@ jobs:
|
||||
$configuration.TestRegistry.Enabled = $false
|
||||
Invoke-Pester -Configuration $configuration
|
||||
|
||||
clients:
|
||||
name: development-clients (${{ matrix.fixture }})
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- fixture: workload
|
||||
dockerfile: dev/Dockerfile.client
|
||||
- fixture: native-auth
|
||||
dockerfile: dev/native-auth/Dockerfile.client
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Build strict fixtures and run fake-HTTP discovery checks
|
||||
env:
|
||||
DOCKERFILE: ${{ matrix.dockerfile }}
|
||||
run: docker build --target build --file "$DOCKERFILE" .
|
||||
|
||||
servers:
|
||||
name: development-servers (${{ matrix.fixture }})
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- fixture: workload
|
||||
dockerfile: dev/Dockerfile.server
|
||||
- fixture: native-auth
|
||||
dockerfile: dev/native-auth/Dockerfile.server
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Build strict Maven project and compile the fixture Java without starting it
|
||||
env:
|
||||
DOCKERFILE: ${{ matrix.dockerfile }}
|
||||
run: docker build --target build --file "$DOCKERFILE" .
|
||||
|
||||
docker:
|
||||
name: development-docker
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -18,3 +18,7 @@ Thumbs.db
|
||||
node_modules/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
|
||||
# Isolated .NET developer fixture output.
|
||||
dev/**/bin/
|
||||
dev/**/obj/
|
||||
+51
-1
@@ -1,6 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased (0.4.0-SNAPSHOT)
|
||||
## 1.1.0
|
||||
|
||||
Native printing extension **2.4.0** (additive; wire payload v1 and the agent
|
||||
subprotocol `qqq-printing.v1` are unchanged). Pairs with protocol 1.1.0 and Windows
|
||||
connector 1.2.0; 1.0.x connectors keep working unchanged.
|
||||
|
||||
- New HELLO and HTTP control-poll capability `printer-approval-v1` (HELLO accepts up to
|
||||
five capabilities, the control poll up to four; it is stored in the device's control
|
||||
capabilities like the others).
|
||||
- New server-to-client realtime frame `PRINTERS`: `{ "printers": [ { "printerId", "enabled" } ] }`,
|
||||
the complete approval state of every printer in the device's latest accepted inventory.
|
||||
Sent only to connections that advertised the capability, after each accepted INVENTORY's
|
||||
ACK and whenever a printer's approval changes through any path (admin route, native
|
||||
approve/disable/set-approval processes, `PrintingService.enablePrinter`). An empty
|
||||
inventory yields an empty list; other connections never receive the frame.
|
||||
- `PrintingService.printerApprovals(deviceId)` exposes the same list to host code.
|
||||
- Remove printers and devices without losing history. New nullable `removed_at` columns on
|
||||
`printing_printer` and `printing_device` (read-only field **Removed at** on both tables, added
|
||||
by the idempotent migration). **Remove printers** (`printingRemoveSelectedPrinters`, on a
|
||||
printer record or a selection) and `PrintingService.removePrinter(deviceId, printerId)` set
|
||||
`removed_at`, clear approval and exposure, and drop the printer from the Printers default
|
||||
view (add the **Removed at is empty** filter), the operator catalog, claims and submissions,
|
||||
the device/printer selectors and the `PRINTERS` frame, which is pushed again on removal. A
|
||||
queue that the device shares again returns exposed but not approved. **Remove device**
|
||||
(`printingRemoveDevices`, confirmation required) revokes access through the existing REVOKE
|
||||
control, then `PrintingService.removeDevice(deviceId)` archives the device and all of its
|
||||
printers; an already revoked device is archived without a new command, and a removed device
|
||||
cannot be restored. Retention cleanup hard-deletes removed printers that no job references
|
||||
and removed devices with no jobs and no printers, bounded by the run's batch size.
|
||||
- Regenerated OpenAPI fragment (`printing.yaml`): semantic version 2.4.0 and the extended
|
||||
`ControlPollRequest` capability enumeration. No authorization, queue, replay or document
|
||||
behavior changed.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Product version 1.0.0 includes record, table-list and text print processes,
|
||||
PDF rendering with headers and footers, the least-privilege operator catalog,
|
||||
reported Windows queue-default media (`printer-media-v1`) and the opt-in retained-source
|
||||
facade. Native printing stays **2.3.0** with wire payload v1; 1.0 is a product version, not a
|
||||
wire change, and it pairs with Windows connector 1.0.1 and protocol 1.0.0. The QQQ dependency
|
||||
is the published 4.1.0-RC.21 candidate, which provides native session login.
|
||||
|
||||
## 0.4.0-rc.1
|
||||
|
||||
Printing extension **2.3.0** (additive; wire payload v1 and the agent subprotocol are
|
||||
unchanged). 2.1.0 added the record, table-list and text print processes; 2.2.0 added
|
||||
@@ -12,6 +54,14 @@ metadata, not by comparing versions. An agent that does not advertise
|
||||
`printer-media-v1` stays a three-capability peer and cannot claim a job carrying a
|
||||
media selection, whatever extension version it discovers.
|
||||
|
||||
- Built-in test prints add 4 x 1 and 4 x 2 inch labels (203/300/600 dpi) beside the unchanged
|
||||
4 x 6 path: compact PDF and ZPL layouts, and for short PDF labels an explicit actual-size
|
||||
job paper (`paperWidthMm`/`paperHeightMm`, a complete positive pair up to 2000 mm, never
|
||||
combined with a media profile). 4 x 6 and page profiles keep using the queue default.
|
||||
- Strict source standards: Error Prone 2.51.0 and NullAway run as errors with `-Werror` on
|
||||
main and test sources, with honest `@Nullable` contracts, strict source headers and
|
||||
development-fixture style gates. No authorization, retained-source, replay or queue
|
||||
behavior was changed by this work.
|
||||
- Realtime shutdown now waits, with a bound, for channel workers that are already
|
||||
running before the host's durable state is released. Previously the executor was
|
||||
only told to stop, so a late drain could query a stopped file database and
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
# Contributing
|
||||
|
||||
Use Java 21 and the checked-in Maven 3.9.11 wrapper. Clone `QRun-IO/qqq-print-protocol` as a sibling and check out the protocol commit pinned in `.github/workflows/ci.yml`. See README for the Windows and Unix build commands.
|
||||
Use Java 21 and the checked-in Maven 3.9.11 wrapper. Clone `QRun-IO/qqq-print-protocol` as a sibling and check out the protocol commit pinned in `.gitea/workflows/qrun-release.yaml`. See README for the Windows and Unix build commands.
|
||||
|
||||
Run `./mvnw verify` (`.\mvnw.cmd verify` on Windows). The validate phase enforces the repository Checkstyle configuration; tests cover real H2 persistence, native QQQ metadata/processes and the public loopback HTTP contract. Add focused coverage for changed behavior. Do not replace spooler acceptance with a physical-print assertion.
|
||||
Run `bash ./mvnw -Pui-demo verify` (`.\mvnw.cmd -Pui-demo verify` on Windows), matching the primary CI profile. The validate phase enforces the repository Checkstyle configuration; tests cover real H2 persistence, native QQQ metadata/processes and the public loopback HTTP contract. Add focused coverage for changed behavior. Do not replace spooler acceptance with a physical-print assertion.
|
||||
|
||||
Changes belong in a pull request with a concise behavior description and actual validation results. Update README, changelog, relevant tests and shared contract together when changing the wire interface. Use Conventional Commit subjects. Do not include local databases, logs, device credentials, customer documents or machine-specific printer information.
|
||||
Changes belong in a pull request on [the primary Gitea repository](https://git.kof22.com/QRun-IO/qbit-printing) with a concise behavior description and actual validation results. Update README, changelog, relevant tests and shared contract together when changing the wire interface. Use Conventional Commit subjects. Do not include local databases, logs, device credentials, customer documents or machine-specific printer information.
|
||||
|
||||
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.
|
||||
Maintainers publish reviewed versions through the primary Gitea CI and trusted Maven publisher, then attach the verified JAR/POM/checksums to a Gitea release. Follow [release operations](docs/RELEASES.md). GitHub is a shadow/archive; its Actions are retired for this repository. Historical `.github` scripts remain available as local quality checks. Deployment and physical printer acceptance are separate from library publication.
|
||||
|
||||
## Script quality
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# QQQ Printing
|
||||
|
||||
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Release candidate **0.3.0-rc.1** requires native printing API **2.0**, with no job/control polling fallback. Upgrade the server before the matching Windows connector.
|
||||
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://git.kof22.com/QRun-IO/qqq-print-agent) 1.2.0 and the [shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol) 1.1.0 (1.0.1 / 1.0.0 remain compatible). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Version **1.1.0** supports native printing **2.4**, wire payload v1, real-time jobs and controls, native browser sign-in through QQQ, and pushes each exposed printer's approval to connectors that advertise `printer-approval-v1`. Upgrade the server before the matching Windows connector.
|
||||
|
||||
**Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. Current release candidate: [**0.3.0-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.3.0-rc.1). The previous [**0.2.0-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.2.0-rc.1) tagged build passed 110 tests and zero Checkstyle violations. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with build attestations for all three assets. Verify the exact downloaded release; publication does not establish installed or physical-output acceptance. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR.
|
||||
**Released:** [QQQ Printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), with a library JAR, matching POM and `SHA256SUMS`. The same JAR/POM are published in the QRun-IO Gitea Maven registry. Gitea requires sign-in for downloads. The QBit itself is not published to Maven Central and is not a standalone executable/fat JAR. See [installation and release access](docs/RELEASES.md).
|
||||
|
||||
**Runtime:** Java 21 and QQQ **4.1.0-RC.21**, published on Maven Central with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. Product version 1.1.0, native extension 2.4.0 and wire payload v1 are separate version contracts.
|
||||
|
||||
Implemented: single-use enrollment, device credentials, inventory/approval, PDF/ZPL submission, durable JDBC queue, idempotency, claim/content/events, native QQQ setup/test-print processes, read-only device/printer/job tables and a durable enqueue effect target.
|
||||
|
||||
The 0.2.0-rc.1 source adds native record/selection actions, per-device printers/jobs/queue/history, desired versus observed status and standard-session authentication at the discovered `/qqq/v1/qbit/printing` extension. Device credentials become secondary identity proof alongside the current QQQ user's permissions. Upgrade the server before the 0.2.0 Windows client, then explicitly associate an existing unbound pairing during sign-in. These changes are included in the published release above. See the [authentication contract](https://github.com/QRun-IO/qqq-print-protocol/blob/main/docs/QQQ-AUTHENTICATION.md); bound devices cannot fall back to legacy `/printing/v1` authentication.
|
||||
Native record/selection actions expose per-device printers, jobs, queue and history, with desired versus observed status. The discovered `/qqq/v1/qbit/printing` extension uses standard QQQ session authentication. Device credentials are secondary identity proof alongside the current QQQ user's permissions. Existing unbound pairings require explicit association during sign-in. See the [authentication contract](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/branch/main/docs/QQQ-AUTHENTICATION.md); bound devices cannot fall back to legacy `/printing/v1` authentication.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -27,14 +29,14 @@ Java/.NET WebSocket jobs and S3 checks without an installed connector or printer
|
||||
|
||||
## Build and verify
|
||||
|
||||
Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `f3cbc02e9b0fb4fdfefaa59d976c04df747cc589`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11).
|
||||
Install Java 21. Clone this repository and `qqq-print-protocol` as siblings from Gitea. Check out protocol commit `42c0d249f81395f00408ec0f92638dcbbf5deb03`, the immutable fixture ref in `.gitea/workflows/qrun-release.yaml`. Use the checked-in wrapper (Maven 3.9.11).
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd verify
|
||||
.\mvnw.cmd -Pui-demo verify
|
||||
.\mvnw.cmd dependency:build-classpath '-Dmdep.outputFile=target/runtime-classpath.txt' '-DincludeScope=runtime'
|
||||
```
|
||||
|
||||
On Linux/macOS use `bash ./mvnw verify` and the corresponding wrapper commands. First builds download dependencies; `-o` is only appropriate after they are cached.
|
||||
On Linux/macOS use `bash ./mvnw -Pui-demo verify` and the corresponding wrapper commands. First builds download dependencies; `-o` is only appropriate after they are cached.
|
||||
|
||||
Checkstyle runs during validate. Tests exercise real H2 persistence, native QQQ metadata/processes and real loopback HTTP through Javalin, including the shared fixtures and withdrawal of a locally exposed printer. Keep `../qqq-print-protocol` checked out or pass `-Dprinting.protocol.fixtures=<fixture-directory>`.
|
||||
|
||||
@@ -70,14 +72,14 @@ Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator
|
||||
|
||||
The protocol-only `PrintingDemo` and synthetic, mock-authenticated `PrintingUiDemo` remain separate. Do not point the synthetic demo at real paired printer data. The local host retains database document storage until an actual dedicated S3 binding is configured in a deployment-owned application; it does not claim a remote cloud deployment.
|
||||
|
||||
Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The previously accepted 0.1.4-rc.1 release passed 73 tests and zero Checkstyle violations; the user confirmed all three Query diagnostic outputs good. Production S3/IAM and remote deployment remain separate qualification work. The 0.2.0 controls/authentication release passed 25 real client/native QQQ interoperability checks; the user later confirmed installed Letter PDF and Zebra ZPL output, the Connected footer and both printers. These results do not qualify installed or physical behavior for 0.3.
|
||||
The local setup uses the same native authorization and transport paths as an integrated application. Production authentication mappings, S3/IAM and database operation belong to the deploying host; the local fixture does not certify those settings. Dated test evidence is retained in [qualification notes](docs/QUALIFICATION-2026-11-01.md).
|
||||
|
||||
## Add to a QQQ application
|
||||
|
||||
For a source checkout, `mvnw.cmd install` (or `bash ./mvnw install`) installs the verified library into your local Maven repository. For a GitHub release, download the JAR, matching POM and `SHA256SUMS`, verify the checksums, then install those artifacts locally or through your organization's artifact repository. Example using the release files in the current directory:
|
||||
Use the [authenticated Gitea Maven registry](docs/RELEASES.md#use-the-maven-registry), or download the release JAR, matching POM and `SHA256SUMS` and verify both files. For a local installation of the downloaded artifacts:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd install:install-file '-Dfile=qbit-printing-0.3.0-rc.1.jar' '-DpomFile=qbit-printing-0.3.0-rc.1.pom'
|
||||
.\mvnw.cmd install:install-file '-Dfile=qbit-printing-1.0.0.jar' '-DpomFile=qbit-printing-1.0.0.pom'
|
||||
```
|
||||
|
||||
After the artifact is available in your configured repository, add:
|
||||
@@ -86,7 +88,7 @@ After the artifact is available in your configured repository, add:
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-printing</artifactId>
|
||||
<version>0.3.0-rc.1</version>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
@@ -106,12 +108,12 @@ new PrintingQBitProducer().withConfig(new PrintingQBitConfig()
|
||||
.withStorageTableName("printing_documents") // existing dedicated S3 storage table
|
||||
.withSchedulerName("appScheduler") // existing host scheduler
|
||||
.withService(printing)).produce(qInstance, "printing");
|
||||
server.withAdditionalRouteProviders(List.of(new PrintingRoutes(printing, context -> hostPrintingAdminGuard(context))));
|
||||
server.withAdditionalRouteProviders(List.of(new PrintingRealtimeRoutes(printing)));
|
||||
```
|
||||
|
||||
`hostPrintingAdminGuard` is the host's own authenticated administrator policy, not a supplied library method. The service is bound to one tenant; use a separate host service instance for each tenant. Device token verification and job ownership are enforced on every device operation. Management service methods are intended for trusted application code; enforce user/facility authorization before calling them.
|
||||
The producer registers the native printing API; the additional provider supplies its WebSocket transport. Preserve any other host route providers and close the printing provider during shutdown. The same service instance must be used for metadata and transport. The service is bound to one tenant; use a separate host service instance for each tenant. Device token verification and job ownership are enforced on every device operation. Management service methods are intended for trusted application code; enforce user/facility authorization before calling them. See [real-time integration](docs/REALTIME.md).
|
||||
|
||||
The storage/scheduler options above are included in 0.1.4-rc.1. Configure a dedicated private printing bucket for the customer/environment through existing QQQ S3 metadata; no bucket credentials belong in printing forms. Admin retention defaults to 365 days after successful completion. Scheduled cleanup handles bounded batches every five minutes and preserves active, failed and uncertain work plus all receipts/history. Without a configured storage binding, legacy database documents remain supported; without a host scheduler, automatic cleanup does not run. Review [storage configuration, bucket isolation and S3 versioning](docs/STORAGE-AND-RETENTION.md) before deployment.
|
||||
Configure a dedicated private printing bucket for the customer/environment through existing QQQ S3 metadata; no bucket credentials belong in printing forms. Admin retention defaults to 365 days after successful completion. Scheduled cleanup handles bounded batches every five minutes and preserves active, failed and uncertain work plus all receipts/history. Without a configured storage binding, legacy database documents remain supported; without a host scheduler, automatic cleanup does not run. Review [storage configuration, bucket isolation and S3 versioning](docs/STORAGE-AND-RETENTION.md) before deployment.
|
||||
|
||||
Enrollment uses a named facility selector. Set `withFacilityPossibleValueSourceName("your_existing_facility_source")` to reuse an already registered host source and its record security. Without it, the standalone choice is **Default facility**. The backend validates the selection under the current user's permissions before issuing an enrollment code; typing or forging an unknown/foreign-tenant ID cannot bypass that check. Existing stored facility IDs are preserved; configure their original source to resolve their names.
|
||||
|
||||
@@ -121,11 +123,13 @@ Typical setup: run **Enroll a print device** from Printing home or Print devices
|
||||
|
||||
For label printers, configure the machine's **Windows Printing Defaults** for the actual stock size and print method before testing PDFs through the service. A test profile selects document size and ZPL resolution; it does not change the Windows driver or printer's media configuration. Our 300 dpi Zebra required 4 × 6-inch stock and Thermal Direct instead of its previous 4.09 × 2-inch Thermal Transfer defaults. After correcting those settings and resuming the printer, the user confirmed the built-in PDF label, alongside the already confirmed ZPL label and laser PDF. Barcode scanning and measured alignment remain separate checks; `SPOOLED` alone is not proof of physical output.
|
||||
|
||||
On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the related printer and device names; approval, availability, format and status fields have named choices.
|
||||
On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Remove printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the related printer and device names; approval, availability, format and status fields have named choices.
|
||||
|
||||
Removing is archiving, not deleting. **Remove printers** sets **Removed at** on the selected printers, clears their approval and exposure, and takes them out of the operator catalog, claims, the `PRINTERS` frame and every device/printer selector; their rows and job history stay. The Printers and Print devices tables have no server-side default filter, so add the filter **Removed at is empty** to the view you use day to day. If the device shares a removed queue again, the next inventory brings it back exposed but **not approved**, exactly like a newly discovered queue. **Remove device** (a device action that requires confirmation) revokes the device's access through the existing REVOKE control, then archives the device and all of its printers; an already revoked device is archived without a new command. A removed device cannot be restored; connect the computer again with a fresh enrollment, which creates a new device. Retention cleanup later hard-deletes removed printers and devices that no job references (see [storage and retention](docs/STORAGE-AND-RETENTION.md)).
|
||||
|
||||
Metadata names follow QBit conventions: tables and data sources use `printing_*`, while processes/widgets use `printing...`. The initial namespace is fixed to `printing`. The QBit preserves the host's `QBrandingMetaData`; customer identity belongs in the host branding, not a custom QBit build.
|
||||
|
||||
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. Removing a queue from local selection makes it unavailable without deleting its approval/history. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
|
||||
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. A connector whose HELLO advertises `printer-approval-v1` (extension 2.4.0, connector 1.2.0) receives a `PRINTERS` frame listing `{ printerId, enabled }` for every queue in its latest accepted inventory after each accepted inventory and whenever an approval changes through any path; older connectors never receive it and keep reading the operator catalog. Removing a queue from local selection makes it unavailable without deleting its approval/history; removing a printer in QQQ additionally drops it from the frame until the device shares it again. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
|
||||
|
||||
Application code calls `printing.submit(JSONObject)` with the shared `SubmitJobRequest` fields. A stable business idempotency key returns the same job when retried; changing document/options/target under the same key fails. PDF is sent as bytes encoded once in base64; ZPL is UTF-8 printer-ready bytes. Default decoded limits are 100 MiB for PDF and 20 MiB for ZPL, both configurable in the native Printing connection and limits process. Queue approval and format compatibility are checked before insertion.
|
||||
|
||||
@@ -143,31 +147,35 @@ 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`. `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.
|
||||
The returned ID identifies durable queue acceptance. Check job status asynchronously. The [protocol v1 specification](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/branch/main/README.md) defines HTTP endpoints, JSON fields, integrity checks and lifecycle semantics.
|
||||
|
||||
For durable QQQ jobs register `PrintEnqueueEffectTarget` as an `IDEMPOTENT_EXTERNAL` target in `DurableJobsMetaData`, then emit the submit-request payload. The target replaces the payload idempotency key with the durable effect key. Its SENT result means accepted into the queue, not physical output. This implementation uses its own JDBC transaction; do not register it as LOCAL_TX. Authorize the destination in the emitting process before writing the effect. QQQ's durable dispatcher runs with a `QSystemUserSession` and does not propagate the user's tenant keys. For that trusted outbox path only, the target resolves the service configured on the dispatcher's current QQQ instance; that service fixes the tenant and rejects devices belonging to another tenant. The payload cannot choose a service or tenant. A cached target resolves the current instance on every call. Inline user calls retain the `printing_tenant` scope check, and native management processes (including enrollment) still require both administrator permission and matching tenant scope. Do not expose direct effect emission through an arbitrary user-supplied target/payload endpoint.
|
||||
|
||||
## Deliberate first-version limits
|
||||
## Deployment boundaries
|
||||
|
||||
The implemented path targets an explicit device/printer. Logical destination bindings, cancellation, reprint workflow, automatic reconciliation, clustered throughput optimization, retention and alerts are follow-ups. Documents are durably stored as base64 in the database for this first path; object-storage integration is deferred. Custom paper dimensions are rejected; configure the Windows queue. Received/submitting jobs are not automatically offered again. The queue records SPOOLED honestly and does not assert physical delivery.
|
||||
Jobs target an explicit device/printer. Logical destination routing and a distributed real-time bus belong to the integrating host. The release includes cancellation of queued jobs, explicit reprints, owned spool-cancellation commands, durable receipt reconciliation, S3 delivery storage and scheduled retention. Received/submitting jobs are not automatically offered again; `SPOOLED` never asserts physical delivery.
|
||||
|
||||
The protocol demo and synthetic native UI preview are separate local hosts. Attaching the QBit to a deployed application and its real authentication/facility policy remains a host integration task. PostgreSQL behavior, production deployment, upgrades, cancellation/reprint and broad printer-driver coverage are not certified by the initial H2/laser tests.
|
||||
Job paper (`paperWidthMm` and `paperHeightMm`, both positive and at most 2000, never combined with `mediaProfile`) is supported by the matching Windows 1.0.1 connector. The built-in 4 × 1 and 4 × 2 PDF tests use 101.6 × 25.4 / 50.8 mm. Without explicit paper or a reported media selection, jobs follow the Windows queue default. See [media selection](docs/OPERATOR-CATALOG.md).
|
||||
|
||||
## Verified initial path
|
||||
The protocol demo and synthetic native UI preview are separate local hosts. Attaching the QBit to a deployed application requires that host's real authentication, facility policy, database, storage and supervision. PostgreSQL and multiple QQQ nodes are not qualified by the local H2/MinIO fixture.
|
||||
|
||||
On 2026-10-29, both the development Windows connector and the installed LocalSystem service printed a PDF to a local laser printer. The user confirmed both physical pages. MSI installation/start and temporary uninstall passed. Zebra hardware testing is deferred until a Zebra is connected. A SPOOLED status alone is never used as proof of physical output.
|
||||
## Physical output
|
||||
|
||||
The user confirmed physical laser PDF and Zebra ZPL/PDF output during development, including a 4 × 1 test. A configured stock profile does not prove a physical result on another printer or stock; inspect the first output after changing media. Historical acceptance records remain in [session history](docs/SESSION-STATE.md).
|
||||
|
||||
## CI and releases
|
||||
|
||||
Pull requests and `main` pushes run Java 21/Maven wrapper verification with the immutable sibling protocol checkout. A tag matching the POM version runs build/tests and the secrets, workflow, dependency and malicious-package security gates before publishing the tested JAR, POM and `SHA256SUMS` as GitHub release assets. Build verification enables the `ui-demo` profile used by the local host. Maven generates its selected compile/runtime graph as a CycloneDX SBOM, including frontend assets. Trivy and OSV scan that graph; a guard rejects missing or different package versions, missing frontend assets, and empty inventories. CI retains the resolved graph and scanner evidence. OSV uses `--all-packages`; its pinned 2.6.0 reporter [omits Maven groups](https://github.com/google/osv-scanner/issues/3099), so the guard checks every reported artifact/version and rejects ambiguous names while the verified input retains full package URLs. Hyphenated versions create prereleases. The packaged JAR includes the repository LICENSE and NOTICE under `META-INF`, alongside the existing font license and diagnostic assets. The workflow does not publish to Maven Central, deploy a QQQ application or print to hardware. The [0.1.0-rc.1 release](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.0-rc.1) passed these hosted gates.
|
||||
Gitea is the source, pull-request, CI and release authority. The thin `.gitea/workflows/qrun-release.yaml` delegates to the protected `qrun-release-v1` channel with Java 21, the Maven wrapper, `ui-demo` and the immutable protocol fixture checkout. Required verify, secrets and gate checks precede merge; successful main builds capture the tested Maven reactor for the trusted registry publisher. Maintainers publish the corresponding tagged Gitea release from those verified bytes. See [release operations](docs/RELEASES.md#maintainer-release-path).
|
||||
|
||||
GitHub is a shadow/archive. Its Actions were retired for this repository on 2026-11-04; checked-in `.github` workflows describe the former pipeline and do not publish current printing releases. The separate Windows repository retains its signing/update-feed workflow. The 1.0.0 server release does not claim GitHub attestations or Maven Central publication. The JAR includes LICENSE, NOTICE and font/diagnostic asset licenses; publication does not deploy a QQQ host or print to hardware.
|
||||
|
||||
## Printing home
|
||||
|
||||
The admin-only Printing home uses stock QQQ widgets: a purpose/scope/help header, actionable exceptions, four health tiles, and a full-width pending/recent work list. Counts and record links use QQQ queries under the viewer's tenant. Device disconnection means no heartbeat for 90 seconds; job failure tiles cover jobs created in the past 24 hours. Older failures remain in Print jobs. An empty installation explains enrollment and printer setup.
|
||||
|
||||
This RC implements the happy path of the [QQQ dashboard standard](https://github.com/QRun-IO/qqq/blob/main/docs/DASHBOARD-STANDARD.md), with explicit remaining gaps: RC.20 carries role/priority hints in widget default values; attention uses scoped database queries and 60-second refresh rather than the core live attention feed; dismiss/snooze, interactive URL-backed page scope, and stale/retry presentation depend on further core/frontend integration. It has not passed the standard's full responsive/accessibility audit. No custom frontend or HTML dashboard is used.
|
||||
The home uses the standard QQQ frontend widgets and scoped queries. Its timed dashboard refresh updates the visible summary; job delivery uses the independent real-time WebSocket and does not wait for a dashboard refresh. No custom frontend or HTML dashboard is required.
|
||||
|
||||
### Operator actions (0.1.1-rc.1)
|
||||
### Operator actions
|
||||
|
||||
The Windows companion can request test prints and explicit reprints through device-scoped authenticated routes. The same printer approval/exposure rules apply. Upgrade the QBit and connector together for these additive protocol features. Reprints create a new job; uncertain/spooled originals require duplicate-output consent and remain unchanged.
|
||||
|
||||
@@ -177,6 +185,6 @@ QQQ's **Print jobs** table and records expose **Print again** and **Cancel queue
|
||||
|
||||
All built-in printer test actions share `PrintingTestDocuments`: QQQ branding, the official [Query dog mascot](https://qrun.io/query), large warehouse-style identifiers and embedded JetBrains Mono Bold with a dotted zero and distinct 1/I/l. Query's original artwork and aspect ratio are preserved in PDF and thermal output; [asset provenance](src/main/resources/com/kingsrook/qbits/printing/images/README.md) ships with the resource. The font is included under SIL OFL 1.1; its license and provenance ship in the JAR. PDF embeds a font subset. ZPL renders the same glyphs into document-local, row-aligned graphics below Zebra's 99,999-byte per-field limit; it does not download or save a printer font.
|
||||
|
||||
Native record and selected-table actions expose a possible-value stock/resolution selector. Letter and A4 are PDF-only. Label profiles are 4 × 6 inches at 203, 300 or 600 dpi; PDF label output is always 101.6 × 152.4 mm. Select matching stock and printer resolution. Bulk actions apply the chosen profile to every selected destination, so group compatible printers. The document does not reconfigure Windows stock, calibrate the printer or save device configuration; ZPL width/length are transient format settings.
|
||||
Native record and selected-table actions expose a possible-value stock/resolution selector. Letter and A4 are PDF-only. Label profiles cover 4 × 1, 4 × 2 and 4 × 6 inches at 203, 300 or 600 dpi, with corresponding PDF dimensions of 101.6 × 25.4, 50.8 or 152.4 mm. Select matching loaded stock and printer resolution. Bulk actions apply the chosen profile to every selected destination, so group compatible printers. The document does not change the Windows queue defaults, calibrate the printer or save device configuration; ZPL width/length are transient format settings. Direct-thermal stock needs the driver's Direct Thermal setting, without a ribbon.
|
||||
|
||||
The device test-print API accepts optional `testProfile` with the same choices. Existing clients default to Letter PDF or 203-dpi 4 × 6 ZPL. Invalid profiles fail before enqueueing. Test actions use the ordinary durable queue and existing printer exposure/approval rules; discovery never automatically prints. A ZPL barcode, glyph comparisons and size/alignment marks support physical inspection. Rendering previews and spool acceptance do not prove paper or scanner quality.
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
# Security
|
||||
|
||||
Report vulnerabilities privately through [GitHub vulnerability reporting](https://github.com/QRun-IO/qbit-printing/security/advisories/new), or `contact@kingsrook.com` if that channel is unavailable. Include affected versions, a minimal synthetic reproduction and expected versus actual authorization behavior. Do not put enrollment codes, bearer credentials, customer documents or exploit details in public issues.
|
||||
Report vulnerabilities privately through [GitHub vulnerability reporting](https://github.com/QRun-IO/qbit-printing/security/advisories/new), or `contact@kingsrook.com` if that channel is unavailable. Include affected versions, a minimal synthetic reproduction and expected versus actual authorization behavior. Ordinary source changes and pull requests belong in Gitea. Do not put enrollment codes, bearer credentials, customer documents or exploit details in public issues.
|
||||
|
||||
The current prerelease targets QQQ 4.1+. Fixes target the current prerelease; older QQQ versions are outside scope.
|
||||
The supported release line is 1.0.x for QQQ 4.1+, currently built against QQQ 4.1.0-RC.21. Report the exact QBit, QQQ and Windows connector versions. Older QQQ versions are outside scope.
|
||||
|
||||
The host must provide authenticated management authorization and the correct tenant/facility context. Native QQQ operations require `printing_admin` and a matching `printing_tenant` session security value. Device credentials only authorize their device's queue/content/status operations. HTTPS is required for remote deployment; the supplied HTTP demos deliberately bind to loopback.
|
||||
The host must provide authenticated management authorization and the correct tenant/facility context. Native QQQ operations require `printing_admin` and a matching `printing_tenant` session security value. Native device operations require both the current QQQ user session and bound-device proof; a device token alone cannot authorize them. Operator catalog/submission uses `printing_operator` or administration plus native destination/process permissions; it grants no general device administration. See [authorization and controls](docs/OPERATIONS.md#device-controls-and-durable-action-receipts). HTTPS is required for remote deployment; the supplied HTTP demos deliberately bind to loopback.
|
||||
|
||||
The UI demonstration uses a fixed mock identity and synthetic data and must remain local. It registers no connector transport routes. RAW ZPL is printer-control input: submit trusted generated labels, not arbitrary untrusted printer commands. Stored documents can contain personal information. See [storage and retention](docs/STORAGE-AND-RETENTION.md) for dedicated bucket boundaries, tenant isolation and the default 365-day policy.
|
||||
@@ -0,0 +1,206 @@
|
||||
# KofTwentyTwo .NET code style (standards: K22-CODE-CS): the Kingsrook code style
|
||||
# (https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md) applied to C#.
|
||||
# Copy to the repository root. Severities of "warning" fail the build because the
|
||||
# companion Directory.Build.props sets TreatWarningsAsErrors and EnforceCodeStyleInBuild,
|
||||
# so local `dotnet build` and CI enforce exactly the same rules. The resharper_* keys
|
||||
# apply the Kingsrook blank-line rules in Rider and ReSharper, which .NET's own
|
||||
# formatter cannot express.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
# Kingsrook: always 3 spaces, never tabs.
|
||||
indent_size = 3
|
||||
tab_width = 3
|
||||
|
||||
# YAML stays at 2: the YAML ecosystem (GitHub Actions, Dependabot) assumes it.
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Windows-only script formats require CRLF.
|
||||
[*.{cmd,bat}]
|
||||
end_of_line = crlf
|
||||
|
||||
[*.cs]
|
||||
# All fixture sources are repository-owned Apache-2.0 code.
|
||||
file_header_template = Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0
|
||||
dotnet_diagnostic.IDE0073.severity = warning
|
||||
#### Formatting (IDE0055 makes every rule below a build error) ####
|
||||
dotnet_diagnostic.IDE0055.severity = warning
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
csharp_new_line_before_members_in_object_initializers = true
|
||||
csharp_new_line_before_members_in_anonymous_types = true
|
||||
csharp_new_line_between_query_expression_clauses = true
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_switch_labels = true
|
||||
csharp_indent_labels = one_less_than_current
|
||||
csharp_indent_block_contents = true
|
||||
csharp_indent_braces = false
|
||||
csharp_space_after_cast = false
|
||||
# Kingsrook: no space between a control keyword and its parenthesis: if(x), for(...), catch(...).
|
||||
csharp_space_after_keywords_in_control_flow_statements = false
|
||||
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||
csharp_space_between_parentheses = false
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_after_comma = true
|
||||
csharp_space_before_comma = false
|
||||
csharp_space_after_semicolon_in_for_statement = true
|
||||
csharp_preserve_single_line_statements = false
|
||||
csharp_preserve_single_line_blocks = false
|
||||
|
||||
#### Kingsrook blank lines and layout (Rider / ReSharper; .NET formatter cannot express) ####
|
||||
resharper_blank_lines_around_method = 3
|
||||
resharper_blank_lines_around_property = 3
|
||||
resharper_blank_lines_around_auto_property = 1
|
||||
resharper_blank_lines_around_type = 3
|
||||
resharper_blank_lines_after_using_list = 2
|
||||
resharper_keep_blank_lines_in_code = 1
|
||||
resharper_keep_blank_lines_in_declarations = 3
|
||||
resharper_brace_style = next_line
|
||||
resharper_indent_size = 3
|
||||
resharper_space_after_keywords_in_control_flow_statements = false
|
||||
resharper_max_line_length = 999
|
||||
resharper_wrap_before_first_method_call = true
|
||||
resharper_wrap_chained_method_calls = chop_if_long
|
||||
|
||||
#### Usings and namespaces ####
|
||||
# IDE0005 needs GenerateDocumentationFile=true to run during build (set in Directory.Build.props).
|
||||
dotnet_diagnostic.IDE0005.severity = warning
|
||||
csharp_using_directive_placement = outside_namespace:warning
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
dotnet_diagnostic.IDE0161.severity = warning
|
||||
|
||||
#### var: only when the type is apparent from the right-hand side ####
|
||||
csharp_style_var_for_built_in_types = false:warning
|
||||
csharp_style_var_when_type_is_apparent = true:warning
|
||||
csharp_style_var_elsewhere = false:warning
|
||||
dotnet_diagnostic.IDE0007.severity = warning
|
||||
dotnet_diagnostic.IDE0008.severity = warning
|
||||
|
||||
#### Braces, modifiers, readonly ####
|
||||
csharp_prefer_braces = true:warning
|
||||
dotnet_diagnostic.IDE0011.severity = warning
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||
dotnet_diagnostic.IDE0040.severity = warning
|
||||
dotnet_style_readonly_field = true:warning
|
||||
dotnet_diagnostic.IDE0044.severity = warning
|
||||
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:warning
|
||||
dotnet_diagnostic.IDE0036.severity = warning
|
||||
|
||||
#### this. qualification: never ####
|
||||
dotnet_style_qualification_for_field = false:warning
|
||||
dotnet_style_qualification_for_property = false:warning
|
||||
dotnet_style_qualification_for_method = false:warning
|
||||
dotnet_style_qualification_for_event = false:warning
|
||||
dotnet_diagnostic.IDE0003.severity = warning
|
||||
|
||||
#### Language keywords over framework type names ####
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
|
||||
dotnet_style_predefined_type_for_member_access = true:warning
|
||||
dotnet_diagnostic.IDE0049.severity = warning
|
||||
|
||||
#### Dead code and redundancy ####
|
||||
dotnet_diagnostic.IDE0051.severity = warning # unused private member
|
||||
dotnet_diagnostic.IDE0052.severity = warning # unread private member
|
||||
dotnet_diagnostic.IDE0059.severity = warning # unnecessary assignment
|
||||
dotnet_diagnostic.IDE0060.severity = warning # unused parameter
|
||||
dotnet_diagnostic.IDE0035.severity = warning # unreachable code
|
||||
dotnet_diagnostic.IDE0004.severity = warning # unnecessary cast
|
||||
dotnet_diagnostic.IDE0100.severity = warning # unnecessary equality operator
|
||||
dotnet_diagnostic.IDE0080.severity = warning # unnecessary suppression operator
|
||||
dotnet_diagnostic.IDE0079.severity = warning # unnecessary suppression
|
||||
dotnet_code_quality_unused_parameters = all
|
||||
|
||||
#### Modern language features (suggestions: shown in IDE, not enforced) ####
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
csharp_style_prefer_primary_constructors = true:suggestion
|
||||
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_properties = true:suggestion
|
||||
|
||||
#### Naming ####
|
||||
dotnet_naming_rule.interfaces_begin_with_i.symbols = interfaces
|
||||
dotnet_naming_rule.interfaces_begin_with_i.style = i_prefix_pascal_case
|
||||
dotnet_naming_rule.interfaces_begin_with_i.severity = warning
|
||||
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
||||
dotnet_naming_style.i_prefix_pascal_case.required_prefix = I
|
||||
dotnet_naming_style.i_prefix_pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_rule.type_parameters_begin_with_t.symbols = type_parameters
|
||||
dotnet_naming_rule.type_parameters_begin_with_t.style = t_prefix_pascal_case
|
||||
dotnet_naming_rule.type_parameters_begin_with_t.severity = warning
|
||||
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
|
||||
dotnet_naming_style.t_prefix_pascal_case.required_prefix = T
|
||||
dotnet_naming_style.t_prefix_pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_rule.types_and_members_are_pascal_case.symbols = types_and_members
|
||||
dotnet_naming_rule.types_and_members_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning
|
||||
dotnet_naming_symbols.types_and_members.applicable_kinds = namespace, class, struct, enum, delegate, property, method, event
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel.style = underscore_camel_case
|
||||
dotnet_naming_rule.private_fields_are_underscore_camel.severity = warning
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, private_protected
|
||||
dotnet_naming_symbols.private_fields.required_modifiers =
|
||||
dotnet_naming_style.underscore_camel_case.required_prefix = _
|
||||
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
|
||||
|
||||
dotnet_naming_rule.private_static_fields_are_s_camel.symbols = private_static_fields
|
||||
dotnet_naming_rule.private_static_fields_are_s_camel.style = s_camel_case
|
||||
dotnet_naming_rule.private_static_fields_are_s_camel.severity = warning
|
||||
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, private_protected
|
||||
dotnet_naming_symbols.private_static_fields.required_modifiers = static
|
||||
dotnet_naming_style.s_camel_case.required_prefix = s_
|
||||
dotnet_naming_style.s_camel_case.capitalization = camel_case
|
||||
|
||||
dotnet_naming_rule.constants_are_pascal_case.symbols = constants
|
||||
dotnet_naming_rule.constants_are_pascal_case.style = pascal_case
|
||||
dotnet_naming_rule.constants_are_pascal_case.severity = warning
|
||||
dotnet_naming_symbols.constants.applicable_kinds = field, local
|
||||
dotnet_naming_symbols.constants.required_modifiers = const
|
||||
|
||||
#### Analyzer adjustments (each one justified) ####
|
||||
# CA1716: flags members named like VB keywords (e.g. ILog.Error). KofTwentyTwo is C#-only.
|
||||
dotnet_diagnostic.CA1716.severity = none
|
||||
# MA0004 (ConfigureAwait): app code runs on UI threads where ConfigureAwait(false) is
|
||||
# wrong by default; libraries opt back in with a [*.cs] override in their own folder.
|
||||
dotnet_diagnostic.MA0004.severity = none
|
||||
# MA0049 (type name matches namespace): conflicts with the AppName/AppName.Core layout.
|
||||
dotnet_diagnostic.MA0049.severity = none
|
||||
|
||||
[**/{Generated,obj}/**.cs]
|
||||
generated_code = true
|
||||
|
||||
[{tests,test}/**.cs]
|
||||
# xUnit's Method_Scenario_Expectation names use underscores by convention.
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
# Inline arrays keep test data at the assertion; each test runs once.
|
||||
dotnet_diagnostic.CA1861.severity = none
|
||||
# Test projects are not documented API.
|
||||
dotnet_diagnostic.CS1591.severity = none
|
||||
# MA0048 (one type per file): a test file groups the test classes and fakes of one feature.
|
||||
dotnet_diagnostic.MA0048.severity = none
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Keep the SDK formatter identical on Windows and Linux checkouts.
|
||||
*.cs text eol=lf
|
||||
+21
-9
@@ -1,12 +1,24 @@
|
||||
# Local Authentik reference
|
||||
|
||||
Run the optional real-provider checks with Docker Compose and PowerShell 7.4+.
|
||||
The pinned fixture images use Linux/amd64; other hosts need Docker's corresponding
|
||||
Linux-container support or emulation:
|
||||
The released application uses QQQ 4.1.0-RC.21, including the native session-login
|
||||
grant. A production host configures QQQ's standard OAuth2/Authentik provider,
|
||||
trusted HTTPS, redirect registration and current user/tenant permissions. The
|
||||
Windows connector discovers that setup through the QQQ UI/UX API; provider
|
||||
refresh credentials remain on the server. See [the native authentication
|
||||
contract](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/branch/main/docs/QQQ-AUTHENTICATION.md).
|
||||
|
||||
```powershell
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Authentik
|
||||
```
|
||||
## Historical reference fixture compatibility
|
||||
|
||||
The optional `Invoke-Dev.ps1 -Action Test -Authentik` diagnostic below was written
|
||||
for RC.20. `Invoke-Authentik.ps1` explicitly expects API 1.8 without
|
||||
`nativeSessionLogin`, so that diagnostic is not compatible with the current RC.21
|
||||
application baseline. Do not interpret its expected-absence assertion as a
|
||||
production native-authentication failure or use it as a 1.0 release gate. The
|
||||
separately pinned experimental profile remains available for reproducing its
|
||||
recorded result. No fixture code or pins were changed during documentation closeout.
|
||||
|
||||
The following describes the retained fixture and its recorded scope. It requires
|
||||
Docker Compose, PowerShell 7.4+ and Linux/amd64 container support.
|
||||
|
||||
The command creates a fresh, owned development project, runs the normal QQQ and
|
||||
MinIO smoke test plus the Authentik reference checks, then removes that project's
|
||||
@@ -33,7 +45,7 @@ authentication.
|
||||
S256 capability. A valid state/S256 request with the registered callback
|
||||
continues to the provider login flow without following it. Changing only that
|
||||
callback to an unregistered redirect is rejected before any user login.
|
||||
- The current QQQ RC.20 host advertises UI API 1.8 without `nativeSessionLogin`.
|
||||
- The historical QQQ RC.20 host advertised UI API 1.8 without `nativeSessionLogin`.
|
||||
That expected absence is recorded as a native-client prerequisite failure,
|
||||
not silently replaced by bearer authentication or an unpublished core.
|
||||
|
||||
@@ -89,8 +101,8 @@ denials, logout replay rejection, renewal after expiry and provider revocation
|
||||
with the production Core client. Its synthetic HTTP driver and HTTPS callback
|
||||
receiver do not qualify the installed Windows browser UI or private URI callback.
|
||||
|
||||
Production adoption still requires a compatible published central artifact
|
||||
tuple. Provider refresh tokens stay on the server. The short lifetime test does
|
||||
The released application now adopts the compatible QQQ RC.21 tuple; this
|
||||
historical experimental result does not by itself qualify a different host configuration. Provider refresh tokens stay on the server. The short lifetime test does
|
||||
not prove months or years of unattended use. Do not route the private Windows
|
||||
callback to a live companion whose connection must remain intact.
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<!-- Copyright 2026 Kingsrook, LLC; SPDX-License-Identifier: Apache-2.0 -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<Deterministic>true</Deterministic>
|
||||
<NuGetAudit>true</NuGetAudit>
|
||||
<NuGetAuditMode>all</NuGetAuditMode>
|
||||
<NuGetAuditLevel>low</NuGetAuditLevel>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<CoreAssemblyPath Condition="'$(CoreAssemblyPath)' == ''">/core/Qqq.PrintAgent.Core.dll</CoreAssemblyPath>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(CI)' == 'true'">
|
||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||
<RestoreLockedMode>true</RestoreLockedMode>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
|
||||
</ItemGroup>
|
||||
<Target Name="RequireProductionCore" BeforeTargets="ResolveAssemblyReferences">
|
||||
<Error Condition="!Exists('$(CoreAssemblyPath)')" Text="Build the pinned production Core source first and provide CoreAssemblyPath; fixture stubs are not supported." />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!-- Copyright 2026 Kingsrook, LLC; SPDX-License-Identifier: Apache-2.0 -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.296" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -7,9 +7,11 @@ ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fc
|
||||
RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz
|
||||
RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo
|
||||
RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/
|
||||
WORKDIR /fixture
|
||||
COPY dev/Directory.Build.props dev/Directory.Packages.props dev/.editorconfig dev/global.json /fixture/dev/
|
||||
WORKDIR /fixture/dev/client
|
||||
COPY dev/client .
|
||||
RUN dotnet build -c Release --nologo -o /out
|
||||
RUN dotnet build -c Release -p:RestoreLockedMode=true --nologo -o /out
|
||||
RUN dotnet format --verify-no-changes --severity warn --no-restore
|
||||
RUN dotnet /out/Workload.dll --check-discovery
|
||||
# SDK image is reused deliberately: one pinned image, no hidden host SDK dependency.
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
|
||||
|
||||
@@ -7,10 +7,11 @@ COPY pom.xml mvnw ./
|
||||
COPY .mvn .mvn
|
||||
COPY checkstyle checkstyle
|
||||
COPY src src
|
||||
COPY dev/java dev/java
|
||||
COPY dev/native-auth/java dev/native-auth/java
|
||||
COPY LICENSE NOTICE ./
|
||||
RUN chmod +x mvnw && ./mvnw -B -Pui-demo -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime
|
||||
COPY dev/java /fixture-src
|
||||
RUN mkdir -p /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes /fixture-src/*.java
|
||||
RUN mkdir -p /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes dev/java/*.java
|
||||
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0
|
||||
ARG SOURCE_COMMIT=unknown
|
||||
|
||||
+29
-9
@@ -32,13 +32,13 @@ The host provides native printing screens, settings, a dedicated MinIO document
|
||||
bucket and scheduled retention. No printer is enrolled automatically in this host.
|
||||
The test workload uses a separate disposable database and object store.
|
||||
|
||||
Add `-Authentik` when creating an environment or running a fresh test to include
|
||||
real Authentik/PostgreSQL. See [the provider checks and remaining native grant
|
||||
prerequisite](AUTHENTIK.md). Provider discovery does not establish successful OIDC
|
||||
user login or refresh; the current QQQ RC.20 API lacks that native-client grant.
|
||||
The current application dependency is QQQ RC.21, which supplies native session
|
||||
login. The optional `-Authentik` diagnostic still asserts the older RC.20 API 1.8
|
||||
absence of that feature; it is a retained historical check, not a current RC.21
|
||||
login test. See [provider setup and fixture compatibility](AUTHENTIK.md).
|
||||
Authentik uses PostgreSQL; the printing host itself uses H2, not PostgreSQL or MySQL.
|
||||
|
||||
```powershell
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Authentik
|
||||
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Workload 10000
|
||||
```
|
||||
|
||||
@@ -126,8 +126,28 @@ It is opt-in, Test-only, and does not change the default released QQQ dependency
|
||||
|
||||
## Continuous integration
|
||||
|
||||
The development workflow runs the Pester and PowerShell analysis checks on
|
||||
Windows and Ubuntu for every pull request and main-branch push. Ubuntu also runs
|
||||
the real 40-job/S3 Docker smoke. The longer experimental native-auth profile is
|
||||
an explicit manual workflow option; it is not silently substituted for the
|
||||
Primary Gitea CI runs the shared Maven verification with `ui-demo` and the pinned
|
||||
protocol fixtures. GitHub Actions were retired for this repository on 2026-11-04.
|
||||
The checked-in `.github/workflows/development.yml` records the former Windows/Linux
|
||||
Pester, image-build and Docker checks; it is not an active CI guarantee. The
|
||||
commands above remain available for intentional local development checks.
|
||||
|
||||
Maven Checkstyle checks all four Java fixture sources in `dev/java` and
|
||||
`dev/native-auth/java` during `validate`. They are deliberately **not** Maven
|
||||
compiler sources: the ordinary fixture uses the released QQQ tuple, while the
|
||||
native host compiles only inside its explicitly selected candidate image. Both
|
||||
server Dockerfiles copy both roots before Maven runs so neither image can skip
|
||||
this style coverage.
|
||||
|
||||
The ten C# fixture files share `dev/.editorconfig`, strict build properties,
|
||||
central Meziantou 3.0.296, and committed package locks. The existing pinned SDK
|
||||
image contains .NET SDK 10.0.403, matching `dev/global.json`. Both image builds
|
||||
perform locked, vulnerability-audited restore, warnings-as-errors compilation,
|
||||
formatter verification, and their nine or 12 fake-HTTP discovery cases. Core is
|
||||
still built from the exact public source archive above; no stubs or runtime
|
||||
package dependency were added. For a local build, supply the real pinned Core
|
||||
DLL through `-p:CoreAssemblyPath=/absolute/path/Qqq.PrintAgent.Core.dll`.
|
||||
|
||||
The longer experimental native-auth profile is
|
||||
an explicit local command; it is not silently substituted for the
|
||||
released dependency baseline.
|
||||
@@ -1,12 +1,24 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
namespace Printing.Development;
|
||||
|
||||
|
||||
|
||||
/// <summary>Exercises fixture compatibility decisions using credential-free fake HTTP responses.</summary>
|
||||
internal static class DiscoveryChecks
|
||||
{
|
||||
private static readonly string[] s_features = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
|
||||
|
||||
|
||||
|
||||
private static readonly string[] s_channels = ["api"];
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify supported stable versions and reject malformed or incompatible versions.</summary>
|
||||
internal static async Task RunAsync()
|
||||
{
|
||||
(string Version, bool Accepted)[] cases =
|
||||
@@ -25,8 +37,14 @@ internal static class DiscoveryChecks
|
||||
await WorkloadDiscovery.DiscoverRealtimeAsync(client, CancellationToken.None);
|
||||
accepted = true;
|
||||
}
|
||||
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired) { }
|
||||
catch(InvalidDataException) { }
|
||||
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired)
|
||||
{
|
||||
// Expected for incompatible versions: the case stays rejected and the comparison below judges it.
|
||||
}
|
||||
catch(InvalidDataException)
|
||||
{
|
||||
// Expected for malformed versions: the case stays rejected and the comparison below judges it.
|
||||
}
|
||||
if(accepted != expected || handler.Requests != 1)
|
||||
{
|
||||
throw new InvalidDataException($"Discovery compatibility case {version} failed.");
|
||||
@@ -35,14 +53,29 @@ internal static class DiscoveryChecks
|
||||
Console.WriteLine($"Discovery compatibility checks passed: {cases.Length}.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Supply one version response and reject any premature credential transmission.</summary>
|
||||
private sealed class DiscoveryHandler(string version) : HttpMessageHandler
|
||||
{
|
||||
internal int Requests { get; private set; }
|
||||
internal int Requests
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Return only the synthetic discovery response and count the request.</summary>
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if(request.Method != HttpMethod.Get || request.RequestUri?.AbsolutePath != "/qqq/v1/version"
|
||||
|| request.Headers.Contains("Cookie") || request.Headers.Contains("Authorization"))
|
||||
if(request.Method != HttpMethod.Get
|
||||
|| !string.Equals(request.RequestUri?.AbsolutePath, "/qqq/v1/version", StringComparison.Ordinal)
|
||||
|| request.Headers.Contains("Cookie")
|
||||
|| request.Headers.Contains("Authorization"))
|
||||
{
|
||||
throw new InvalidOperationException("Discovery must precede authentication.");
|
||||
}
|
||||
@@ -50,8 +83,8 @@ internal static class DiscoveryChecks
|
||||
string json = JsonSerializer.Serialize(new
|
||||
{
|
||||
apiVersion = "1.8.0",
|
||||
features = new[] { "version", "metaData", "processes", "processCancel", "extensions", "widgets" },
|
||||
clientChannels = new[] { "api" },
|
||||
features = s_features,
|
||||
clientChannels = s_channels,
|
||||
extensions = new[] { new { name = "printing", version, pathPrefix = "/qbit/printing" } }
|
||||
});
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
|
||||
|
||||
+611
-135
@@ -1,7 +1,5 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.WebSockets;
|
||||
@@ -10,140 +8,618 @@ using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
internal static class Program {
|
||||
static int Completed; static readonly byte[] Expected=Encoding.ASCII.GetBytes("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS"+new string(' ',980)+"^XZ");
|
||||
static async Task<int> Main(string[] args) {
|
||||
if(args is ["--check-discovery"]) { await DiscoveryChecks.RunAsync(); return 0; }
|
||||
string folder=Path.GetFullPath(args[0]); string stage="input";
|
||||
using var timeout=new CancellationTokenSource(TimeSpan.FromMinutes(18));
|
||||
List<Lane> lanes=[]; long peak=0; var timer=Stopwatch.StartNew();
|
||||
try {
|
||||
using JsonDocument input=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(folder,"input.json")));
|
||||
stage="native_login_and_channels";
|
||||
bool idle=input.RootElement.GetProperty("idle").GetBoolean(); int perLane=input.RootElement.GetProperty("jobsPerLane").GetInt32(); int total=perLane*8; int index=0;
|
||||
foreach(var value in input.RootElement.GetProperty("lanes").EnumerateArray()) {
|
||||
Uri origin=new(value.GetProperty("origin").GetString()!);
|
||||
Require(origin.IsLoopback && origin.Port is not (18081 or 18086),"disposable origin");
|
||||
var client=new QqqSessionClient(origin,allowLoopbackHttp:true);
|
||||
await WorkloadDiscovery.DiscoverRealtimeAsync(client,timeout.Token);
|
||||
await client.GetAuthenticationMetadataAsync(timeout.Token);
|
||||
await client.SignInWithPasswordAsync(value.GetProperty("username").GetString()!,value.GetProperty("password").GetString()!,timeout.Token);
|
||||
await client.BindDeviceAsync(new(value.GetProperty("deviceId").GetString()!,value.GetProperty("deviceToken").GetString()!),cancellationToken:timeout.Token);
|
||||
var binding=await client.GetSessionIdentityAsync(timeout.Token);
|
||||
Require(binding.TenantId==value.GetProperty("tenant").GetString(),"authoritative tenant");
|
||||
var connection=await Connection.Open(client,timeout.Token);
|
||||
lanes.Add(new(client,connection,Path.Combine(folder,"lane-"+index++),perLane,folder));
|
||||
namespace Printing.Development;
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs real Core network workers against synthetic Java services without a physical printer.</summary>
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_reportOptions = new() { WriteIndented = true };
|
||||
|
||||
|
||||
|
||||
private static readonly string[] s_capabilities = ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL"];
|
||||
|
||||
|
||||
|
||||
private static int s_completed;
|
||||
|
||||
|
||||
|
||||
private static readonly byte[] s_expected = Encoding.ASCII.GetBytes("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS" + new string(' ', 980) + "^XZ");
|
||||
|
||||
|
||||
|
||||
/// <summary>Run the bounded workload or fake-HTTP checks and always signal completion to the launcher.</summary>
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
if(args is ["--check-discovery"])
|
||||
{
|
||||
await DiscoveryChecks.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
string folder = Path.GetFullPath(args[0]);
|
||||
string stage = "input";
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(18));
|
||||
List<Lane> lanes = [];
|
||||
long peak = 0;
|
||||
var timer = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var input = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(folder, "input.json")));
|
||||
stage = "native_login_and_channels";
|
||||
bool idle = input.RootElement.GetProperty("idle").GetBoolean();
|
||||
int perLane = input.RootElement.GetProperty("jobsPerLane").GetInt32();
|
||||
int total = perLane * 8;
|
||||
await OpenLanesAsync(input.RootElement, lanes, folder, perLane, timeout.Token);
|
||||
bool denied = await RejectForeignProofAsync(input.RootElement, timeout.Token);
|
||||
stage = "workload";
|
||||
peak = await RunLanesAsync(lanes, folder, total, timeout.Token);
|
||||
stage = "logout";
|
||||
foreach(Lane lane in lanes)
|
||||
{
|
||||
await lane.Client.SignOutAsync(timeout.Token);
|
||||
}
|
||||
await WriteSuccessAsync(folder, lanes, total, idle, denied, peak, timer.Elapsed.TotalSeconds);
|
||||
Console.WriteLine("PASS real Core/Java network lifecycles; fake printer only");
|
||||
return 0;
|
||||
}
|
||||
catch(Exception error)
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "result.json"), JsonSerializer.Serialize(new
|
||||
{
|
||||
success = false,
|
||||
stage,
|
||||
error = error.GetType().Name,
|
||||
reason = error is InvalidDataException ? error.Message : null,
|
||||
completed = lanes.Sum(l => l.Printer.Count)
|
||||
}));
|
||||
Console.Error.WriteLine("FAIL " + stage + ": " + error.GetType().Name);
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach(Lane lane in lanes)
|
||||
{
|
||||
await lane.DisposeAsync();
|
||||
}
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "done"), "done");
|
||||
}
|
||||
}
|
||||
Require(lanes.Count==8,"eight lanes");
|
||||
// Authoritative native proof check: tenant A cannot bind a real tenant B device.
|
||||
var foreign=input.RootElement.GetProperty("lanes")[4]; bool denied=false;
|
||||
using(var outsider=new QqqSessionClient(new Uri(input.RootElement.GetProperty("lanes")[0].GetProperty("origin").GetString()!),allowLoopbackHttp:true)) {
|
||||
await outsider.DiscoverAsync(timeout.Token);await outsider.GetAuthenticationMetadataAsync(timeout.Token);
|
||||
await outsider.SignInWithPasswordAsync("operator",input.RootElement.GetProperty("lanes")[0].GetProperty("password").GetString()!,timeout.Token);
|
||||
try {await outsider.BindDeviceAsync(new(foreign.GetProperty("deviceId").GetString()!,foreign.GetProperty("deviceToken").GetString()!),cancellationToken:timeout.Token);}catch(QqqSessionException){denied=true;}
|
||||
Require(denied,"cross tenant proof refused");await outsider.SignOutAsync(timeout.Token);
|
||||
|
||||
|
||||
|
||||
/// <summary>Authenticate and bind eight synthetic lanes before admitting fixture work.</summary>
|
||||
private static async Task OpenLanesAsync(JsonElement input, List<Lane> lanes, string folder, int perLane, CancellationToken token)
|
||||
{
|
||||
int index = 0;
|
||||
foreach(JsonElement value in input.GetProperty("lanes").EnumerateArray())
|
||||
{
|
||||
Uri origin = new(value.GetProperty("origin").GetString()!);
|
||||
Require(origin.IsLoopback && origin.Port is not (18081 or 18086), "disposable origin");
|
||||
var client = new QqqSessionClient(origin, allowLoopbackHttp: true);
|
||||
await WorkloadDiscovery.DiscoverRealtimeAsync(client, token);
|
||||
await client.GetAuthenticationMetadataAsync(token);
|
||||
await client.SignInWithPasswordAsync(value.GetProperty("username").GetString()!, value.GetProperty("password").GetString()!, token);
|
||||
await client.BindDeviceAsync(new(value.GetProperty("deviceId").GetString()!, value.GetProperty("deviceToken").GetString()!), cancellationToken: token);
|
||||
QqqSessionBinding binding = await client.GetSessionIdentityAsync(token);
|
||||
Require(string.Equals(binding.TenantId, value.GetProperty("tenant").GetString(), StringComparison.Ordinal), "authoritative tenant");
|
||||
Connection connection = await Connection.Open(client, token);
|
||||
lanes.Add(new(client, connection, Path.Combine(folder, "lane-" + index++), perLane, folder));
|
||||
}
|
||||
Require(lanes.Count == 8, "eight lanes");
|
||||
}
|
||||
stage="workload";
|
||||
var tasks=lanes.Select(lane=>lane.Run(timeout.Token)).ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"ready"),"ready",timeout.Token);
|
||||
Task all=Task.WhenAll(tasks);
|
||||
while(!all.IsCompleted) {
|
||||
peak=Math.Max(peak,Process.GetCurrentProcess().WorkingSet64);
|
||||
await Task.WhenAny(all,Task.Delay(1000,timeout.Token));timeout.Token.ThrowIfCancellationRequested();
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify that authenticated tenant A cannot adopt tenant B's device proof.</summary>
|
||||
private static async Task<bool> RejectForeignProofAsync(JsonElement input, CancellationToken token)
|
||||
{
|
||||
// Authoritative native proof check: tenant A cannot bind a real tenant B device.
|
||||
JsonElement foreign = input.GetProperty("lanes")[4];
|
||||
bool denied = false;
|
||||
using(var outsider = new QqqSessionClient(new Uri(input.GetProperty("lanes")[0].GetProperty("origin").GetString()!), allowLoopbackHttp: true))
|
||||
{
|
||||
await outsider.DiscoverAsync(token);
|
||||
await outsider.GetAuthenticationMetadataAsync(token);
|
||||
await outsider.SignInWithPasswordAsync("operator", input.GetProperty("lanes")[0].GetProperty("password").GetString()!, token);
|
||||
try
|
||||
{
|
||||
await outsider.BindDeviceAsync(new(foreign.GetProperty("deviceId").GetString()!, foreign.GetProperty("deviceToken").GetString()!), cancellationToken: token);
|
||||
}
|
||||
catch(QqqSessionException) { denied = true; }
|
||||
Require(denied, "cross tenant proof refused");
|
||||
await outsider.SignOutAsync(token);
|
||||
}
|
||||
return denied;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Write only aggregate network, accounting, and fake-printer evidence.</summary>
|
||||
private static async Task WriteSuccessAsync(string folder, List<Lane> lanes, int total, bool idle, bool denied, long peak, double elapsedSeconds)
|
||||
{
|
||||
double[] latencies = lanes.SelectMany(l => l.Latencies).Order().ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "result.json"), JsonSerializer.Serialize(new
|
||||
{
|
||||
success = true,
|
||||
jobs = total,
|
||||
idle,
|
||||
tenants = 2,
|
||||
devices = 8,
|
||||
elapsedSeconds = elapsedSeconds,
|
||||
jobsPerSecond = total / elapsedSeconds,
|
||||
peakClientWorkingSetBytes = peak,
|
||||
p50EnqueueToAckMillis = latencies[total / 2],
|
||||
p95EnqueueToAckMillis = latencies[(int)(total * 0.95)],
|
||||
p99EnqueueToAckMillis = latencies[(int)(total * 0.99)],
|
||||
realCoreWorker = true,
|
||||
fakePrinterBackend = true,
|
||||
fixtureJournal = true,
|
||||
serverReceiptsDurable = true,
|
||||
crossTenantProofRefused = denied,
|
||||
submissionReplays = idle ? 0 : 400,
|
||||
receiptReplays = idle ? 0 : 400,
|
||||
notificationToClaimMillis = lanes.SelectMany(l => l.ClaimLatency).ToArray(),
|
||||
notificationToDownloadCompletedMillis = lanes.SelectMany(l => l.DownloadLatency).ToArray(),
|
||||
periodicJobPolling = false,
|
||||
transport = "HTTP/WS loopback; no TLS qualification",
|
||||
realPrinterCalls = 0,
|
||||
runtime = Environment.Version.ToString(),
|
||||
coreVersion = typeof(PrintWorker).Assembly.GetName().Version?.ToString()
|
||||
}, s_reportOptions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Drain only event-triggered work and measure the peak client working set.</summary>
|
||||
private static async Task<long> RunLanesAsync(List<Lane> lanes, string folder, int total, CancellationToken token)
|
||||
{
|
||||
long peak = 0;
|
||||
Task[] tasks = lanes.Select(lane => lane.Run(token)).ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, "ready"), "ready", token);
|
||||
var all = Task.WhenAll(tasks);
|
||||
while(!all.IsCompleted)
|
||||
{
|
||||
peak = Math.Max(peak, Process.GetCurrentProcess().WorkingSet64);
|
||||
await Task.WhenAny(all, Task.Delay(1000, token));
|
||||
token.ThrowIfCancellationRequested();
|
||||
}
|
||||
await all;
|
||||
Require(lanes.Sum(l => l.Printer.Count) == total, "exact fake submission count");
|
||||
Require(lanes.All(l => l.Journal.Pending().Count == 0), "no unacknowledged receipts");
|
||||
return peak;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stop the fixture when an invariant fails, retaining a fixed diagnostic reason.</summary>
|
||||
private static void Require(bool ok, string reason)
|
||||
{
|
||||
if(!ok)
|
||||
{
|
||||
throw new InvalidDataException(reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Own one authenticated worker, its synthetic journal, and notification latency measurements.</summary>
|
||||
private sealed class Lane : IAsyncDisposable
|
||||
{
|
||||
public QqqSessionClient Client
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Connection Connection
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public FakePrinter Printer { get; } = new();
|
||||
|
||||
|
||||
|
||||
public Journal Journal
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public List<double> Latencies { get; } = [];
|
||||
|
||||
|
||||
|
||||
private readonly PrintApiClient _api;
|
||||
|
||||
|
||||
|
||||
private readonly PrintWorker _worker;
|
||||
|
||||
|
||||
|
||||
private readonly int _target;
|
||||
|
||||
|
||||
|
||||
private readonly string _root;
|
||||
|
||||
|
||||
|
||||
private long _wakeAt;
|
||||
|
||||
|
||||
|
||||
public List<double> ClaimLatency { get; } = [];
|
||||
|
||||
|
||||
|
||||
public List<double> DownloadLatency { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>Own one authenticated worker, its synthetic journal, and notification latency measurements.</summary>
|
||||
public Lane(QqqSessionClient client, Connection connection, string folder, int targetJobs, string rootDirectory)
|
||||
{
|
||||
Client = client;
|
||||
Connection = connection;
|
||||
_target = targetJobs;
|
||||
_root = rootDirectory;
|
||||
Printer.OnDownload = () => DownloadLatency.Add(Stopwatch.GetElapsedTime(_wakeAt).TotalMilliseconds);
|
||||
Directory.CreateDirectory(folder);
|
||||
Journal = new(folder);
|
||||
_api = PrintApiClient.ForSession(client).WithRealtime(connection.Channel, connection.Settings);
|
||||
_worker = new PrintWorker(_api, Printer, Journal, options: new PrintWorkerOptions
|
||||
{
|
||||
Settings = connection.Settings,
|
||||
StagingDirectory = Path.Combine(folder, "staging"),
|
||||
SynchronizeInventory = false,
|
||||
RecoverIncomplete = false,
|
||||
ActiveJobChanged = job =>
|
||||
{
|
||||
if(job is not null)
|
||||
{
|
||||
ClaimLatency.Add(Stopwatch.GetElapsedTime(_wakeAt).TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Drain notified jobs and await durable receipts without periodic job polling.</summary>
|
||||
public async Task Run(CancellationToken token)
|
||||
{
|
||||
using var stop = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
Task ping = Liveness(stop.Token);
|
||||
try
|
||||
{
|
||||
while(Printer.Count < _target)
|
||||
{
|
||||
_wakeAt = await Connection.Wakes.Reader.ReadAsync(token);
|
||||
while(Printer.Count < _target && await _worker.RunOnceAsync(token))
|
||||
{
|
||||
Require(string.Equals(Journal.Last.Event.Status, "SPOOLED", StringComparison.Ordinal), "synthetic job terminal status");
|
||||
Latencies.Add((DateTimeOffset.UtcNow - DateTimeOffset.Parse(Journal.LastJob!.CreatedAt, CultureInfo.InvariantCulture)).TotalMilliseconds);
|
||||
await File.WriteAllTextAsync(Path.Combine(_root, "completed-" + Interlocked.Increment(ref s_completed)), "acknowledged", token);
|
||||
if(Printer.Count % 25 == 0)
|
||||
{
|
||||
await _api.EventAsync(Journal.Last.JobId, Journal.Last.Event, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await stop.CancelAsync();
|
||||
try
|
||||
{
|
||||
await ping;
|
||||
}
|
||||
catch(OperationCanceledException)
|
||||
{
|
||||
// Cancellation ends only the owned liveness task.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Send transport liveness frames without querying for jobs.</summary>
|
||||
private async Task Liveness(CancellationToken token)
|
||||
{
|
||||
using var tick = new PeriodicTimer(TimeSpan.FromSeconds(Connection.Settings.HeartbeatIntervalSeconds));
|
||||
while(await tick.WaitForNextTickAsync(token))
|
||||
{
|
||||
await Connection.Channel.SendAsync("PING", new
|
||||
{
|
||||
}, token: token);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stop owned background work and dispose this fixture lane or channel.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_api.Dispose();
|
||||
await Connection.DisposeAsync();
|
||||
Client.Dispose();
|
||||
Journal.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify payload bytes and reject duplicate actions without calling a spooler.</summary>
|
||||
private sealed class FakePrinter : IFilePrinterBackend
|
||||
{
|
||||
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
public int Count => _seen.Count;
|
||||
|
||||
|
||||
|
||||
public Action? OnDownload
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Expose only the fixed synthetic ZPL destination.</summary>
|
||||
public IReadOnlyList<PrinterInfo> Discover() => [new("synthetic", "No physical printer", "fixture", "none", "READY", ["ZPL"])];
|
||||
|
||||
|
||||
|
||||
/// <summary>Record download completion and validate the staged synthetic payload.</summary>
|
||||
public async Task<string> PrintFileAsync(PrintJob job, string path, CancellationToken token)
|
||||
{
|
||||
OnDownload?.Invoke();
|
||||
return await PrintAsync(job, await File.ReadAllBytesAsync(path, token), token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Check exact bytes and allow each synthetic job to be submitted once.</summary>
|
||||
public Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken token)
|
||||
{
|
||||
Require(content.AsSpan().SequenceEqual(s_expected), "downloaded bytes exact");
|
||||
Require(_seen.Add(job.JobId), "no duplicate physical action");
|
||||
return Task.FromResult("synthetic-" + Count);
|
||||
}
|
||||
}
|
||||
// Fixture journal is deliberately separate from the installed Windows SQLite journal.
|
||||
|
||||
|
||||
|
||||
/// <summary>Track fixture-only receipt state separately from the installed SQLite journal.</summary>
|
||||
private sealed class Journal : IJobJournal, IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string> _states = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
private readonly Dictionary<string, (string, PrintEvent)> _pending = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
private readonly StreamWriter _log;
|
||||
|
||||
|
||||
|
||||
public PrintJob? LastJob
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public (string JobId, PrintEvent Event) Last
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Track fixture-only receipt state separately from the installed SQLite journal.</summary>
|
||||
public Journal(string folder)
|
||||
{
|
||||
_log = new StreamWriter(new FileStream(Path.Combine(folder, "synthetic-receipts.jsonl"), FileMode.CreateNew, FileAccess.Write, FileShare.Read)) { AutoFlush = true };
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Retain accepted job metadata for latency and status assertions.</summary>
|
||||
public void TrackJob(PrintJob job, string? printer)
|
||||
{
|
||||
LastJob = job;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Return the last recorded state for the synthetic job.</summary>
|
||||
public string? GetState(string id) => _states.GetValueOrDefault(id);
|
||||
|
||||
|
||||
|
||||
/// <summary>Flush a receipt before adding it to the pending set.</summary>
|
||||
public void Record(string id, PrintEvent evt)
|
||||
{
|
||||
_log.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
jobId = id,
|
||||
@event = evt
|
||||
}));
|
||||
_states[id] = evt.Status;
|
||||
_pending.Add(evt.EventId, (id, evt));
|
||||
Last = (id, evt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Snapshot unacknowledged synthetic receipts for the real worker.</summary>
|
||||
public IReadOnlyList<(string JobId, PrintEvent Event)> Pending() => _pending.Values.ToList();
|
||||
|
||||
|
||||
|
||||
/// <summary>Remove only the specifically acknowledged receipt.</summary>
|
||||
public void Acknowledge(string id)
|
||||
{
|
||||
_pending.Remove(id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Declare no recovered jobs because each fixture journal starts fresh.</summary>
|
||||
public IReadOnlyList<(string JobId, string State)> Incomplete() => [];
|
||||
|
||||
|
||||
|
||||
/// <summary>Close the fixture receipt log.</summary>
|
||||
public void Dispose() => _log.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Own the realtime reader, bounded notifications, and negotiated settings.</summary>
|
||||
private sealed class Connection : IAsyncDisposable
|
||||
{
|
||||
public required PrintingRealtimeChannel Channel
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public required Task Reader
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public required CancellationTokenSource Lifetime
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public required Channel<RealtimeEnvelope> Messages
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public required Channel<long> Wakes
|
||||
{
|
||||
get; init;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public RealtimeSettings Settings
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
} = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>Open an authenticated channel and acknowledge its initial authoritative state.</summary>
|
||||
public static async Task<Connection> Open(QqqSessionClient client, CancellationToken token)
|
||||
{
|
||||
var lifetime = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
WebSocket socket = await client.ConnectPrintingAsync(TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(60), token);
|
||||
var channel = new PrintingRealtimeChannel(socket, new RealtimeSettings());
|
||||
var messages = System.Threading.Channels.Channel.CreateBounded<RealtimeEnvelope>(8);
|
||||
var wakes = System.Threading.Channels.Channel.CreateBounded<long>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||
Task reader = channel.RunAsync(e =>
|
||||
{
|
||||
if(string.Equals(e.Type, "JOB_AVAILABLE", StringComparison.Ordinal))
|
||||
{
|
||||
wakes.Writer.TryWrite(Stopwatch.GetTimestamp());
|
||||
return;
|
||||
}
|
||||
if(e.Type is "SETTINGS" or "DESIRED_STATE")
|
||||
{
|
||||
long revision = e.Payload.GetProperty("revision").GetInt64();
|
||||
if(revision != 0)
|
||||
{
|
||||
throw new InvalidDataException("Unexpected policy change");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(e.Type is "PONG" or "ACK")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!messages.Writer.TryWrite(e))
|
||||
{
|
||||
throw new IOException("Fixture envelope inbox full");
|
||||
}
|
||||
}, lifetime.Token);
|
||||
_ = reader.ContinueWith(t => { wakes.Writer.TryComplete(t.Exception); messages.Writer.TryComplete(t.Exception); }, TaskScheduler.Default);
|
||||
var result = new Connection { Channel = channel, Reader = reader, Lifetime = lifetime, Messages = messages, Wakes = wakes };
|
||||
await channel.SendAsync("HELLO", new
|
||||
{
|
||||
agentVersion = PrintApiClient.AgentVersion,
|
||||
capabilities = s_capabilities,
|
||||
lastAppliedRevision = 0,
|
||||
lastSettingsRevision = 0,
|
||||
limits = RealtimeSettings.ClientLimits
|
||||
}, token: token);
|
||||
RealtimeEnvelope welcome = await messages.Reader.ReadAsync(token);
|
||||
Require(string.Equals(welcome.Type, "WELCOME", StringComparison.Ordinal), "welcome");
|
||||
result.Settings = welcome.Payload.GetProperty("settings").Deserialize<RealtimeSettings>(PrintApiClient.JsonOptions)!;
|
||||
result.Settings.Validate();
|
||||
channel.UpdateSettings(result.Settings);
|
||||
await channel.SendAsync("STATE_APPLIED", new
|
||||
{
|
||||
lastAppliedRevision = welcome.Payload.GetProperty("desiredState").GetProperty("revision").GetInt64(),
|
||||
lastSettingsRevision = result.Settings.Revision
|
||||
}, acknowledged: true, token: token);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stop owned background work and dispose this fixture lane or channel.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Lifetime.CancelAsync();
|
||||
try
|
||||
{
|
||||
await Reader;
|
||||
}
|
||||
catch(Exception e) when(e is OperationCanceledException or IOException or WebSocketException)
|
||||
{
|
||||
// Cancelling the owned reader normally ends it with one of these; disposal must still finish.
|
||||
}
|
||||
Channel.Dispose();
|
||||
Lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
await all;
|
||||
Require(lanes.Sum(l=>l.Printer.Count)==total,"exact fake submission count");
|
||||
Require(lanes.All(l=>l.Journal.Pending().Count==0),"no unacknowledged receipts");
|
||||
stage="logout";foreach(var lane in lanes)await lane.Client.SignOutAsync(timeout.Token);
|
||||
double[] latencies=lanes.SelectMany(l=>l.Latencies).Order().ToArray();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(new {
|
||||
success=true,jobs=total,idle,tenants=2,devices=8,elapsedSeconds=timer.Elapsed.TotalSeconds,jobsPerSecond=total/timer.Elapsed.TotalSeconds,
|
||||
peakClientWorkingSetBytes=peak,p50EnqueueToAckMillis=latencies[total/2],p95EnqueueToAckMillis=latencies[(int)(total*0.95)],p99EnqueueToAckMillis=latencies[(int)(total*0.99)],
|
||||
realCoreWorker=true,fakePrinterBackend=true,fixtureJournal=true,serverReceiptsDurable=true,crossTenantProofRefused=denied,
|
||||
submissionReplays=idle?0:400,receiptReplays=idle?0:400,notificationToClaimMillis=lanes.SelectMany(l=>l.ClaimLatency).ToArray(),notificationToDownloadCompletedMillis=lanes.SelectMany(l=>l.DownloadLatency).ToArray(),periodicJobPolling=false,transport="HTTP/WS loopback; no TLS qualification",realPrinterCalls=0,
|
||||
runtime=Environment.Version.ToString(),coreVersion=typeof(PrintWorker).Assembly.GetName().Version?.ToString()
|
||||
},new JsonSerializerOptions{WriteIndented=true}));
|
||||
Console.WriteLine("PASS real Core/Java network lifecycles; fake printer only");return 0;
|
||||
} catch(Exception error) {
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(new{success=false,stage,error=error.GetType().Name,reason=error is InvalidDataException?error.Message:null,completed=lanes.Sum(l=>l.Printer.Count)}));
|
||||
Console.Error.WriteLine("FAIL "+stage+": "+error.GetType().Name);return 1;
|
||||
} finally {
|
||||
foreach(var lane in lanes)await lane.DisposeAsync();
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"done"),"done");
|
||||
}
|
||||
}
|
||||
static void Require(bool ok,string reason){if(!ok)throw new InvalidDataException(reason);}
|
||||
sealed class Lane: IAsyncDisposable {
|
||||
public QqqSessionClient Client{get;} public Connection Connection{get;}
|
||||
public FakePrinter Printer{get;}=new(); public Journal Journal{get;} public List<double> Latencies{get;}=[];
|
||||
readonly PrintApiClient api;readonly PrintWorker worker; readonly int target; readonly string root; long wakeAt; public List<double> ClaimLatency{get;}=[]; public List<double> DownloadLatency{get;}=[];
|
||||
public Lane(QqqSessionClient client,Connection connection,string folder,int target,string root) {
|
||||
Client=client;Connection=connection;this.target=target;this.root=root;Printer.OnDownload=()=>DownloadLatency.Add(Stopwatch.GetElapsedTime(wakeAt).TotalMilliseconds);Directory.CreateDirectory(folder);Journal=new(folder);
|
||||
api=PrintApiClient.ForSession(client).WithRealtime(connection.Channel,connection.Settings);
|
||||
worker=new PrintWorker(api,Printer,Journal,options:new PrintWorkerOptions{Settings=connection.Settings,StagingDirectory=Path.Combine(folder,"staging"),SynchronizeInventory=false,RecoverIncomplete=false,ActiveJobChanged=job=>{if(job is not null)ClaimLatency.Add(Stopwatch.GetElapsedTime(wakeAt).TotalMilliseconds);}});
|
||||
}
|
||||
public async Task Run(CancellationToken token) {
|
||||
using var stop=CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
Task ping=Liveness(stop.Token);
|
||||
try {
|
||||
while(Printer.Count<target) {
|
||||
wakeAt=await Connection.Wakes.Reader.ReadAsync(token);
|
||||
while(Printer.Count<target && await worker.RunOnceAsync(token)) {
|
||||
Require(Journal.Last.Event.Status=="SPOOLED","synthetic job terminal status");
|
||||
Latencies.Add((DateTimeOffset.UtcNow-DateTimeOffset.Parse(Journal.LastJob!.CreatedAt,CultureInfo.InvariantCulture)).TotalMilliseconds);
|
||||
await File.WriteAllTextAsync(Path.Combine(root,"completed-"+Interlocked.Increment(ref Completed)),"acknowledged",token);
|
||||
if(Printer.Count%25==0)await api.EventAsync(Journal.Last.JobId,Journal.Last.Event,token);
|
||||
}
|
||||
}
|
||||
} finally {await stop.CancelAsync();try{await ping;}catch(OperationCanceledException){}}
|
||||
}
|
||||
async Task Liveness(CancellationToken token) {
|
||||
using var tick=new PeriodicTimer(TimeSpan.FromSeconds(Connection.Settings.HeartbeatIntervalSeconds));
|
||||
while(await tick.WaitForNextTickAsync(token))await Connection.Channel.SendAsync("PING",new{},token:token);
|
||||
}
|
||||
public async ValueTask DisposeAsync(){api.Dispose();await Connection.DisposeAsync();Client.Dispose();Journal.Dispose();}
|
||||
}
|
||||
sealed class FakePrinter:IFilePrinterBackend {
|
||||
readonly HashSet<string> seen=new(StringComparer.Ordinal);public int Count=>seen.Count; public Action? OnDownload{get;set;}
|
||||
public IReadOnlyList<PrinterInfo> Discover()=>[new("synthetic","No physical printer","fixture","none","READY",["ZPL"])];
|
||||
public async Task<string> PrintFileAsync(PrintJob job,string path,CancellationToken token) {OnDownload?.Invoke();return await PrintAsync(job,await File.ReadAllBytesAsync(path,token),token);}
|
||||
public Task<string> PrintAsync(PrintJob job,byte[] content,CancellationToken token) {
|
||||
Require(content.AsSpan().SequenceEqual(Expected),"downloaded bytes exact");Require(seen.Add(job.JobId),"no duplicate physical action");return Task.FromResult("synthetic-"+Count);
|
||||
}
|
||||
}
|
||||
// Fixture journal is deliberately separate from the installed Windows SQLite journal.
|
||||
sealed class Journal: IJobJournal,IDisposable {
|
||||
readonly Dictionary<string,string> states=new(StringComparer.Ordinal);readonly Dictionary<string,(string,PrintEvent)> pending=new(StringComparer.Ordinal);readonly StreamWriter log;
|
||||
public PrintJob? LastJob{get;private set;}public (string JobId,PrintEvent Event) Last{get;private set;}
|
||||
public Journal(string folder){log=new StreamWriter(new FileStream(Path.Combine(folder,"synthetic-receipts.jsonl"),FileMode.CreateNew,FileAccess.Write,FileShare.Read)){AutoFlush=true};}
|
||||
public void TrackJob(PrintJob job,string? printer){LastJob=job;}
|
||||
public string? GetState(string id)=>states.GetValueOrDefault(id);
|
||||
public void Record(string id,PrintEvent evt){log.WriteLine(JsonSerializer.Serialize(new{jobId=id,@event=evt}));states[id]=evt.Status;pending.Add(evt.EventId,(id,evt));Last=(id,evt);}
|
||||
public IReadOnlyList<(string JobId,PrintEvent Event)> Pending()=>pending.Values.ToList();
|
||||
public void Acknowledge(string id){pending.Remove(id);}
|
||||
public IReadOnlyList<(string JobId,string State)> Incomplete()=>[];
|
||||
public void Dispose()=>log.Dispose();
|
||||
}
|
||||
sealed class Connection:IAsyncDisposable {
|
||||
public required PrintingRealtimeChannel Channel{get;init;}public required Task Reader{get;init;}public required CancellationTokenSource Lifetime{get;init;}
|
||||
public required Channel<RealtimeEnvelope> Messages{get;init;}public required Channel<long> Wakes{get;init;}public RealtimeSettings Settings{get;private set;}=new();
|
||||
public static async Task<Connection> Open(QqqSessionClient client,CancellationToken token) {
|
||||
var lifetime=CancellationTokenSource.CreateLinkedTokenSource(token);WebSocket socket=await client.ConnectPrintingAsync(TimeSpan.FromSeconds(20),TimeSpan.FromSeconds(60),token);
|
||||
var channel=new PrintingRealtimeChannel(socket,new RealtimeSettings());var messages=System.Threading.Channels.Channel.CreateBounded<RealtimeEnvelope>(8);
|
||||
var wakes=System.Threading.Channels.Channel.CreateBounded<long>(new BoundedChannelOptions(1){FullMode=BoundedChannelFullMode.DropWrite});
|
||||
Task reader=channel.RunAsync(e=>{
|
||||
if(e.Type=="JOB_AVAILABLE"){wakes.Writer.TryWrite(Stopwatch.GetTimestamp());return;}
|
||||
if(e.Type is "SETTINGS" or "DESIRED_STATE") {long revision=e.Payload.GetProperty("revision").GetInt64();if(revision!=0)throw new InvalidDataException("Unexpected policy change");return;}
|
||||
if(e.Type is "PONG" or "ACK")return;
|
||||
if(!messages.Writer.TryWrite(e))throw new IOException("Fixture envelope inbox full");
|
||||
},lifetime.Token);
|
||||
_=reader.ContinueWith(t=>{wakes.Writer.TryComplete(t.Exception);messages.Writer.TryComplete(t.Exception);},TaskScheduler.Default);
|
||||
var result=new Connection{Channel=channel,Reader=reader,Lifetime=lifetime,Messages=messages,Wakes=wakes};
|
||||
await channel.SendAsync("HELLO",new{agentVersion=PrintApiClient.AgentVersion,capabilities=new[]{"realtime-v1","REFRESH_INVENTORY","CANCEL_SPOOL"},lastAppliedRevision=0,lastSettingsRevision=0,limits=RealtimeSettings.ClientLimits},token:token);
|
||||
RealtimeEnvelope welcome=await messages.Reader.ReadAsync(token);Require(welcome.Type=="WELCOME","welcome");
|
||||
result.Settings=welcome.Payload.GetProperty("settings").Deserialize<RealtimeSettings>(PrintApiClient.JsonOptions)!;result.Settings.Validate();channel.UpdateSettings(result.Settings);
|
||||
await channel.SendAsync("STATE_APPLIED",new{lastAppliedRevision=welcome.Payload.GetProperty("desiredState").GetProperty("revision").GetInt64(),lastSettingsRevision=result.Settings.Revision},acknowledged:true,token:token);return result;
|
||||
}
|
||||
public async ValueTask DisposeAsync(){await Lifetime.CancelAsync();try{await Reader;}catch(Exception e)when(e is OperationCanceledException or IOException or WebSocketException){}Channel.Dispose();Lifetime.Dispose();}
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core"><HintPath>/core/Qqq.PrintAgent.Core.dll</HintPath></Reference>
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core">
|
||||
<HintPath>$(CoreAssemblyPath)</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,10 +1,14 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
namespace Printing.Development;
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies production Core discovery rules before any fixture credentials are sent.</summary>
|
||||
internal static class WorkloadDiscovery
|
||||
{
|
||||
/// <summary>Require a compatible realtime extension after strict production discovery validation.</summary>
|
||||
internal static async Task<QqqApiDiscovery> DiscoverRealtimeAsync(QqqSessionClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
QqqApiDiscovery discovery = await client.DiscoverAsync(cancellationToken);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.296, )",
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.403",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
+41
-12
@@ -19,8 +19,11 @@
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
@@ -41,17 +44,30 @@ import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/** Docker-only developer host. Compose publishes its port to host loopback only. */
|
||||
|
||||
/***************************************************************************
|
||||
** Docker-only developer host. Compose publishes its port to host loopback only.
|
||||
***************************************************************************/
|
||||
public final class DevHost extends PrintingLocalHost
|
||||
{
|
||||
private static final String TABLE = "printing_documents";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Configure the isolated developer database and synthetic account from its private environment.
|
||||
***************************************************************************/
|
||||
private DevHost() throws Exception
|
||||
{
|
||||
super(new PrintingLocalSettings("jdbc:h2:file:/data/printing", "sa", required("QQQ_DATABASE_PASSWORD"), "development",
|
||||
Integer.parseInt(required("QQQ_HTTP_PORT")), required("QQQ_MANAGEMENT_TOKEN"), "developer", required("QQQ_ADMIN_PASSWORD")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wait for owned storage and launch the loopback-published developer host.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
waitStorage();
|
||||
@@ -73,6 +89,11 @@ public final class DevHost extends PrintingLocalHost
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Add the isolated MinIO-backed document table and make sure its bucket exists.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
@@ -87,19 +108,17 @@ public final class DevHost extends PrintingLocalHost
|
||||
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE).withFileNameFieldName("id").withContentsFieldName("content"))
|
||||
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
|
||||
var storage = S3ClientFactory.getClient(backend);
|
||||
try
|
||||
{
|
||||
try
|
||||
storage.headBucket(HeadBucketRequest.builder().bucket("printing-development").build());
|
||||
}
|
||||
catch(S3Exception missing)
|
||||
{
|
||||
if(missing.statusCode() != 404)
|
||||
{
|
||||
storage.headBucket(HeadBucketRequest.builder().bucket("printing-development").build());
|
||||
}
|
||||
catch(S3Exception missing)
|
||||
{
|
||||
if(missing.statusCode() != 404)
|
||||
{
|
||||
throw missing;
|
||||
}
|
||||
storage.createBucket(CreateBucketRequest.builder().bucket("printing-development").build());
|
||||
throw missing;
|
||||
}
|
||||
storage.createBucket(CreateBucketRequest.builder().bucket("printing-development").build());
|
||||
}
|
||||
PrintingQBitConfig config = instance.getQBits().values().stream().map(value -> value.getConfig())
|
||||
.filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow();
|
||||
@@ -108,6 +127,11 @@ public final class DevHost extends PrintingLocalHost
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bound startup retries to the owned MinIO health endpoint.
|
||||
***************************************************************************/
|
||||
private static void waitStorage() throws Exception
|
||||
{
|
||||
try(HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build())
|
||||
@@ -123,7 +147,7 @@ public final class DevHost extends PrintingLocalHost
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException notReady)
|
||||
catch(IOException notReady)
|
||||
{
|
||||
// Only the fixture startup window retries; application work is never retried here.
|
||||
}
|
||||
@@ -133,6 +157,11 @@ public final class DevHost extends PrintingLocalHost
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read a required fixture setting without exposing its value.
|
||||
***************************************************************************/
|
||||
private static String required(String name)
|
||||
{
|
||||
String value = System.getenv(name);
|
||||
|
||||
@@ -19,202 +19,423 @@
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import com.kingsrook.qbits.printing.*;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.*;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.*;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.*;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import org.json.*;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.printing.DriverDataSource;
|
||||
import com.kingsrook.qbits.printing.PrintingException;
|
||||
import com.kingsrook.qbits.printing.PrintingService;
|
||||
import com.kingsrook.qbits.printing.PrintingTestDocuments;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.*;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.ListMultipartUploadsRequest;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
|
||||
/** Non-listening synthetic harness. Never routes work to a Windows device. */
|
||||
public final class S3PrintingQualification {
|
||||
private static final String TABLE = "printing_documents";
|
||||
private static final JSONArray CHECKS = new JSONArray();
|
||||
private static final JSONArray DOCUMENTS = new JSONArray();
|
||||
private static final Map<String, String> OBJECT_KEYS = new HashMap<>();
|
||||
private static DriverDataSource database;
|
||||
private static S3Client s3;
|
||||
private static String bucket;
|
||||
private static String otherBucket;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path output = Path.of(args[0]).toAbsolutePath();
|
||||
Files.createDirectories(output);
|
||||
String run = args[2];
|
||||
bucket = "printing-fixture-" + run;
|
||||
otherBucket = "printing-canary-" + run;
|
||||
S3BackendMetaData backend = new S3BackendMetaData().withName("printing_s3_" + run)
|
||||
.withBucketName(bucket).withEndpoint(args[1]).withPathStyleAccess(true).withRegion("us-east-1")
|
||||
.withAccessKey(System.getenv("MINIO_ROOT_USER")).withSecretKey(System.getenv("MINIO_ROOT_PASSWORD"));
|
||||
backend.setConnectTimeoutMillis(3000);
|
||||
backend.setSocketTimeoutMillis(15000);
|
||||
QInstance instance = new QInstance();
|
||||
// Required metadata only: this harness opens no HTTP endpoint or user session.
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("fixture").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(backend);
|
||||
instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName())
|
||||
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE)
|
||||
.withFileNameFieldName("id").withContentsFieldName("content")).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
|
||||
database = new DriverDataSource("jdbc:h2:file:" + output.resolve("synthetic-data").toString().replace('\\', '/'), "sa", "");
|
||||
try {
|
||||
s3 = S3ClientFactory.getClient(backend);
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(otherBucket).build());
|
||||
s3.putObject(PutObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build(), RequestBody.fromString("synthetic unrelated object"));
|
||||
s3.putObject(PutObjectRequest.builder().bucket(otherBucket).key("canary.txt").build(), RequestBody.fromString("synthetic separate bucket"));
|
||||
PrintingService a = service(instance, "fixture-tenant-a");
|
||||
PrintingService b = service(instance, "fixture-tenant-b");
|
||||
String deviceA = enroll(a);
|
||||
String deviceB = enroll(b);
|
||||
byte[] pdf = PrintingTestDocuments.pdf();
|
||||
byte[] zpl = "^XA^FO20,20^ADN,30,18^FDSYNTHETIC STORAGE TEST ONLY^FS^XZ".getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] large = new byte[6 * 1024 * 1024 + 71];
|
||||
Arrays.fill(large, (byte)'A');
|
||||
System.arraycopy("^XA^FX".getBytes(StandardCharsets.US_ASCII), 0, large, 0, 6);
|
||||
System.arraycopy("^FS^XZ".getBytes(StandardCharsets.US_ASCII), 0, large, large.length - 6, 6);
|
||||
String pdfJob = submitCheck(a, deviceA, "small-pdf", "PDF", pdf, false);
|
||||
finish(a, deviceA, pdfJob, "SPOOLED");
|
||||
String zplJob = submitCheck(a, deviceA, "small-zpl", "ZPL", zpl, false);
|
||||
finish(a, deviceA, zplJob, "SPOOLED");
|
||||
String largeJob = submitCheck(a, deviceA, "multipart-zpl", "ZPL", large, true);
|
||||
finish(a, deviceA, largeJob, "SPOOLED");
|
||||
String foreignJob = submitCheck(b, deviceB, "foreign-zpl", "ZPL", zpl, false);
|
||||
finish(b, deviceB, foreignJob, "SPOOLED");
|
||||
String foreignRef = reference(foreignJob);
|
||||
String ownRef = reference(pdfJob);
|
||||
check(!ownRef.split("/")[1].equals(foreignRef.split("/")[1]), "tenant prefixes differ");
|
||||
expectStatus(404, () -> a.content(deviceA, foreignJob), "foreign tenant job download denied");
|
||||
expectStatus(404, () -> a.content(deviceB, foreignJob), "foreign tenant device rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", foreignRef, pdfJob);
|
||||
expectStatus(409, () -> a.content(deviceA, pdfJob), "foreign stored reference rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", ownRef, pdfJob);
|
||||
check(Arrays.equals(zpl, b.content(deviceB, foreignJob)), "foreign object unchanged after rejection");
|
||||
String failed = submitCheck(a, deviceA, "protected-failed", "ZPL", zpl, false);
|
||||
finish(a, deviceA, failed, "FAILED");
|
||||
String unknown = submitCheck(a, deviceA, "protected-unknown", "ZPL", zpl, false);
|
||||
finish(a, deviceA, unknown, "OUTCOME_UNKNOWN");
|
||||
String active = a.submit(request(deviceA, "protected-queued", "ZPL", zpl)).getString("jobId");
|
||||
String old = Instant.now().minusSeconds(400L * 86400).toString();
|
||||
for(String id : List.of(pdfJob, largeJob, foreignJob, failed, unknown, active)) {
|
||||
execute("UPDATE printing_job SET created_at=?,completed_at=? WHERE id=?", old, old, id);
|
||||
/***************************************************************************
|
||||
** Non-listening synthetic harness. Never routes work to a Windows device.
|
||||
***************************************************************************/
|
||||
public final class S3PrintingQualification
|
||||
{
|
||||
private static final String TABLE = "printing_documents";
|
||||
private static final JSONArray CHECKS = new JSONArray();
|
||||
private static final JSONArray DOCUMENTS = new JSONArray();
|
||||
private static final Map<String, String> OBJECT_KEYS = new HashMap<>();
|
||||
private static DriverDataSource database;
|
||||
private static S3Client s3;
|
||||
private static String bucket;
|
||||
private static String otherBucket;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Exercise real storage operations against dedicated synthetic buckets without dispatching to a printer.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
Path output = Path.of(args[0]).toAbsolutePath();
|
||||
Files.createDirectories(output);
|
||||
String run = args[2];
|
||||
bucket = "printing-fixture-" + run;
|
||||
otherBucket = "printing-canary-" + run;
|
||||
S3BackendMetaData backend = new S3BackendMetaData().withName("printing_s3_" + run)
|
||||
.withBucketName(bucket).withEndpoint(args[1]).withPathStyleAccess(true).withRegion("us-east-1")
|
||||
.withAccessKey(System.getenv("MINIO_ROOT_USER")).withSecretKey(System.getenv("MINIO_ROOT_PASSWORD"));
|
||||
backend.setConnectTimeoutMillis(3000);
|
||||
backend.setSocketTimeoutMillis(15000);
|
||||
QInstance instance = new QInstance();
|
||||
// Required metadata only: this harness opens no HTTP endpoint or user session.
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("fixture").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(backend);
|
||||
instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName())
|
||||
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE)
|
||||
.withFileNameFieldName("id").withContentsFieldName("content")).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
|
||||
database = new DriverDataSource("jdbc:h2:file:" + output.resolve("synthetic-data").toString().replace('\\', '/'), "sa", "");
|
||||
try
|
||||
{
|
||||
s3 = S3ClientFactory.getClient(backend);
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(otherBucket).build());
|
||||
s3.putObject(PutObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build(), RequestBody.fromString("synthetic unrelated object"));
|
||||
s3.putObject(PutObjectRequest.builder().bucket(otherBucket).key("canary.txt").build(), RequestBody.fromString("synthetic separate bucket"));
|
||||
PrintingService a = service(instance, "fixture-tenant-a");
|
||||
PrintingService b = service(instance, "fixture-tenant-b");
|
||||
String deviceA = enroll(a);
|
||||
String deviceB = enroll(b);
|
||||
byte[] pdf = PrintingTestDocuments.pdf();
|
||||
byte[] zpl = "^XA^FO20,20^ADN,30,18^FDSYNTHETIC STORAGE TEST ONLY^FS^XZ".getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] large = new byte[6 * 1024 * 1024 + 71];
|
||||
Arrays.fill(large, (byte) 'A');
|
||||
System.arraycopy("^XA^FX".getBytes(StandardCharsets.US_ASCII), 0, large, 0, 6);
|
||||
System.arraycopy("^FS^XZ".getBytes(StandardCharsets.US_ASCII), 0, large, large.length - 6, 6);
|
||||
String pdfJob = submitCheck(a, deviceA, "small-pdf", "PDF", pdf, false);
|
||||
finish(a, deviceA, pdfJob, "SPOOLED");
|
||||
String zplJob = submitCheck(a, deviceA, "small-zpl", "ZPL", zpl, false);
|
||||
finish(a, deviceA, zplJob, "SPOOLED");
|
||||
String largeJob = submitCheck(a, deviceA, "multipart-zpl", "ZPL", large, true);
|
||||
finish(a, deviceA, largeJob, "SPOOLED");
|
||||
String foreignJob = submitCheck(b, deviceB, "foreign-zpl", "ZPL", zpl, false);
|
||||
finish(b, deviceB, foreignJob, "SPOOLED");
|
||||
String foreignRef = reference(foreignJob);
|
||||
String ownRef = reference(pdfJob);
|
||||
check(!ownRef.split("/")[1].equals(foreignRef.split("/")[1]), "tenant prefixes differ");
|
||||
expectStatus(404, () -> a.content(deviceA, foreignJob), "foreign tenant job download denied");
|
||||
expectStatus(404, () -> a.content(deviceB, foreignJob), "foreign tenant device rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", foreignRef, pdfJob);
|
||||
expectStatus(409, () -> a.content(deviceA, pdfJob), "foreign stored reference rejected");
|
||||
execute("UPDATE printing_job SET content_reference=? WHERE id=?", ownRef, pdfJob);
|
||||
check(Arrays.equals(zpl, b.content(deviceB, foreignJob)), "foreign object unchanged after rejection");
|
||||
String failed = submitCheck(a, deviceA, "protected-failed", "ZPL", zpl, false);
|
||||
finish(a, deviceA, failed, "FAILED");
|
||||
String unknown = submitCheck(a, deviceA, "protected-unknown", "ZPL", zpl, false);
|
||||
finish(a, deviceA, unknown, "OUTCOME_UNKNOWN");
|
||||
String active = a.submit(request(deviceA, "protected-queued", "ZPL", zpl)).getString("jobId");
|
||||
String old = Instant.now().minusSeconds(400L * 86400).toString();
|
||||
for(String id : List.of(pdfJob, largeJob, foreignJob, failed, unknown, active))
|
||||
{
|
||||
execute("UPDATE printing_job SET created_at=?,completed_at=? WHERE id=?", old, old, id);
|
||||
}
|
||||
int before = objectCount();
|
||||
check(a.runRetention() == 2, "tenant A retention removes exactly two aged successful documents");
|
||||
check(objectCount() == before - 2, "object count reflects exact two removals");
|
||||
missing(OBJECT_KEYS.get(ownRef));
|
||||
missing(OBJECT_KEYS.get(reference(largeJob)));
|
||||
expectStatus(410, () -> a.content(deviceA, pdfJob), "expired document returns 410");
|
||||
check(a.submit(request(deviceA, "small-pdf", "PDF", pdf)).getString("jobId").equals(pdfJob), "idempotent replay survives retention without reupload");
|
||||
check(objectCount() == before - 2, "post-retention replay creates no object");
|
||||
for(String id : List.of(zplJob, foreignJob, failed, unknown, active))
|
||||
{
|
||||
check(s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey(reference(id))).build()).contentLength() == zpl.length,
|
||||
"protected object retained: " + status(id));
|
||||
}
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build()).asUtf8String().equals("synthetic unrelated object"), "unrelated same-bucket canary preserved");
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(otherBucket).key("canary.txt").build()).asUtf8String().equals("synthetic separate bucket"), "separate bucket canary preserved");
|
||||
check(a.runRetention() == 0, "repeated cleanup is idempotent");
|
||||
check(b.runRetention() == 1, "tenant B cleanup independently removes its one eligible document");
|
||||
check(s3.listMultipartUploads(ListMultipartUploadsRequest.builder().bucket(bucket).build()).uploads().isEmpty(), "no incomplete multipart uploads remain");
|
||||
check(QContext.getQSession() == null, "storage restores original absent QQQ session");
|
||||
JSONObject result = new JSONObject().put("passed", true).put("timestamp", Instant.now().toString())
|
||||
.put("checks", CHECKS).put("documents", DOCUMENTS).put("runtimeJarCount", 204)
|
||||
.put("appRevision", System.getenv("PRINTING_SOURCE_COMMIT"))
|
||||
.put("h2Version", org.h2.engine.Constants.VERSION).put("retentionDays", a.getRetentionSettings().getInt("days"))
|
||||
.put("transport", "loopback HTTP S3-compatible only").put("hardwarePrinting", false)
|
||||
.put("qualifies", "QQQ StorageAction / AWS SDK v2 S3 data-plane behavior against disposable MinIO")
|
||||
.put("doesNotQualify", new JSONArray(List.of("AWS IAM", "remote TLS", "production bucket ownership", "Object Lock/version lifecycle", "remote deployment", "hardware printing")));
|
||||
Files.writeString(output.resolve("results.json"), result.toString(2) + "\n");
|
||||
System.out.println("PASS " + CHECKS.length() + " checks; " + DOCUMENTS.length() + " synthetic document round trips");
|
||||
}
|
||||
int before = objectCount();
|
||||
check(a.runRetention() == 2, "tenant A retention removes exactly two aged successful documents");
|
||||
check(objectCount() == before - 2, "object count reflects exact two removals");
|
||||
missing(OBJECT_KEYS.get(ownRef));
|
||||
missing(OBJECT_KEYS.get(reference(largeJob)));
|
||||
expectStatus(410, () -> a.content(deviceA, pdfJob), "expired document returns 410");
|
||||
check(a.submit(request(deviceA, "small-pdf", "PDF", pdf)).getString("jobId").equals(pdfJob), "idempotent replay survives retention without reupload");
|
||||
check(objectCount() == before - 2, "post-retention replay creates no object");
|
||||
for(String id : List.of(zplJob, foreignJob, failed, unknown, active)) {
|
||||
check(s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey(reference(id))).build()).contentLength() == zpl.length,
|
||||
"protected object retained: " + status(id));
|
||||
finally
|
||||
{
|
||||
Files.writeString(output.resolve("completed-checks.json"), new JSONObject().put("checks", CHECKS).put("documents", DOCUMENTS).toString(2) + "\n");
|
||||
S3ClientFactory.clearCache();
|
||||
QContext.clear();
|
||||
}
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build()).asUtf8String().equals("synthetic unrelated object"), "unrelated same-bucket canary preserved");
|
||||
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(otherBucket).key("canary.txt").build()).asUtf8String().equals("synthetic separate bucket"), "separate bucket canary preserved");
|
||||
check(a.runRetention() == 0, "repeated cleanup is idempotent");
|
||||
check(b.runRetention() == 1, "tenant B cleanup independently removes its one eligible document");
|
||||
check(s3.listMultipartUploads(ListMultipartUploadsRequest.builder().bucket(bucket).build()).uploads().isEmpty(), "no incomplete multipart uploads remain");
|
||||
check(QContext.getQSession() == null, "storage restores original absent QQQ session");
|
||||
JSONObject result = new JSONObject().put("passed", true).put("timestamp", Instant.now().toString())
|
||||
.put("checks", CHECKS).put("documents", DOCUMENTS).put("runtimeJarCount", 204)
|
||||
.put("appRevision", System.getenv("PRINTING_SOURCE_COMMIT"))
|
||||
.put("h2Version", org.h2.engine.Constants.VERSION).put("retentionDays", a.getRetentionSettings().getInt("days"))
|
||||
.put("transport", "loopback HTTP S3-compatible only").put("hardwarePrinting", false)
|
||||
.put("qualifies", "QQQ StorageAction / AWS SDK v2 S3 data-plane behavior against disposable MinIO")
|
||||
.put("doesNotQualify", new JSONArray(List.of("AWS IAM", "remote TLS", "production bucket ownership", "Object Lock/version lifecycle", "remote deployment", "hardware printing")));
|
||||
Files.writeString(output.resolve("results.json"), result.toString(2) + "\n");
|
||||
System.out.println("PASS " + CHECKS.length() + " checks; " + DOCUMENTS.length() + " synthetic document round trips");
|
||||
} finally {
|
||||
Files.writeString(output.resolve("completed-checks.json"), new JSONObject().put("checks", CHECKS).put("documents", DOCUMENTS).toString(2) + "\n");
|
||||
S3ClientFactory.clearCache();
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
private static PrintingService service(QInstance instance, String tenant) throws Exception {
|
||||
PrintingService service = new PrintingService(database, tenant);
|
||||
service.initialize(); service.configureStorage(instance, TABLE); return service;
|
||||
}
|
||||
private static String enroll(PrintingService service) throws Exception {
|
||||
String id = service.enroll(service.createEnrollment("fixture", "Synthetic station")
|
||||
.put("deviceName", "Synthetic station").put("agentVersion", "isolated-storage-fixture")).getString("deviceId");
|
||||
service.inventory(id, new JSONArray().put(new JSONObject().put("printerId", "synthetic-never-dispatched")
|
||||
.put("name", "Synthetic storage-only destination").put("status", "READY").put("formats", new JSONArray(List.of("PDF", "ZPL")))));
|
||||
service.enablePrinter(id, "synthetic-never-dispatched", true); return id;
|
||||
}
|
||||
private static JSONObject request(String device, String key, String format, byte[] bytes) {
|
||||
return new JSONObject().put("deviceId", device).put("printerId", "synthetic-never-dispatched").put("title", "Synthetic storage qualification")
|
||||
.put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key);
|
||||
}
|
||||
private static String submitCheck(PrintingService service, String device, String key, String format, byte[] bytes, boolean multipart) throws Exception {
|
||||
JSONObject request = request(device, key, format, bytes);
|
||||
String id = service.submit(request).getString("jobId");
|
||||
int count = objectCount();
|
||||
check(id.equals(service.submit(request).getString("jobId")) && count == objectCount(), key + " stable replay creates no object");
|
||||
check(service.claim(device).getJSONObject("job").getString("jobId").equals(id), key + " claimed through production service");
|
||||
byte[] actual = service.content(device, id);
|
||||
check(Arrays.equals(bytes, actual) && sha(bytes).equals(sha(actual)), key + " download bytes and SHA256 match");
|
||||
String ref = reference(id);
|
||||
String actualKey = objectKey(ref);
|
||||
HeadObjectResponse head = s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(actualKey).build());
|
||||
check(head.contentLength() == bytes.length, key + " object length exact");
|
||||
check(head.contentType().equals(format.equals("PDF") ? "application/pdf" : "application/vnd.zebra-zpl"), key + " content type correct");
|
||||
check(head.eTag().contains("-2") == multipart, key + " expected single/multipart object ETag");
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT content_base64,content_storage_table FROM printing_job WHERE id=?")) {
|
||||
statement.setString(1, id); try(var rows = statement.executeQuery()) { check(rows.next() && rows.getString(1) == null && TABLE.equals(rows.getString(2)), key + " payload external, no inline database copy"); }
|
||||
}
|
||||
DOCUMENTS.put(new JSONObject().put("case", key).put("format", format).put("bytes", bytes.length).put("sha256", sha(bytes)).put("multipart", multipart).put("etag", head.eTag())
|
||||
.put("objectKeyEqualsStoredReference", actualKey.equals(ref)).put("objectKeyPrefixBeforeReference", actualKey.substring(0, actualKey.length() - ref.length())));
|
||||
return id;
|
||||
}
|
||||
private static void finish(PrintingService service, String device, String id, String status) throws Exception {
|
||||
for(String phase : List.of("RECEIVED", "SUBMITTING", status)) service.event(device, id, new JSONObject().put("eventId", UUID.randomUUID().toString()).put("status", phase).put("message", "Synthetic receipt; no physical device or spooler used"));
|
||||
}
|
||||
private static String sha(byte[] bytes) throws Exception { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
|
||||
private static int objectCount() { return s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().size(); }
|
||||
private static String objectKey(String reference) {
|
||||
String known = OBJECT_KEYS.get(reference);
|
||||
if(known != null) return known;
|
||||
List<String> matches = s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().stream()
|
||||
.map(S3Object::key).filter(key -> key.endsWith(reference)).toList();
|
||||
if(matches.size() != 1) throw new AssertionError("Expected exactly one synthetic object for stored reference");
|
||||
String key = matches.getFirst();
|
||||
String prefix = key.substring(0, key.length() - reference.length());
|
||||
if(!prefix.isEmpty() && !prefix.matches("\\\\+")) throw new AssertionError("Unexpected physical S3 key prefix");
|
||||
OBJECT_KEYS.put(reference, key);
|
||||
return key;
|
||||
}
|
||||
private static String reference(String id) throws Exception { return field(id, "content_reference"); }
|
||||
private static String status(String id) throws Exception { return field(id, "status"); }
|
||||
private static String field(String id, String field) throws Exception {
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT " + field + " FROM printing_job WHERE id=?")) {
|
||||
statement.setString(1,id); try(var rows=statement.executeQuery()) { if(!rows.next()) throw new AssertionError("Missing synthetic job"); return rows.getString(1); }
|
||||
}
|
||||
}
|
||||
private static void execute(String sql, String... args) throws Exception {
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement(sql)) { for(int i=0; i<args.length; i++) statement.setString(i+1,args[i]); statement.executeUpdate(); }
|
||||
}
|
||||
private static void check(boolean pass, String label) { if(!pass) throw new AssertionError(label); CHECKS.put(label); }
|
||||
private static void missing(String ref) {
|
||||
try { s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(ref).build()); throw new AssertionError("Expected removed object"); }
|
||||
catch(S3Exception exception) { check(exception.statusCode()==404,"eligible object absent from S3 after cleanup"); }
|
||||
}
|
||||
@FunctionalInterface private interface Checked { void run() throws Exception; }
|
||||
private static void expectStatus(int status, Checked operation, String label) throws Exception {
|
||||
try { operation.run(); throw new AssertionError(label); } catch(PrintingException exception) { check(exception.getStatus()==status,label); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Initialize a tenant-scoped service using the shared synthetic storage metadata.
|
||||
***************************************************************************/
|
||||
private static PrintingService service(QInstance instance, String tenant) throws Exception
|
||||
{
|
||||
PrintingService service = new PrintingService(database, tenant);
|
||||
service.initialize();
|
||||
service.configureStorage(instance, TABLE);
|
||||
return service;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Create and approve a synthetic storage-only destination.
|
||||
***************************************************************************/
|
||||
private static String enroll(PrintingService service) throws Exception
|
||||
{
|
||||
String id = service.enroll(service.createEnrollment("fixture", "Synthetic station")
|
||||
.put("deviceName", "Synthetic station").put("agentVersion", "isolated-storage-fixture")).getString("deviceId");
|
||||
service.inventory(id, new JSONArray().put(new JSONObject().put("printerId", "synthetic-never-dispatched")
|
||||
.put("name", "Synthetic storage-only destination").put("status", "READY").put("formats", new JSONArray(List.of("PDF", "ZPL")))));
|
||||
service.enablePrinter(id, "synthetic-never-dispatched", true);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Build a repeatable submission containing synthetic document bytes.
|
||||
***************************************************************************/
|
||||
private static JSONObject request(String device, String key, String format, byte[] bytes)
|
||||
{
|
||||
return new JSONObject().put("deviceId", device).put("printerId", "synthetic-never-dispatched").put("title", "Synthetic storage qualification")
|
||||
.put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Verify submission replay, download integrity, and the actual external object representation.
|
||||
***************************************************************************/
|
||||
private static String submitCheck(PrintingService service, String device, String key, String format, byte[] bytes, boolean multipart) throws Exception
|
||||
{
|
||||
JSONObject request = request(device, key, format, bytes);
|
||||
String id = service.submit(request).getString("jobId");
|
||||
int count = objectCount();
|
||||
check(id.equals(service.submit(request).getString("jobId")) && count == objectCount(), key + " stable replay creates no object");
|
||||
check(service.claim(device).getJSONObject("job").getString("jobId").equals(id), key + " claimed through production service");
|
||||
byte[] actual = service.content(device, id);
|
||||
check(Arrays.equals(bytes, actual) && sha(bytes).equals(sha(actual)), key + " download bytes and SHA256 match");
|
||||
String ref = reference(id);
|
||||
String actualKey = objectKey(ref);
|
||||
HeadObjectResponse head = s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(actualKey).build());
|
||||
check(head.contentLength() == bytes.length, key + " object length exact");
|
||||
check(head.contentType().equals(format.equals("PDF") ? "application/pdf" : "application/vnd.zebra-zpl"), key + " content type correct");
|
||||
check(head.eTag().contains("-2") == multipart, key + " expected single/multipart object ETag");
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT content_base64,content_storage_table FROM printing_job WHERE id=?"))
|
||||
{
|
||||
statement.setString(1, id);
|
||||
try(var rows = statement.executeQuery())
|
||||
{
|
||||
check(rows.next() && rows.getString(1) == null && TABLE.equals(rows.getString(2)), key + " payload external, no inline database copy");
|
||||
}
|
||||
}
|
||||
DOCUMENTS.put(new JSONObject().put("case", key).put("format", format).put("bytes", bytes.length).put("sha256", sha(bytes)).put("multipart", multipart).put("etag", head.eTag())
|
||||
.put("objectKeyEqualsStoredReference", actualKey.equals(ref)).put("objectKeyPrefixBeforeReference", actualKey.substring(0, actualKey.length() - ref.length())));
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Record synthetic lifecycle events without invoking a physical spooler.
|
||||
***************************************************************************/
|
||||
private static void finish(PrintingService service, String device, String id, String status) throws Exception
|
||||
{
|
||||
for(String phase : List.of("RECEIVED", "SUBMITTING", status))
|
||||
{
|
||||
service.event(device, id, new JSONObject().put("eventId", UUID.randomUUID().toString()).put("status", phase).put("message", "Synthetic receipt; no physical device or spooler used"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Compute the document digest used for round-trip assertions.
|
||||
***************************************************************************/
|
||||
private static String sha(byte[] bytes) throws Exception
|
||||
{
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Count objects in the dedicated synthetic bucket.
|
||||
***************************************************************************/
|
||||
private static int objectCount()
|
||||
{
|
||||
return s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().size();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve and cache the physical key corresponding to a persisted document reference.
|
||||
***************************************************************************/
|
||||
private static String objectKey(String reference)
|
||||
{
|
||||
String known = OBJECT_KEYS.get(reference);
|
||||
if(known != null)
|
||||
{
|
||||
return known;
|
||||
}
|
||||
List<String> matches = s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().stream()
|
||||
.map(S3Object::key).filter(key -> key.endsWith(reference)).toList();
|
||||
if(matches.size() != 1)
|
||||
{
|
||||
throw new AssertionError("Expected exactly one synthetic object for stored reference");
|
||||
}
|
||||
String key = matches.getFirst();
|
||||
String prefix = key.substring(0, key.length() - reference.length());
|
||||
if(!prefix.isEmpty() && !prefix.matches("\\\\+"))
|
||||
{
|
||||
throw new AssertionError("Unexpected physical S3 key prefix");
|
||||
}
|
||||
OBJECT_KEYS.put(reference, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read the accepted external reference for a synthetic job.
|
||||
***************************************************************************/
|
||||
private static String reference(String id) throws Exception
|
||||
{
|
||||
return field(id, "content_reference");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read the lifecycle state of a synthetic job.
|
||||
***************************************************************************/
|
||||
private static String status(String id) throws Exception
|
||||
{
|
||||
return field(id, "status");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read a fixture-selected field from a synthetic job.
|
||||
***************************************************************************/
|
||||
private static String field(String id, String field) throws Exception
|
||||
{
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT " + field + " FROM printing_job WHERE id=?"))
|
||||
{
|
||||
statement.setString(1, id);
|
||||
try(var rows = statement.executeQuery())
|
||||
{
|
||||
if(!rows.next())
|
||||
{
|
||||
throw new AssertionError("Missing synthetic job");
|
||||
}
|
||||
return rows.getString(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Apply a bounded synthetic fixture adjustment with bound values.
|
||||
***************************************************************************/
|
||||
private static void execute(String sql, String... args) throws Exception
|
||||
{
|
||||
try(var c = database.getConnection(); var statement = c.prepareStatement(sql))
|
||||
{
|
||||
for(int i = 0; i < args.length; i++)
|
||||
{
|
||||
statement.setString(i + 1, args[i]);
|
||||
}
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Record a successful assertion or stop qualification immediately.
|
||||
***************************************************************************/
|
||||
private static void check(boolean pass, String label)
|
||||
{
|
||||
if(!pass)
|
||||
{
|
||||
throw new AssertionError(label);
|
||||
}
|
||||
CHECKS.put(label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Verify an eligible object is absent after retention.
|
||||
***************************************************************************/
|
||||
private static void missing(String ref)
|
||||
{
|
||||
try
|
||||
{
|
||||
s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(ref).build());
|
||||
throw new AssertionError("Expected removed object");
|
||||
}
|
||||
catch(S3Exception exception)
|
||||
{
|
||||
check(exception.statusCode() == 404, "eligible object absent from S3 after cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Operation that can surface a checked storage or service failure.
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Checked
|
||||
{
|
||||
/***************************************************************************
|
||||
** Execute an operation whose expected HTTP-domain failure is checked by the caller.
|
||||
***************************************************************************/
|
||||
void run() throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Require the exact domain rejection while allowing unrelated failures to surface.
|
||||
***************************************************************************/
|
||||
private static void expectStatus(int status, Checked operation, String label) throws Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
operation.run();
|
||||
throw new AssertionError(label);
|
||||
}
|
||||
catch(PrintingException exception)
|
||||
{
|
||||
check(exception.getStatus() == status, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
-83
@@ -19,94 +19,229 @@
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
import java.nio.file.*;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qqq.middleware.javalin.*;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
|
||||
import io.javalin.Javalin;
|
||||
import org.json.*;
|
||||
/** Fixture only: real checkout host, synthetic SDK producer, no physical printer. */
|
||||
public final class WorkloadFixture {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path dir=Path.of(args[0]).toAbsolutePath();
|
||||
if(!dir.getFileName().toString().startsWith("run-"))throw new IllegalArgumentException("Owned run directory required");
|
||||
String jdbc="jdbc:h2:file:"+dir.resolve("synthetic").toString().replace('\\','/')+";DB_CLOSE_DELAY=-1";
|
||||
String password="synthetic-"+UUID.randomUUID();
|
||||
List<QApplicationLauncher> launches=new ArrayList<>(); List<PrintingService> services=new ArrayList<>();
|
||||
List<String> devices=new ArrayList<>(); JSONArray inputs=new JSONArray();
|
||||
boolean idle=args.length>1 && args[1].equals("idle"); int total=idle?40:10000; long started=System.nanoTime(); int exit=1;
|
||||
try {
|
||||
for(int tenant=0;tenant<2;tenant++) {
|
||||
PrintingLocalHost host=new PrintingLocalHost(new PrintingLocalSettings(jdbc,"sa","","tenant-"+tenant,0,"fixture-only-management-token-123456","operator",password));
|
||||
AtomicReference<Javalin> app=new AtomicReference<>();
|
||||
QApplicationLauncher launch=QApplicationLauncher.run(host,new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server->{host.configure(server,false);server.withJavalinConfigurationCustomizer(app::set);}));
|
||||
launches.add(launch);
|
||||
PrintingService service=launch.getQInstance().getQBits().values().stream().map(v->v.getConfig()).filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService();
|
||||
services.add(service);
|
||||
for(int lane=0;lane<4;lane++) {
|
||||
JSONObject enrolled=service.enrollForUser(service.createEnrollment("default","Synthetic lane").put("deviceName","Synthetic lane").put("agentVersion","qualification"),"operator");
|
||||
String device=enrolled.getString("deviceId");devices.add(device);
|
||||
service.inventory(device,new JSONArray().put(new JSONObject().put("printerId","synthetic").put("name","No physical printer").put("status","READY").put("formats",new JSONArray().put("ZPL"))));service.enablePrinter(device,"synthetic",true);
|
||||
inputs.put(new JSONObject().put("origin","http://127.0.0.1:"+app.get().port()+"/").put("username","operator").put("password",password).put("tenant","tenant-"+tenant).put("deviceId",device).put("deviceToken",enrolled.getString("deviceToken")));
|
||||
}
|
||||
}
|
||||
// Only synthetic credentials inside the already protected run directory.
|
||||
Files.writeString(dir.resolve("input.json"),new JSONObject().put("lanes",inputs).put("idle",idle).put("jobsPerLane",total/8).toString());
|
||||
await(dir.resolve("ready"),Duration.ofMinutes(2));
|
||||
byte[] payload=("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS"+" ".repeat(980)+"^XZ").getBytes(StandardCharsets.US_ASCII);
|
||||
String encoded=Base64.getEncoder().encodeToString(payload);
|
||||
long producing=System.nanoTime();
|
||||
if(idle) { for(int job=0;job<total;job++) { int lane=job%8; PrintingService service=services.get(lane/4); JSONObject request=new JSONObject().put("deviceId",devices.get(lane)).put("printerId","synthetic").put("title","Synthetic idle workload").put("format","ZPL").put("contentBase64",encoded).put("idempotencyKey","idle-"+job); service.submit(request); await(dir.resolve("completed-"+(job+1)),Duration.ofSeconds(30)); Thread.sleep(200); } } else try(var executor=Executors.newFixedThreadPool(8)) {
|
||||
List<Future<?>> futures=new ArrayList<>();
|
||||
for(int lane=0;lane<8;lane++) { final int index=lane;
|
||||
futures.add(executor.submit(()->{try{
|
||||
PrintingService service=services.get(index/4);
|
||||
for(int job=0;job<1250;job++) {
|
||||
JSONObject request=new JSONObject().put("deviceId",devices.get(index)).put("printerId","synthetic").put("title","Synthetic workload "+job).put("format","ZPL").put("contentBase64",encoded).put("idempotencyKey","lane-"+index+"-"+job);
|
||||
JSONObject submitted=service.submit(request);
|
||||
if(job%25==0 && !submitted.getString("jobId").equals(service.submit(request).getString("jobId")))throw new IllegalStateException("Replay duplicated job");
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fixture only: real checkout host, synthetic SDK producer, no physical printer.
|
||||
***************************************************************************/
|
||||
public final class WorkloadFixture
|
||||
{
|
||||
/***************************************************************************
|
||||
** Drive two synthetic tenants through real network workers and verify durable receipts and accounting.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
Path dir = Path.of(args[0]).toAbsolutePath();
|
||||
if(!dir.getFileName().toString().startsWith("run-"))
|
||||
{
|
||||
throw new IllegalArgumentException("Owned run directory required");
|
||||
}
|
||||
}catch(Exception e){throw new RuntimeException(e);}}));
|
||||
}
|
||||
for(var future:futures)future.get(15,TimeUnit.MINUTES);
|
||||
String jdbc = "jdbc:h2:file:" + dir.resolve("synthetic").toString().replace('\\', '/') + ";DB_CLOSE_DELAY=-1";
|
||||
String password = "synthetic-" + UUID.randomUUID();
|
||||
List<QApplicationLauncher> launches = new ArrayList<>();
|
||||
List<PrintingService> services = new ArrayList<>();
|
||||
List<String> devices = new ArrayList<>();
|
||||
JSONArray inputs = new JSONArray();
|
||||
boolean idle = args.length > 1 && args[1].equals("idle");
|
||||
int total = idle ? 40 : 10000;
|
||||
long started = System.nanoTime();
|
||||
int exit = 1;
|
||||
try
|
||||
{
|
||||
for(int tenant = 0; tenant < 2; tenant++)
|
||||
{
|
||||
PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "tenant-" + tenant, 0, "fixture-only-management-token-123456", "operator", password));
|
||||
AtomicReference<Javalin> app = new AtomicReference<>();
|
||||
QApplicationLauncher launch = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server ->
|
||||
{
|
||||
host.configure(server, false);
|
||||
server.withJavalinConfigurationCustomizer(app::set);
|
||||
}));
|
||||
launches.add(launch);
|
||||
PrintingService service = launch.getQInstance().getQBits().values().stream().map(v -> v.getConfig()).filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService();
|
||||
services.add(service);
|
||||
for(int lane = 0; lane < 4; lane++)
|
||||
{
|
||||
JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Synthetic lane").put("deviceName", "Synthetic lane").put("agentVersion", "qualification"), "operator");
|
||||
String device = enrolled.getString("deviceId");
|
||||
devices.add(device);
|
||||
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "synthetic").put("name", "No physical printer").put("status", "READY").put("formats", new JSONArray().put("ZPL"))));
|
||||
service.enablePrinter(device, "synthetic", true);
|
||||
inputs.put(new JSONObject().put("origin", "http://127.0.0.1:" + app.get().port() + "/").put("username", "operator").put("password", password).put("tenant", "tenant-" + tenant).put("deviceId", device).put("deviceToken", enrolled.getString("deviceToken")));
|
||||
}
|
||||
}
|
||||
// Only synthetic credentials inside the already protected run directory.
|
||||
Files.writeString(dir.resolve("input.json"), new JSONObject().put("lanes", inputs).put("idle", idle).put("jobsPerLane", total / 8).toString());
|
||||
await(dir.resolve("ready"), Duration.ofMinutes(2));
|
||||
byte[] payload = ("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS" + " ".repeat(980) + "^XZ").getBytes(StandardCharsets.US_ASCII);
|
||||
String encoded = Base64.getEncoder().encodeToString(payload);
|
||||
long producing = System.nanoTime();
|
||||
if(idle)
|
||||
{
|
||||
for(int job = 0; job < total; job++)
|
||||
{
|
||||
int lane = job % 8;
|
||||
PrintingService service = services.get(lane / 4);
|
||||
JSONObject request = new JSONObject().put("deviceId", devices.get(lane)).put("printerId", "synthetic").put("title", "Synthetic idle workload").put("format", "ZPL").put("contentBase64", encoded).put("idempotencyKey", "idle-" + job);
|
||||
service.submit(request);
|
||||
await(dir.resolve("completed-" + (job + 1)), Duration.ofSeconds(30));
|
||||
Thread.sleep(200);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try(var executor = Executors.newFixedThreadPool(8))
|
||||
{
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
for(int lane = 0; lane < 8; lane++)
|
||||
{
|
||||
final int index = lane;
|
||||
futures.add(executor.submit(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
PrintingService service = services.get(index / 4);
|
||||
for(int job = 0; job < 1250; job++)
|
||||
{
|
||||
JSONObject request = new JSONObject().put("deviceId", devices.get(index)).put("printerId", "synthetic").put("title", "Synthetic workload " + job).put("format", "ZPL").put("contentBase64", encoded).put("idempotencyKey", "lane-" + index + "-" + job);
|
||||
JSONObject submitted = service.submit(request);
|
||||
if(job % 25 == 0 && !submitted.getString("jobId").equals(service.submit(request).getString("jobId")))
|
||||
{
|
||||
throw new IllegalStateException("Replay duplicated job");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for(var future : futures)
|
||||
{
|
||||
future.get(15, TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
}
|
||||
double producerSeconds = (System.nanoTime() - producing) / 1e9;
|
||||
Files.writeString(dir.resolve("producer-done"), "done");
|
||||
await(dir.resolve("done"), Duration.ofMinutes(15));
|
||||
JSONObject client = new JSONObject(Files.readString(dir.resolve("result.json")));
|
||||
if(!client.getBoolean("success"))
|
||||
{
|
||||
throw new IllegalStateException("Client reported failure");
|
||||
}
|
||||
JSONObject report = new JSONObject().put("success", true).put("jobs", total).put("receipts", total * 3).put("idle", idle).put("producerSeconds", producerSeconds).put("elapsedSeconds", (System.nanoTime() - started) / 1e9).put("realPrinterCalls", 0);
|
||||
try(Connection connection = DriverManager.getConnection(jdbc, "sa", ""); Statement query = connection.createStatement())
|
||||
{
|
||||
require(query, "SELECT COUNT(*) FROM printing_job", total);
|
||||
require(query, "SELECT COUNT(*) FROM printing_job WHERE status='SPOOLED'", total);
|
||||
require(query, "SELECT COUNT(*) FROM printing_event", total * 3);
|
||||
require(query, "SELECT COUNT(*) FROM printing_document_orphan", 0);
|
||||
for(int tenant = 0; tenant < 2; tenant++)
|
||||
{
|
||||
require(query, "SELECT COUNT(*) FROM printing_job WHERE tenant_id='tenant-" + tenant + "'", total / 2);
|
||||
require(query, "SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant-" + tenant + "' AND device_id=''", (total / 2L) * payload.length);
|
||||
}
|
||||
JSONArray cross = new JSONArray();
|
||||
for(int tenant = 0; tenant < 2; tenant++)
|
||||
{
|
||||
String foreign = services.get(1 - tenant).jobsPage(1, null).getJSONArray("jobs").getJSONObject(0).getString("jobId");
|
||||
try
|
||||
{
|
||||
services.get(tenant).getJob(foreign);
|
||||
throw new IllegalStateException("Foreign job visible");
|
||||
}
|
||||
catch(PrintingException denied)
|
||||
{
|
||||
if(denied.getStatus() != 404)
|
||||
{
|
||||
throw denied;
|
||||
}
|
||||
}
|
||||
cross.put(true);
|
||||
}
|
||||
report.put("tenantIsolation", cross).put("retainedAccountingVerified", true).put("bytesPerDocument", payload.length);
|
||||
}
|
||||
Files.writeString(dir.resolve("server-result.json"), report.toString(2));
|
||||
exit = 0;
|
||||
}
|
||||
catch(Throwable failure)
|
||||
{
|
||||
Files.writeString(dir.resolve("server-error.json"), new JSONObject().put("error", failure.getClass().getName()).put("cause", failure.getCause() == null ? "" : failure.getCause().getClass().getName()).toString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
for(int i = launches.size() - 1; i >= 0; i--)
|
||||
{
|
||||
launches.get(i).stop();
|
||||
}
|
||||
try(Connection connection = DriverManager.getConnection(jdbc, "sa", ""); Statement query = connection.createStatement())
|
||||
{
|
||||
query.execute("SHUTDOWN");
|
||||
}
|
||||
Files.writeString(dir.resolve("stopped.json"), new JSONObject().put("normalStop", true).put("exitCode", exit).toString());
|
||||
}
|
||||
System.exit(exit);
|
||||
}
|
||||
double producerSeconds=(System.nanoTime()-producing)/1e9;
|
||||
Files.writeString(dir.resolve("producer-done"),"done");
|
||||
await(dir.resolve("done"),Duration.ofMinutes(15));
|
||||
JSONObject client=new JSONObject(Files.readString(dir.resolve("result.json")));
|
||||
if(!client.getBoolean("success"))throw new IllegalStateException("Client reported failure");
|
||||
JSONObject report=new JSONObject().put("success",true).put("jobs",total).put("receipts",total*3).put("idle",idle).put("producerSeconds",producerSeconds).put("elapsedSeconds",(System.nanoTime()-started)/1e9).put("realPrinterCalls",0);
|
||||
try(Connection connection=DriverManager.getConnection(jdbc,"sa","");Statement query=connection.createStatement()) {
|
||||
require(query,"SELECT COUNT(*) FROM printing_job",total);
|
||||
require(query,"SELECT COUNT(*) FROM printing_job WHERE status='SPOOLED'",total);
|
||||
require(query,"SELECT COUNT(*) FROM printing_event",total*3);
|
||||
require(query,"SELECT COUNT(*) FROM printing_document_orphan",0);
|
||||
for(int tenant=0;tenant<2;tenant++) {
|
||||
require(query,"SELECT COUNT(*) FROM printing_job WHERE tenant_id='tenant-"+tenant+"'",total/2);
|
||||
require(query,"SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant-"+tenant+"' AND device_id=''",(total/2L)*payload.length);
|
||||
}
|
||||
JSONArray cross=new JSONArray();for(int tenant=0;tenant<2;tenant++) {
|
||||
String foreign=services.get(1-tenant).jobsPage(1,null).getJSONArray("jobs").getJSONObject(0).getString("jobId");
|
||||
try {services.get(tenant).getJob(foreign);throw new IllegalStateException("Foreign job visible");}catch(PrintingException denied){if(denied.getStatus()!=404)throw denied;}
|
||||
cross.put(true);
|
||||
}
|
||||
report.put("tenantIsolation",cross).put("retainedAccountingVerified",true).put("bytesPerDocument",payload.length);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wait for a private coordination marker with a monotonic deadline.
|
||||
***************************************************************************/
|
||||
static void await(Path file, Duration limit) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + limit.toNanos();
|
||||
while(!Files.exists(file))
|
||||
{
|
||||
if(System.nanoTime() > deadline)
|
||||
{
|
||||
throw new TimeoutException("Fixture deadline");
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Verify a synthetic database aggregate without emitting row values.
|
||||
***************************************************************************/
|
||||
static void require(Statement query, String sql, long expected) throws Exception
|
||||
{
|
||||
try(ResultSet rows = query.executeQuery(sql))
|
||||
{
|
||||
if(!rows.next() || rows.getLong(1) != expected)
|
||||
{
|
||||
throw new IllegalStateException("Synthetic count/accounting mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
Files.writeString(dir.resolve("server-result.json"),report.toString(2));exit=0;
|
||||
} catch(Throwable failure) {
|
||||
Files.writeString(dir.resolve("server-error.json"),new JSONObject().put("error",failure.getClass().getName()).put("cause",failure.getCause()==null?"":failure.getCause().getClass().getName()).toString());
|
||||
} finally {
|
||||
for(int i=launches.size()-1;i>=0;i--)launches.get(i).stop();
|
||||
try(Connection connection=DriverManager.getConnection(jdbc,"sa","");Statement query=connection.createStatement()){query.execute("SHUTDOWN");}
|
||||
Files.writeString(dir.resolve("stopped.json"),new JSONObject().put("normalStop",true).put("exitCode",exit).toString());
|
||||
}
|
||||
System.exit(exit);
|
||||
}
|
||||
static void await(Path file,Duration limit)throws Exception {long deadline=System.nanoTime()+limit.toNanos();while(!Files.exists(file)){if(System.nanoTime()>deadline)throw new TimeoutException("Fixture deadline");Thread.sleep(100);}}
|
||||
static void require(Statement query,String sql,long expected)throws Exception {try(ResultSet rows=query.executeQuery(sql)){if(!rows.next()||rows.getLong(1)!=expected)throw new IllegalStateException("Synthetic count/accounting mismatch");}}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fc
|
||||
RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz
|
||||
RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo
|
||||
RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/
|
||||
COPY dev/Directory.Build.props dev/Directory.Packages.props dev/.editorconfig dev/global.json /fixture/dev/
|
||||
WORKDIR /fixture/dev/native-auth/client
|
||||
COPY dev/native-auth/client .
|
||||
COPY dev/client/WorkloadDiscovery.cs /fixture/dev/client/WorkloadDiscovery.cs
|
||||
RUN dotnet build -c Release --nologo -o /out
|
||||
RUN dotnet build -c Release -p:RestoreLockedMode=true --nologo -o /out
|
||||
RUN dotnet format --verify-no-changes --severity warn --no-restore
|
||||
RUN dotnet /out/NativeAuth.dll --check-discovery
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
|
||||
|
||||
@@ -12,10 +12,11 @@ COPY pom.xml mvnw ./
|
||||
COPY .mvn .mvn
|
||||
COPY checkstyle checkstyle
|
||||
COPY src src
|
||||
COPY dev/java dev/java
|
||||
COPY dev/native-auth/java dev/native-auth/java
|
||||
COPY LICENSE NOTICE ./
|
||||
RUN chmod +x mvnw && ./mvnw -B -Dmaven.repo.local=/candidate-maven -Dqqq.version=4.1.0-native-dev-ecf0f550 -Dmaven.test.skip=true package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime
|
||||
COPY dev/native-auth/java /fixture-src
|
||||
RUN mkdir /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes /fixture-src/*.java
|
||||
RUN mkdir /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes dev/native-auth/java/*.java
|
||||
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Experimental native Authentik qualification
|
||||
|
||||
This optional profile tests an **unreleased upstream QQQ candidate**. It does
|
||||
not replace the default released RC.20 TABLE/MinIO development environment,
|
||||
This optional profile preserves a **historical upstream candidate** for reproducible
|
||||
qualification. The normal application now uses published QQQ RC.21 with native
|
||||
session login; this profile still builds its explicit older source pin. It does
|
||||
not replace the default TABLE/MinIO development environment,
|
||||
install a connector, or qualify the Windows browser callback UI.
|
||||
|
||||
From the repository root, with Docker and PowerShell 7.4 or later:
|
||||
@@ -77,9 +79,10 @@ private input/result files, rather than a host home or credential directory.
|
||||
The QQQ fixture uses fresh standard session, redirect-state and encrypted
|
||||
refresh-token tables. Its customizer maps the verified provider's synthetic
|
||||
`printing_admin` group to the existing native permission. It creates no device
|
||||
pairing, job, print operation or fabricated user session. Released RC.20 lacks
|
||||
the native-session grant; this experiment must stay opt-in until an appropriate
|
||||
immutable upstream tuple is published and separately adopted.
|
||||
pairing, job, print operation or fabricated user session. RC.20 lacked the
|
||||
native-session grant; RC.21 now supplies it in the ordinary application. This
|
||||
fixture remains opt-in and pinned to its recorded source rather than silently
|
||||
substituting a different runtime into historical results.
|
||||
|
||||
## Recorded local qualification
|
||||
|
||||
@@ -93,4 +96,4 @@ probe compatibility correction also passed all eight checks on 2026-11-01.
|
||||
All source files matched the pre-run hash manifest through completion, and
|
||||
independent cleanup again found no owned containers, volumes or networks.
|
||||
The native image's 12 discovery checks and default workload's nine checks pass.
|
||||
The unreleased upstream and installed-browser limitations above still apply.
|
||||
The historical source pin and installed-browser limitations above still apply.
|
||||
@@ -1,18 +1,27 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Receives the actual HTTPS-proxied callback only inside this test network.</summary>
|
||||
internal sealed class CallbackReceiver : IDisposable
|
||||
{
|
||||
private readonly HttpListener _listener = new();
|
||||
|
||||
|
||||
|
||||
private readonly Channel<Uri> _callbacks = Channel.CreateBounded<Uri>(1);
|
||||
|
||||
|
||||
|
||||
private readonly Uri _callback;
|
||||
|
||||
|
||||
|
||||
/// <summary>Receive callbacks only inside the owned native-auth container.</summary>
|
||||
internal CallbackReceiver(Uri callback)
|
||||
{
|
||||
_callback = callback;
|
||||
@@ -21,6 +30,9 @@ internal sealed class CallbackReceiver : IDisposable
|
||||
_ = ReceiveAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Accept only the configured callback path and publish one bounded result.</summary>
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
try
|
||||
@@ -29,10 +41,12 @@ internal sealed class CallbackReceiver : IDisposable
|
||||
{
|
||||
HttpListenerContext context = await _listener.GetContextAsync();
|
||||
Uri? request = context.Request.Url;
|
||||
bool valid = request is not null && request.AbsolutePath == _callback.AbsolutePath
|
||||
&& context.Request.Headers["Host"] == _callback.Authority
|
||||
&& context.Request.Headers["X-Forwarded-Proto"] == "https"
|
||||
&& context.Request.HttpMethod == "GET" && request.Query.Length is > 0 and < 16384;
|
||||
bool valid = request is not null
|
||||
&& string.Equals(request.AbsolutePath, _callback.AbsolutePath, StringComparison.Ordinal)
|
||||
&& string.Equals(context.Request.Headers["Host"], _callback.Authority, StringComparison.Ordinal)
|
||||
&& string.Equals(context.Request.Headers["X-Forwarded-Proto"], "https", StringComparison.Ordinal)
|
||||
&& string.Equals(context.Request.HttpMethod, "GET", StringComparison.Ordinal)
|
||||
&& request.Query.Length is > 0 and < 16384;
|
||||
if(valid)
|
||||
{
|
||||
var actual = new UriBuilder(_callback) { Query = request!.Query.TrimStart('?') };
|
||||
@@ -49,6 +63,13 @@ internal sealed class CallbackReceiver : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Await the actual provider callback under the caller deadline.</summary>
|
||||
internal ValueTask<Uri> ReadAsync(CancellationToken token) => _callbacks.Reader.ReadAsync(token);
|
||||
|
||||
|
||||
|
||||
/// <summary>Close the owned callback listener.</summary>
|
||||
public void Dispose() => _listener.Close();
|
||||
}
|
||||
@@ -1,26 +1,69 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Small bounded fixture transport; no redirects, proxy or response-body diagnostics.</summary>
|
||||
internal sealed class FixtureHttp : IDisposable
|
||||
{
|
||||
/// <summary>Carry only the parsed bounded response and optional redirect location.</summary>
|
||||
internal sealed record Reply(HttpStatusCode Status, JsonObject? Json, Uri? Location);
|
||||
|
||||
|
||||
|
||||
/// <summary>Record fixed classifications without credentials, URLs, or response bodies.</summary>
|
||||
internal sealed record TraceEntry(string Kind, int Status, bool HasQuery, string? LocationKind, bool LocationHasQuery);
|
||||
|
||||
|
||||
|
||||
internal static List<TraceEntry> Trace { get; } = [];
|
||||
internal static string? LastRequestKind { get; private set; }
|
||||
internal static int? LastStatus { get; private set; }
|
||||
|
||||
|
||||
|
||||
internal static string? LastRequestKind
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
internal static int? LastStatus
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
|
||||
|
||||
private readonly CookieContainer _cookies = new();
|
||||
|
||||
|
||||
|
||||
private readonly Uri _origin;
|
||||
|
||||
|
||||
|
||||
private readonly string? _adminToken;
|
||||
|
||||
|
||||
|
||||
/// <summary>Configure bounded same-origin HTTP with redirects and proxies disabled.</summary>
|
||||
internal FixtureHttp(Uri origin, bool cookies = false, string? adminToken = null)
|
||||
{
|
||||
_origin = origin;
|
||||
@@ -32,9 +75,12 @@ internal sealed class FixtureHttp : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Send one origin-confined request and retain bounded sanitized diagnostics.</summary>
|
||||
internal async Task<Reply> SendAsync(string method, Uri uri, JsonObject? body, CancellationToken cancellationToken)
|
||||
{
|
||||
if(uri.Scheme != "https" || uri.GetLeftPart(UriPartial.Authority) != _origin.GetLeftPart(UriPartial.Authority))
|
||||
if(!string.Equals(uri.Scheme, "https", StringComparison.Ordinal) || !string.Equals(uri.GetLeftPart(UriPartial.Authority), _origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("A fixture request left its configured HTTPS origin.");
|
||||
}
|
||||
@@ -44,7 +90,10 @@ internal sealed class FixtureHttp : IDisposable
|
||||
LastStatus = null;
|
||||
if(_adminToken is not null)
|
||||
{
|
||||
if(!uri.AbsolutePath.StartsWith("/api/v3/", StringComparison.Ordinal)) { throw new InvalidOperationException("Administrative credential scope refused."); }
|
||||
if(!uri.AbsolutePath.StartsWith("/api/v3/", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Administrative credential scope refused.");
|
||||
}
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
|
||||
}
|
||||
if(body is not null)
|
||||
@@ -52,36 +101,57 @@ internal sealed class FixtureHttp : IDisposable
|
||||
request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
request.Headers.Add("Origin", _origin.GetLeftPart(UriPartial.Authority));
|
||||
request.Headers.Referrer = _origin;
|
||||
Cookie? csrf = _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => cookie.Name == "authentik_csrf");
|
||||
csrf ??= _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => cookie.Name == "csrftoken");
|
||||
if(csrf is not null) { request.Headers.Add("X-authentik-CSRF", csrf.Value); }
|
||||
Cookie? csrf = _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => string.Equals(cookie.Name, "authentik_csrf", StringComparison.Ordinal));
|
||||
csrf ??= _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => string.Equals(cookie.Name, "csrftoken", StringComparison.Ordinal));
|
||||
if(csrf is not null)
|
||||
{
|
||||
request.Headers.Add("X-authentik-CSRF", csrf.Value);
|
||||
}
|
||||
}
|
||||
using HttpResponseMessage response = await _client.SendAsync(request, cancellationToken);
|
||||
LastStatus = (int)response.StatusCode;
|
||||
Uri? location = response.Headers.Location is null ? null : new Uri(_origin, response.Headers.Location);
|
||||
if(Trace.Count == 32) { Trace.RemoveAt(0); }
|
||||
if(Trace.Count == 32)
|
||||
{
|
||||
Trace.RemoveAt(0);
|
||||
}
|
||||
Trace.Add(new TraceEntry(LastRequestKind, (int)response.StatusCode, uri.Query.Length != 0,
|
||||
location is null ? null : Classify(location, "GET"), location?.Query.Length > 0));
|
||||
string content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
JsonObject? json = response.Content.Headers.ContentType?.MediaType == "application/json" && content.Length != 0
|
||||
JsonObject? json = string.Equals(response.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.Ordinal) && content.Length != 0
|
||||
? JsonNode.Parse(content)?.AsObject() : null;
|
||||
return new Reply(response.StatusCode, json, response.Headers.Location);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Send one origin-confined request and retain bounded sanitized diagnostics.</summary>
|
||||
internal Task<Reply> SendAsync(string method, string path, JsonObject? body, CancellationToken cancellationToken)
|
||||
=> SendAsync(method, new Uri(_origin, path), body, cancellationToken);
|
||||
|
||||
private static string Classify(Uri uri, string method) => uri.Host == "callback.printing.internal" ? "callback"
|
||||
|
||||
|
||||
/// <summary>Map an endpoint to a fixed label without exposing its path or query.</summary>
|
||||
private static string Classify(Uri uri, string method) => string.Equals(uri.Host, "callback.printing.internal", StringComparison.Ordinal) ? "callback"
|
||||
: uri.AbsolutePath.StartsWith("/application/o/authorize", StringComparison.Ordinal) ? "provider-authorization"
|
||||
: uri.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal) ? "provider-flow-page"
|
||||
: uri.AbsolutePath.StartsWith("/api/v3/flows/executor/", StringComparison.Ordinal) ? "provider-flow-executor-" + method
|
||||
: "fixture-api";
|
||||
|
||||
|
||||
|
||||
/// <summary>Require the expected status and a parsed JSON object.</summary>
|
||||
internal static JsonObject RequireJson(Reply reply, HttpStatusCode expected)
|
||||
{
|
||||
if(reply.Status != expected || reply.Json is null) { throw new InvalidOperationException("Unexpected fixture API status or document shape."); }
|
||||
if(reply.Status != expected || reply.Json is null)
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected fixture API status or document shape.");
|
||||
}
|
||||
return reply.Json;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Dispose the owned HTTP client and handler.</summary>
|
||||
public void Dispose() => _client.Dispose();
|
||||
}
|
||||
@@ -2,17 +2,15 @@
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core"><HintPath>/core/Qqq.PrintAgent.Core.dll</HintPath></Reference>
|
||||
<Compile Include="../../client/WorkloadDiscovery.cs" Link="WorkloadDiscovery.cs" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Qqq.PrintAgent.Core">
|
||||
<HintPath>$(CoreAssemblyPath)</HintPath>
|
||||
</Reference>
|
||||
<Compile Include="../../client/WorkloadDiscovery.cs" Link="WorkloadDiscovery.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -2,11 +2,12 @@
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
using Printing.Development;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Requires the pinned experimental native grant before provider authentication.</summary>
|
||||
internal static class NativeAuthDiscovery
|
||||
{
|
||||
@@ -14,7 +15,7 @@ internal static class NativeAuthDiscovery
|
||||
internal static async Task DiscoverAsync(QqqSessionClient client, CancellationToken token)
|
||||
{
|
||||
QqqApiDiscovery discovery = await WorkloadDiscovery.DiscoverRealtimeAsync(client, token);
|
||||
if(discovery.ApiVersion != "1.9.0" || !discovery.Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
|
||||
if(!string.Equals(discovery.ApiVersion, "1.9.0", StringComparison.Ordinal) || !discovery.Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The actual server did not advertise the pinned native-session API.");
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Exercises the real native probe gate against bounded, credential-free HTTP responses.</summary>
|
||||
internal static class NativeAuthDiscoveryChecks
|
||||
{
|
||||
/// <summary>Accepts compatible printing releases while preserving the exact upstream grant requirement.</summary>
|
||||
private static readonly string[] s_channels = ["api"];
|
||||
|
||||
|
||||
|
||||
/// <summary>Check the native grant and compatible extension using fake discovery responses.</summary>
|
||||
internal static async Task RunAsync()
|
||||
{
|
||||
(string PrintingVersion, string ApiVersion, bool NativeGrant, bool Accepted)[] cases =
|
||||
@@ -42,12 +47,15 @@ internal static class NativeAuthDiscoveryChecks
|
||||
}
|
||||
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired)
|
||||
{
|
||||
// Expected for incompatible discovery: the case stays rejected and the comparison below judges it.
|
||||
}
|
||||
catch(InvalidDataException)
|
||||
{
|
||||
// Expected for malformed discovery values: the case stays rejected and the comparison below judges it.
|
||||
}
|
||||
catch(InvalidOperationException)
|
||||
{
|
||||
// Expected when native-session discovery is absent: the case stays rejected and judged below.
|
||||
}
|
||||
if(accepted != expected || handler.Requests != 1)
|
||||
{
|
||||
@@ -63,15 +71,24 @@ internal static class NativeAuthDiscoveryChecks
|
||||
private sealed class DiscoveryHandler(string printingVersion, string apiVersion, bool nativeGrant) : HttpMessageHandler
|
||||
{
|
||||
/// <summary>Ensures rejected metadata still traversed the public HTTP discovery boundary.</summary>
|
||||
internal int Requests { get; private set; }
|
||||
internal int Requests
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Refuses any credential or request outside the public discovery boundary.</summary>
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if(request.Method != HttpMethod.Get || request.RequestUri?.AbsoluteUri != "https://printing.example.invalid/qqq/v1/version"
|
||||
|| request.Headers.Contains("Cookie") || request.Headers.Contains("Authorization")
|
||||
if(request.Method != HttpMethod.Get
|
||||
|| !string.Equals(request.RequestUri?.AbsoluteUri, "https://printing.example.invalid/qqq/v1/version", StringComparison.Ordinal)
|
||||
|| request.Headers.Contains("Cookie")
|
||||
|| request.Headers.Contains("Authorization")
|
||||
|| request.Headers.Contains("X-QQQ-Device-Id") || request.Headers.Contains("X-QQQ-Device-Token"))
|
||||
{
|
||||
throw new InvalidOperationException("Native discovery must precede authentication and device proof.");
|
||||
@@ -84,7 +101,7 @@ internal static class NativeAuthDiscoveryChecks
|
||||
{
|
||||
apiVersion,
|
||||
features,
|
||||
clientChannels = new[] { "api" },
|
||||
clientChannels = s_channels,
|
||||
extensions = new[] { new { name = "printing", version = printingVersion, pathPrefix = "/qbit/printing" } }
|
||||
});
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
|
||||
|
||||
@@ -1,26 +1,55 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Qualify real provider sessions only in the selected experimental environment.</summary>
|
||||
internal static class Program
|
||||
{
|
||||
private const string CoreSource = "ecf0f550a0357cec6387c2b54efca128a405a629";
|
||||
private const string ClientSource = "346fc285198908c47a2ee9db7b9adb53d5e88c3c";
|
||||
private const string InputPath = "/work/input.json";
|
||||
private const string ResultPath = "/work/result.json";
|
||||
private const string TlsMarker = "/tmp/printing-native-untrusted-tls-passed";
|
||||
private static readonly List<string> Checks = [];
|
||||
private static string _stage = "input";
|
||||
private static bool? _authorizationRequestMatches;
|
||||
|
||||
|
||||
|
||||
private const string ClientSource = "346fc285198908c47a2ee9db7b9adb53d5e88c3c";
|
||||
|
||||
|
||||
|
||||
private const string InputPath = "/work/input.json";
|
||||
|
||||
|
||||
|
||||
private const string ResultPath = "/work/result.json";
|
||||
|
||||
|
||||
|
||||
private const string TlsMarker = "/tmp/printing-native-untrusted-tls-passed";
|
||||
|
||||
|
||||
|
||||
private static readonly JsonSerializerOptions s_reportOptions = new() { WriteIndented = true };
|
||||
|
||||
|
||||
|
||||
private static readonly List<string> s_checks = [];
|
||||
|
||||
|
||||
|
||||
private static string s_stage = "input";
|
||||
|
||||
|
||||
|
||||
private static bool? s_authorizationRequestMatches;
|
||||
|
||||
|
||||
|
||||
/// <summary>Validate synthetic input and record bounded evidence without credential output.</summary>
|
||||
public static async Task<int> Main(string[] arguments)
|
||||
{
|
||||
if(arguments is ["--check-discovery"])
|
||||
@@ -32,21 +61,28 @@ internal static class Program
|
||||
try
|
||||
{
|
||||
if(arguments.Length != 1 || arguments[0] is not ("assert-untrusted" or "test"))
|
||||
{ throw new InvalidOperationException("An explicit fixture action is required."); }
|
||||
{
|
||||
throw new InvalidOperationException("An explicit fixture action is required.");
|
||||
}
|
||||
if(new FileInfo(InputPath).Length is <= 0 or > 65536)
|
||||
{ throw new InvalidOperationException("Fixture input size is invalid."); }
|
||||
{
|
||||
throw new InvalidOperationException("Fixture input size is invalid.");
|
||||
}
|
||||
input = JsonNode.Parse(await File.ReadAllTextAsync(InputPath))!.AsObject();
|
||||
ValidateInput(input);
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||
if(arguments[0] == "assert-untrusted")
|
||||
if(string.Equals(arguments[0], "assert-untrusted", StringComparison.Ordinal))
|
||||
{
|
||||
_stage = "untrusted-tls";
|
||||
s_stage = "untrusted-tls";
|
||||
await AssertUntrustedAsync(new Uri(ProviderSetup.Text(input, "providerOrigin")), deadline.Token);
|
||||
await File.WriteAllTextAsync(TlsMarker, "passed", deadline.Token);
|
||||
return 0;
|
||||
}
|
||||
if(!File.Exists(TlsMarker)) { throw new InvalidOperationException("The ordinary TLS negative control did not run."); }
|
||||
Checks.Add("untrusted-tls-refused");
|
||||
if(!File.Exists(TlsMarker))
|
||||
{
|
||||
throw new InvalidOperationException("The ordinary TLS negative control did not run.");
|
||||
}
|
||||
s_checks.Add("untrusted-tls-refused");
|
||||
await QualifyAsync(input, deadline.Token);
|
||||
await WriteResultAsync(input, "PASS", null, null);
|
||||
Console.WriteLine("Experimental native Authentik qualification passed; development-only unpublished core.");
|
||||
@@ -57,158 +93,221 @@ internal static class Program
|
||||
// Exception messages, response bodies, callback URLs and credentials are deliberately withheld.
|
||||
await WriteResultAsync(input, "FAILED", exception.GetType().Name,
|
||||
exception is QqqSessionException sessionException ? sessionException.Failure.ToString() : null);
|
||||
Console.Error.WriteLine("Experimental native Authentik qualification failed at " + _stage + " (" + exception.GetType().Name + ").");
|
||||
Console.Error.WriteLine("Experimental native Authentik qualification failed at " + s_stage + " (" + exception.GetType().Name + ").");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Refuse fixture identities, origins, or source tuples outside the pinned contract.</summary>
|
||||
private static void ValidateInput(JsonObject input)
|
||||
{
|
||||
if(!Regex.IsMatch(ProviderSetup.Text(input, "project"), "^qbit-printing-dev-[a-f0-9]{32}$", RegexOptions.CultureInvariant)
|
||||
|| ProviderSetup.Text(input, "coreSource") != CoreSource || ProviderSetup.Text(input, "clientSource") != ClientSource
|
||||
|| ProviderSetup.Text(input, "providerOrigin") != "https://auth.printing.internal"
|
||||
|| ProviderSetup.Text(input, "qqqOrigin") != "https://qqq.printing.internal"
|
||||
|| ProviderSetup.Text(input, "callback") != "https://callback.printing.internal/oauth/callback"
|
||||
|| ProviderSetup.Text(input, "issuer") != "https://auth.printing.internal/application/o/" + ProviderSetup.Text(input, "project") + "/"
|
||||
|| ProviderSetup.Text(input, "clientId") != ProviderSetup.Text(input, "project"))
|
||||
{ throw new InvalidOperationException("Fixture identity or destination differs from the pinned local contract."); }
|
||||
if(!Regex.IsMatch(ProviderSetup.Text(input, "project"), "^qbit-printing-dev-[a-f0-9]{32}$", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "coreSource"), CoreSource, StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "clientSource"), ClientSource, StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "providerOrigin"), "https://auth.printing.internal", StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "qqqOrigin"), "https://qqq.printing.internal", StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "callback"), "https://callback.printing.internal/oauth/callback", StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "issuer"), "https://auth.printing.internal/application/o/" + ProviderSetup.Text(input, "project") + "/", StringComparison.Ordinal)
|
||||
|| !string.Equals(ProviderSetup.Text(input, "clientId"), ProviderSetup.Text(input, "project"), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Fixture identity or destination differs from the pinned local contract.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Create the production session client with redirects, cookies, and proxies controlled.</summary>
|
||||
private static QqqSessionClient Session(Uri origin) => new(origin,
|
||||
new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false });
|
||||
|
||||
|
||||
|
||||
/// <summary>Require TLS to reject the private CA before scoped trust is installed.</summary>
|
||||
private static async Task AssertUntrustedAsync(Uri origin, CancellationToken token)
|
||||
{
|
||||
using var transport = new FixtureHttp(origin);
|
||||
try { await transport.SendAsync("GET", "/-/health/live/", null, token); }
|
||||
try
|
||||
{
|
||||
await transport.SendAsync("GET", "/-/health/live/", null, token);
|
||||
}
|
||||
catch(HttpRequestException exception)
|
||||
{
|
||||
for(Exception? cause = exception; cause is not null; cause = cause.InnerException)
|
||||
{
|
||||
if(cause is AuthenticationException) { return; }
|
||||
if(cause is AuthenticationException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("The negative TLS control failed for a reason other than certificate authentication.");
|
||||
throw new InvalidOperationException("The negative TLS control failed for a reason other than certificate authentication.", exception);
|
||||
}
|
||||
throw new InvalidOperationException("The test unexpectedly trusted its private CA before scoped installation.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Exercise real discovery, authorization, origin enforcement, logout, renewal, and revocation.</summary>
|
||||
private static async Task QualifyAsync(JsonObject input, CancellationToken token)
|
||||
{
|
||||
var origin = new Uri(ProviderSetup.Text(input, "qqqOrigin"));
|
||||
var providerOrigin = new Uri(ProviderSetup.Text(input, "providerOrigin"));
|
||||
var callback = new Uri(ProviderSetup.Text(input, "callback"));
|
||||
_stage = "readiness";
|
||||
s_stage = "readiness";
|
||||
await WaitForQqqAsync(origin, token);
|
||||
using var provider = new ProviderSetup(input);
|
||||
_stage = "provider-readiness";
|
||||
s_stage = "provider-readiness";
|
||||
await provider.WaitReadyAsync(token);
|
||||
_stage = "provider-configuration";
|
||||
s_stage = "provider-configuration";
|
||||
await provider.ConfigureAsync(token);
|
||||
using var receiver = new CallbackReceiver(callback);
|
||||
using var first = Session(origin);
|
||||
_stage = "typed-native-discovery";
|
||||
using QqqSessionClient first = Session(origin);
|
||||
s_stage = "typed-native-discovery";
|
||||
await NativeAuthDiscovery.DiscoverAsync(first, token);
|
||||
QqqAuthenticationMetadata metadata = await first.GetAuthenticationMetadataAsync(token);
|
||||
if(metadata.Values?.ClientId != ProviderSetup.Text(input, "clientId"))
|
||||
{ throw new InvalidOperationException("Native metadata did not retain the configured client identifier."); }
|
||||
Checks.Add("typed-native-grant");
|
||||
|
||||
_stage = "real-provider-login";
|
||||
if(!string.Equals(metadata.Values?.ClientId, ProviderSetup.Text(input, "clientId"), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Native metadata did not retain the configured client identifier.");
|
||||
}
|
||||
s_checks.Add("typed-native-grant");
|
||||
s_stage = "real-provider-login";
|
||||
await LoginAsync(first, receiver, input, providerOrigin, callback, authorized: true, token);
|
||||
_stage = "authorized-native-identity";
|
||||
s_stage = "authorized-native-identity";
|
||||
QqqSessionBinding identity = await first.GetSessionIdentityAsync(token);
|
||||
if(string.IsNullOrWhiteSpace(identity.PrincipalId) || identity.TenantId != "printing-native-dev" || identity.DeviceId is not null)
|
||||
{ throw new InvalidOperationException("The native subject did not receive the intended server-derived identity."); }
|
||||
Checks.Add("real-provider-login");
|
||||
_stage = "wrong-origin-cookie";
|
||||
if(string.IsNullOrWhiteSpace(identity.PrincipalId) || !string.Equals(identity.TenantId, "printing-native-dev", StringComparison.Ordinal) || identity.DeviceId is not null)
|
||||
{
|
||||
throw new InvalidOperationException("The native subject did not receive the intended server-derived identity.");
|
||||
}
|
||||
s_checks.Add("real-provider-login");
|
||||
s_stage = "wrong-origin-cookie";
|
||||
await AssertWrongOriginAsync(origin, first.ExportCredential(), token);
|
||||
await first.GetSessionIdentityAsync(token);
|
||||
Checks.Add("wrong-origin-cookie-denied");
|
||||
|
||||
_stage = "permission-denied-user";
|
||||
using(var denied = Session(origin))
|
||||
s_checks.Add("wrong-origin-cookie-denied");
|
||||
s_stage = "permission-denied-user";
|
||||
using(QqqSessionClient denied = Session(origin))
|
||||
{
|
||||
await LoginAsync(denied, receiver, input, providerOrigin, callback, authorized: false, token);
|
||||
await ExpectFailureAsync(() => denied.GetSessionIdentityAsync(token), QqqSessionException.FailureKind.PermissionDenied);
|
||||
await denied.SignOutAsync(token);
|
||||
}
|
||||
Checks.Add("permission-denied-user");
|
||||
s_checks.Add("permission-denied-user");
|
||||
await AssertLogoutReplayAsync(first, origin, token);
|
||||
await QualifyRenewalAsync(input, provider, receiver, origin, providerOrigin, callback, token);
|
||||
}
|
||||
|
||||
_stage = "logout-replay";
|
||||
|
||||
|
||||
/// <summary>Verify that signing out invalidates the exact previously exported native cookie.</summary>
|
||||
private static async Task AssertLogoutReplayAsync(QqqSessionClient first, Uri origin, CancellationToken token)
|
||||
{
|
||||
s_stage = "logout-replay";
|
||||
QqqSessionCredential retired = first.ExportCredential();
|
||||
await first.SignOutAsync(token);
|
||||
using(var replay = Session(origin))
|
||||
{ await ExpectFailureAsync(() => replay.RestoreSessionAsync(retired, token), QqqSessionException.FailureKind.AuthenticationRequired); }
|
||||
Checks.Add("logout-replay-denied");
|
||||
using(QqqSessionClient replay = Session(origin))
|
||||
{
|
||||
await ExpectFailureAsync(() => replay.RestoreSessionAsync(retired, token), QqqSessionException.FailureKind.AuthenticationRequired);
|
||||
}
|
||||
s_checks.Add("logout-replay-denied");
|
||||
}
|
||||
|
||||
_stage = "fresh-login-for-renewal";
|
||||
using var renewable = Session(origin);
|
||||
|
||||
|
||||
/// <summary>Cross real provider expiry and revocation while preserving the authenticated subject.</summary>
|
||||
private static async Task QualifyRenewalAsync(JsonObject input, ProviderSetup provider, CallbackReceiver receiver,
|
||||
Uri origin, Uri providerOrigin, Uri callback, CancellationToken token)
|
||||
{
|
||||
s_stage = "fresh-login-for-renewal";
|
||||
using QqqSessionClient renewable = Session(origin);
|
||||
await LoginAsync(renewable, receiver, input, providerOrigin, callback, authorized: true, token);
|
||||
QqqSessionBinding before = await renewable.GetSessionIdentityAsync(token);
|
||||
HashSet<string> previousGrants = await provider.AccessGrantIdsAsync(token);
|
||||
_stage = "server-refresh-after-expiry";
|
||||
s_stage = "server-refresh-after-expiry";
|
||||
await Task.Delay(TimeSpan.FromSeconds(22), token);
|
||||
await renewable.ResumeSessionAsync(token);
|
||||
QqqSessionBinding after = await renewable.GetSessionIdentityAsync(token);
|
||||
HashSet<string> refreshedGrants = await provider.AccessGrantIdsAsync(token);
|
||||
if(before != after || !refreshedGrants.Except(previousGrants, StringComparer.Ordinal).Any())
|
||||
{ throw new InvalidOperationException("Renewal did not preserve the identity and obtain a new actual provider grant."); }
|
||||
Checks.Add("server-refresh-after-expiry");
|
||||
|
||||
_stage = "provider-revocation-after-expiry";
|
||||
{
|
||||
throw new InvalidOperationException("Renewal did not preserve the identity and obtain a new actual provider grant.");
|
||||
}
|
||||
s_checks.Add("server-refresh-after-expiry");
|
||||
s_stage = "provider-revocation-after-expiry";
|
||||
await provider.RevokeRefreshGrantsAsync(token);
|
||||
await Task.Delay(TimeSpan.FromSeconds(22), token);
|
||||
await ExpectFailureAsync(() => renewable.ResumeSessionAsync(token), QqqSessionException.FailureKind.AuthenticationRequired);
|
||||
Checks.Add("provider-revocation-denied");
|
||||
_stage = "complete";
|
||||
s_checks.Add("provider-revocation-denied");
|
||||
s_stage = "complete";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Use the real PKCE attempt and provider callback without synthesizing identity.</summary>
|
||||
private static async Task LoginAsync(QqqSessionClient client, CallbackReceiver receiver, JsonObject input,
|
||||
Uri providerOrigin, Uri callback, bool authorized, CancellationToken token)
|
||||
{
|
||||
string role = authorized ? "authorized" : "denied";
|
||||
_stage = role + "-begin-pkce";
|
||||
s_stage = role + "-begin-pkce";
|
||||
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(callback, token);
|
||||
_stage = role + "-generated-authorization-request";
|
||||
Dictionary<string, string> parameters = attempt.AuthorizationUri.Query.TrimStart('?').Split('&')
|
||||
s_stage = role + "-generated-authorization-request";
|
||||
var parameters = attempt.AuthorizationUri.Query.TrimStart('?').Split('&')
|
||||
.Select(entry => entry.Split('=', 2)).ToDictionary(pair => Uri.UnescapeDataString(pair[0]), pair => Uri.UnescapeDataString(pair[1]), StringComparer.Ordinal);
|
||||
_authorizationRequestMatches = attempt.AuthorizationUri.AbsolutePath == "/application/o/authorize/"
|
||||
&& parameters.GetValueOrDefault("client_id") == ProviderSetup.Text(input, "clientId")
|
||||
&& parameters.GetValueOrDefault("redirect_uri") == callback.AbsoluteUri
|
||||
&& parameters.GetValueOrDefault("response_type") == "code"
|
||||
&& parameters.GetValueOrDefault("code_challenge_method") == "S256"
|
||||
s_authorizationRequestMatches = string.Equals(attempt.AuthorizationUri.AbsolutePath, "/application/o/authorize/", StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.GetValueOrDefault("client_id"), ProviderSetup.Text(input, "clientId"), StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.GetValueOrDefault("redirect_uri"), callback.AbsoluteUri, StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.GetValueOrDefault("response_type"), "code", StringComparison.Ordinal)
|
||||
&& string.Equals(parameters.GetValueOrDefault("code_challenge_method"), "S256", StringComparison.Ordinal)
|
||||
&& parameters.GetValueOrDefault("state")?.Length == 43;
|
||||
if(_authorizationRequestMatches != true)
|
||||
{ throw new InvalidOperationException("The generated authorization request differs from the exact provider registration."); }
|
||||
_stage = role + "-provider-flow";
|
||||
if(s_authorizationRequestMatches != true)
|
||||
{
|
||||
throw new InvalidOperationException("The generated authorization request differs from the exact provider registration.");
|
||||
}
|
||||
s_stage = role + "-provider-flow";
|
||||
await ProviderFlow.AuthenticateAsync(attempt.AuthorizationUri, providerOrigin, callback,
|
||||
ProviderSetup.Text(input, role + "Username"), ProviderSetup.Text(input, role + "Password"), token);
|
||||
_stage = role + "-callback";
|
||||
s_stage = role + "-callback";
|
||||
Uri actualCallback = await receiver.ReadAsync(token);
|
||||
_stage = role + "-exchange";
|
||||
s_stage = role + "-exchange";
|
||||
await client.CompleteBrowserLoginAsync(attempt, actualCallback, token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Require the precise native authentication or permission failure.</summary>
|
||||
private static async Task ExpectFailureAsync(Func<Task> operation, QqqSessionException.FailureKind expected)
|
||||
{
|
||||
try { await operation(); }
|
||||
try
|
||||
{
|
||||
await operation();
|
||||
}
|
||||
catch(QqqSessionException exception) when(exception.Failure == expected) { return; }
|
||||
throw new InvalidOperationException("The expected native authentication or permission denial did not occur.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Verify the authenticated native boundary rejects a foreign Origin.</summary>
|
||||
private static async Task AssertWrongOriginAsync(Uri origin, QqqSessionCredential credential, CancellationToken token)
|
||||
{
|
||||
using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false })
|
||||
{ Timeout = TimeSpan.FromSeconds(15), MaxResponseContentBufferSize = 65536 };
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
MaxResponseContentBufferSize = 65536
|
||||
};
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(origin, "/qqq/v1/qbit/printing/session"));
|
||||
request.Headers.Add("X-QQQ-Client", QqqSessionClient.ClientChannel);
|
||||
request.Headers.Add("Cookie", "sessionUUID=" + credential.SessionToken);
|
||||
request.Headers.Add("Origin", "https://wrong-origin.invalid");
|
||||
using HttpResponseMessage response = await http.SendAsync(request, token);
|
||||
if(response.StatusCode != HttpStatusCode.Forbidden)
|
||||
{ throw new InvalidOperationException("The fixture did not reject a wrong-Origin cookie request."); }
|
||||
{
|
||||
throw new InvalidOperationException("The fixture did not reject a wrong-Origin cookie request.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Bound startup readiness reads to the owned QQQ host.</summary>
|
||||
private static async Task WaitForQqqAsync(Uri origin, CancellationToken token)
|
||||
{
|
||||
using var http = new FixtureHttp(origin);
|
||||
@@ -217,25 +316,45 @@ internal static class Program
|
||||
try
|
||||
{
|
||||
FixtureHttp.Reply response = await http.SendAsync("GET", "/qqq/v1/version", null, token);
|
||||
if(response.Status == HttpStatusCode.OK && response.Json is not null) { return; }
|
||||
if(response.Status == HttpStatusCode.OK && response.Json is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(HttpRequestException)
|
||||
{
|
||||
// The owned host is still starting; the bounded attempt loop retries and then fails explicitly.
|
||||
}
|
||||
catch(HttpRequestException) { }
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), token);
|
||||
}
|
||||
throw new InvalidOperationException("The owned native host did not become ready.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Write only allowlisted classifications and results to the private report.</summary>
|
||||
private static Task WriteResultAsync(JsonObject? input, string status, string? failureType, string? sessionFailure)
|
||||
=> File.WriteAllTextAsync(ResultPath, JsonSerializer.Serialize(new
|
||||
{
|
||||
status, scope = "DEVELOPMENT_ONLY_UNRELEASED", coreSource = CoreSource, clientSource = ClientSource,
|
||||
project = input?["project"]?.ToString(), observedUtc = DateTimeOffset.UtcNow, stage = _stage, failureType,
|
||||
sessionFailure, lastRequestKind = FixtureHttp.LastRequestKind, lastHttpStatus = FixtureHttp.LastStatus,
|
||||
status,
|
||||
scope = "DEVELOPMENT_ONLY_UNRELEASED",
|
||||
coreSource = CoreSource,
|
||||
clientSource = ClientSource,
|
||||
project = input?["project"]?.ToString(),
|
||||
observedUtc = DateTimeOffset.UtcNow,
|
||||
stage = s_stage,
|
||||
failureType,
|
||||
sessionFailure,
|
||||
lastRequestKind = FixtureHttp.LastRequestKind,
|
||||
lastHttpStatus = FixtureHttp.LastStatus,
|
||||
lastProviderComponent = ProviderFlow.LastComponent,
|
||||
authorizationRequestMatches = _authorizationRequestMatches,
|
||||
authorizationRequestMatches = s_authorizationRequestMatches,
|
||||
requestClassifications = FixtureHttp.Trace,
|
||||
providerLookupKind = ProviderSetup.LastLookupKind, providerLookupCount = ProviderSetup.LastLookupCount,
|
||||
providerLookupKind = ProviderSetup.LastLookupKind,
|
||||
providerLookupCount = ProviderSetup.LastLookupCount,
|
||||
providerReadinessCounts = ProviderSetup.ReadinessCounts,
|
||||
checks = Checks, installedUiAcceptance = false, providerRefreshTokensInClient = false,
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
checks = s_checks,
|
||||
installedUiAcceptance = false,
|
||||
providerRefreshTokensInClient = false,
|
||||
}, s_reportOptions));
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Uses Authentik's real synthetic-user login stages; never fabricates a session or code.</summary>
|
||||
internal static class ProviderFlow
|
||||
{
|
||||
internal static string? LastComponent { get; private set; }
|
||||
internal static string? LastComponent
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Follow bounded provider navigation while confining redirects to fixture origins.</summary>
|
||||
internal static async Task AuthenticateAsync(Uri authorization, Uri origin, Uri callback, string username, string password, CancellationToken token)
|
||||
{
|
||||
using var browser = new FixtureHttp(origin, cookies: true);
|
||||
@@ -17,15 +28,23 @@ internal static class ProviderFlow
|
||||
Uri next = authorization;
|
||||
for(int hop = 0; hop < 24; hop++)
|
||||
{
|
||||
if(next.GetLeftPart(UriPartial.Authority) == callback.GetLeftPart(UriPartial.Authority))
|
||||
if(string.Equals(next.GetLeftPart(UriPartial.Authority), callback.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
|
||||
{
|
||||
if(next.AbsolutePath != callback.AbsolutePath) { throw new InvalidOperationException("Unexpected callback path."); }
|
||||
if(!string.Equals(next.AbsolutePath, callback.AbsolutePath, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected callback path.");
|
||||
}
|
||||
FixtureHttp.Reply result = await callbackHttp.SendAsync("GET", next, null, token);
|
||||
if(result.Status != HttpStatusCode.OK) { throw new InvalidOperationException("The HTTPS callback receiver refused the provider redirect."); }
|
||||
if(result.Status != HttpStatusCode.OK)
|
||||
{
|
||||
throw new InvalidOperationException("The HTTPS callback receiver refused the provider redirect.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(next.GetLeftPart(UriPartial.Authority) != origin.GetLeftPart(UriPartial.Authority))
|
||||
{ throw new InvalidOperationException("A provider redirect left the fixture."); }
|
||||
if(!string.Equals(next.GetLeftPart(UriPartial.Authority), origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("A provider redirect left the fixture.");
|
||||
}
|
||||
FixtureHttp.Reply page = await browser.SendAsync("GET", next, null, token);
|
||||
if(page.Status is HttpStatusCode.Redirect or HttpStatusCode.SeeOther or HttpStatusCode.TemporaryRedirect)
|
||||
{
|
||||
@@ -33,61 +52,82 @@ internal static class ProviderFlow
|
||||
continue;
|
||||
}
|
||||
if(page.Status != HttpStatusCode.OK || !next.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal))
|
||||
{ throw new InvalidOperationException("Unexpected provider login navigation."); }
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected provider login navigation.");
|
||||
}
|
||||
string slug = next.AbsolutePath["/if/flow/".Length..].Trim('/');
|
||||
if(slug.Length == 0 || slug.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '-'))
|
||||
{ throw new InvalidOperationException("Unexpected provider flow identifier."); }
|
||||
var executor = new Uri(origin, "/api/v3/flows/executor/" + slug + "/?query=" + Uri.EscapeDataString(next.Query.TrimStart('?')));
|
||||
JsonObject challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("GET", executor, null, token), token);
|
||||
bool redirected = false;
|
||||
for(int stage = 0; stage < 10; stage++)
|
||||
{
|
||||
string component = ProviderSetup.Text(challenge, "component");
|
||||
LastComponent = component switch
|
||||
{
|
||||
"ak-stage-identification" => "identification",
|
||||
"ak-stage-password" => "password",
|
||||
"ak-stage-consent" => "consent",
|
||||
"xak-flow-redirect" => "redirect",
|
||||
"ak-stage-access-denied" => "access-denied",
|
||||
"ak-stage-flow-error" => "flow-error",
|
||||
_ => "unsupported",
|
||||
};
|
||||
if(component == "xak-flow-redirect")
|
||||
{
|
||||
next = new Uri(origin, ProviderSetup.Text(challenge, "to"));
|
||||
redirected = true;
|
||||
break;
|
||||
}
|
||||
JsonObject response = component switch
|
||||
{
|
||||
"ak-stage-identification" => new JsonObject { ["component"] = component, ["uid_field"] = username },
|
||||
"ak-stage-password" => new JsonObject { ["component"] = component, ["password"] = password },
|
||||
"ak-stage-consent" => new JsonObject { ["component"] = component, ["consent"] = true },
|
||||
_ => throw new InvalidOperationException("The provider requested an unsupported test login stage."),
|
||||
};
|
||||
challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("POST", executor, response, token), token);
|
||||
if(challenge["response_errors"] is JsonObject errors && errors.Count != 0)
|
||||
{ throw new InvalidOperationException("The real provider rejected the synthetic login response."); }
|
||||
throw new InvalidOperationException("Unexpected provider flow identifier.");
|
||||
}
|
||||
if(!redirected) { throw new InvalidOperationException("Provider stage bound exceeded."); }
|
||||
var executor = new Uri(origin, "/api/v3/flows/executor/" + slug + "/?query=" + Uri.EscapeDataString(next.Query.TrimStart('?')));
|
||||
next = await CompleteStagesAsync(browser, executor, origin, username, password, token);
|
||||
}
|
||||
throw new InvalidOperationException("Provider redirect bound exceeded.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Answer at most ten real provider challenges and return its declared redirect.</summary>
|
||||
private static async Task<Uri> CompleteStagesAsync(FixtureHttp browser, Uri executor, Uri origin, string username, string password, CancellationToken token)
|
||||
{
|
||||
JsonObject challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("GET", executor, null, token), token);
|
||||
for(int stage = 0; stage < 10; stage++)
|
||||
{
|
||||
string component = ProviderSetup.Text(challenge, "component");
|
||||
LastComponent = component switch
|
||||
{
|
||||
"ak-stage-identification" => "identification",
|
||||
"ak-stage-password" => "password",
|
||||
"ak-stage-consent" => "consent",
|
||||
"xak-flow-redirect" => "redirect",
|
||||
"ak-stage-access-denied" => "access-denied",
|
||||
"ak-stage-flow-error" => "flow-error",
|
||||
_ => "unsupported",
|
||||
};
|
||||
if(string.Equals(component, "xak-flow-redirect", StringComparison.Ordinal))
|
||||
{
|
||||
return new Uri(origin, ProviderSetup.Text(challenge, "to"));
|
||||
}
|
||||
JsonObject response = component switch
|
||||
{
|
||||
"ak-stage-identification" => new JsonObject { ["component"] = component, ["uid_field"] = username },
|
||||
"ak-stage-password" => new JsonObject { ["component"] = component, ["password"] = password },
|
||||
"ak-stage-consent" => new JsonObject { ["component"] = component, ["consent"] = true },
|
||||
_ => throw new InvalidOperationException("The provider requested an unsupported test login stage."),
|
||||
};
|
||||
challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("POST", executor, response, token), token);
|
||||
if(challenge["response_errors"] is JsonObject errors && errors.Count != 0)
|
||||
{
|
||||
throw new InvalidOperationException("The real provider rejected the synthetic login response.");
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("Provider stage bound exceeded.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Follow only same-executor POST/redirect/GET responses and require a JSON challenge.</summary>
|
||||
private static async Task<JsonObject> ChallengeAsync(FixtureHttp browser, Uri executor, FixtureHttp.Reply response, CancellationToken token)
|
||||
{
|
||||
// Authentik uses POST/redirect/GET when a real stage accepts its answer.
|
||||
// Follow only that same HTTPS executor; other navigation uses typed flow redirects.
|
||||
for(int redirect = 0; redirect < 8; redirect++)
|
||||
{
|
||||
if(response.Status == HttpStatusCode.OK) { return FixtureHttp.RequireJson(response, HttpStatusCode.OK); }
|
||||
if(response.Status == HttpStatusCode.OK)
|
||||
{
|
||||
return FixtureHttp.RequireJson(response, HttpStatusCode.OK);
|
||||
}
|
||||
if(response.Status is not (HttpStatusCode.Redirect or HttpStatusCode.SeeOther) || response.Location is null)
|
||||
{ throw new InvalidOperationException("Unexpected provider stage response."); }
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected provider stage response.");
|
||||
}
|
||||
var location = new Uri(executor, response.Location);
|
||||
if(location.GetLeftPart(UriPartial.Authority) != executor.GetLeftPart(UriPartial.Authority)
|
||||
|| location.AbsolutePath != executor.AbsolutePath)
|
||||
{ throw new InvalidOperationException("A stage redirect left its exact HTTPS executor."); }
|
||||
if(!string.Equals(location.GetLeftPart(UriPartial.Authority), executor.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal)
|
||||
|| !string.Equals(location.AbsolutePath, executor.AbsolutePath, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("A stage redirect left its exact HTTPS executor.");
|
||||
}
|
||||
response = await browser.SendAsync("GET", location, null, token);
|
||||
}
|
||||
throw new InvalidOperationException("Provider stage redirect bound exceeded.");
|
||||
|
||||
@@ -1,43 +1,93 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Printing.NativeAuth;
|
||||
|
||||
|
||||
|
||||
/// <summary>Configures only the fresh project's synthetic provider and subjects.</summary>
|
||||
internal sealed class ProviderSetup : IDisposable
|
||||
{
|
||||
internal static string? LastLookupKind { get; private set; }
|
||||
internal static int? LastLookupCount { get; private set; }
|
||||
internal static string? LastLookupKind
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
internal static int? LastLookupCount
|
||||
{
|
||||
get;
|
||||
|
||||
|
||||
|
||||
private set;
|
||||
}
|
||||
|
||||
|
||||
|
||||
internal static Dictionary<string, int> ReadinessCounts { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
private readonly JsonObject _input;
|
||||
|
||||
|
||||
|
||||
private readonly FixtureHttp _admin;
|
||||
|
||||
|
||||
|
||||
private string _provider = "";
|
||||
|
||||
|
||||
|
||||
private string _authorizedUser = "";
|
||||
|
||||
|
||||
|
||||
/// <summary>Use the private bootstrap token only with the configured provider origin.</summary>
|
||||
internal ProviderSetup(JsonObject input)
|
||||
{
|
||||
_input = input;
|
||||
_admin = new FixtureHttp(new Uri(Text(input, "providerOrigin")), adminToken: Text(input, "adminToken"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Read required synthetic input without disclosing its value in failures.</summary>
|
||||
internal static string Text(JsonObject document, string key) => document[key]?.ToString() ?? throw new InvalidOperationException("Required fixture field is absent.");
|
||||
|
||||
|
||||
|
||||
/// <summary>Require one provider metadata object and retain only its lookup classification.</summary>
|
||||
private async Task<JsonObject> ExactAsync(string path, CancellationToken token)
|
||||
{
|
||||
JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", path, null, token), HttpStatusCode.OK);
|
||||
JsonArray records = page["results"]!.AsArray();
|
||||
LastLookupKind = LookupKind(path);
|
||||
LastLookupCount = records.Count;
|
||||
if(records.Count != 1) { throw new InvalidOperationException("Required provider object is absent or ambiguous."); }
|
||||
if(records.Count != 1)
|
||||
{
|
||||
throw new InvalidOperationException("Required provider object is absent or ambiguous.");
|
||||
}
|
||||
return records[0]!.AsObject();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Create one synthetic object and require its successful JSON response.</summary>
|
||||
private async Task<JsonObject> CreateAsync(string path, JsonObject body, CancellationToken token)
|
||||
=> FixtureHttp.RequireJson(await _admin.SendAsync("POST", path, body, token), HttpStatusCode.Created);
|
||||
|
||||
|
||||
|
||||
/// <summary>Wait for complete default flows and scopes before configuring the provider.</summary>
|
||||
internal async Task WaitReadyAsync(CancellationToken token)
|
||||
{
|
||||
string[] paths = ["/api/v3/flows/instances/?slug=default-authentication-flow",
|
||||
@@ -58,20 +108,34 @@ internal sealed class ProviderSetup : IDisposable
|
||||
JsonArray? records = reply.Status == HttpStatusCode.OK ? reply.Json?["results"] as JsonArray : null;
|
||||
string kind = LookupKind(path);
|
||||
ReadinessCounts[kind] = records?.Count ?? -1;
|
||||
if(records?.Count > 1) { throw new InvalidOperationException("An owned provider bootstrap dependency is ambiguous."); }
|
||||
if(records?.Count > 1)
|
||||
{
|
||||
throw new InvalidOperationException("An owned provider bootstrap dependency is ambiguous.");
|
||||
}
|
||||
bool present = records is { Count: 1 };
|
||||
if(kind == "authentication-flow")
|
||||
{ present &= records is { Count: 1 } && records[0]?["stages"] is JsonArray { Count: >= 4 }; }
|
||||
if(string.Equals(kind, "authentication-flow", StringComparison.Ordinal))
|
||||
{
|
||||
present &= records is { Count: 1 } && records[0]?["stages"] is JsonArray { Count: >= 4 };
|
||||
}
|
||||
ready &= present;
|
||||
}
|
||||
if(ready) { return; }
|
||||
if(ready)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(HttpRequestException)
|
||||
{
|
||||
// The owned provider is still starting; the bounded attempt loop retries and then fails explicitly.
|
||||
}
|
||||
catch(HttpRequestException) { }
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), token);
|
||||
}
|
||||
throw new InvalidOperationException("The owned provider bootstrap did not become ready.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Classify prerequisite lookups using fixed diagnostic names.</summary>
|
||||
private static string LookupKind(string path) => path switch
|
||||
{
|
||||
"/api/v3/flows/instances/?slug=default-authentication-flow" => "authentication-flow",
|
||||
@@ -84,6 +148,9 @@ internal sealed class ProviderSetup : IDisposable
|
||||
_ => "other",
|
||||
};
|
||||
|
||||
|
||||
|
||||
/// <summary>Register the synthetic OAuth provider and authorized and denied users.</summary>
|
||||
internal async Task ConfigureAsync(CancellationToken token)
|
||||
{
|
||||
JsonObject authentication = await ExactAsync("/api/v3/flows/instances/?slug=default-authentication-flow", token);
|
||||
@@ -125,60 +192,97 @@ internal sealed class ProviderSetup : IDisposable
|
||||
["include_claims_in_id_token"] = true,
|
||||
}, token);
|
||||
_provider = Text(provider, "pk");
|
||||
if(Text(provider, "client_id") != Text(_input, "clientId")
|
||||
|| Text(provider, "authentication_flow") != Text(authentication, "pk"))
|
||||
{ throw new InvalidOperationException("The real provider did not retain the configured client identifier."); }
|
||||
if(!string.Equals(Text(provider, "client_id"), Text(_input, "clientId"), StringComparison.Ordinal) || !string.Equals(Text(provider, "authentication_flow"), Text(authentication, "pk"), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The real provider did not retain the configured client identifier.");
|
||||
}
|
||||
JsonObject application = await CreateAsync("/api/v3/core/applications/", new JsonObject
|
||||
{
|
||||
["name"] = Text(_input, "project"), ["slug"] = Text(_input, "project"),
|
||||
["provider"] = provider["pk"]!.DeepClone(), ["policy_engine_mode"] = "all",
|
||||
["name"] = Text(_input, "project"),
|
||||
["slug"] = Text(_input, "project"),
|
||||
["provider"] = provider["pk"]!.DeepClone(),
|
||||
["policy_engine_mode"] = "all",
|
||||
}, token);
|
||||
if(Text(application, "provider") != _provider)
|
||||
{ throw new InvalidOperationException("The real provider application binding differs from the configured provider."); }
|
||||
if(!string.Equals(Text(application, "provider"), _provider, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The real provider application binding differs from the configured provider.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Create a synthetic user with configured groups and its private password.</summary>
|
||||
private async Task<JsonObject> CreateUserAsync(string usernameKey, string passwordKey, JsonArray groups, CancellationToken token)
|
||||
{
|
||||
JsonObject user = await CreateAsync("/api/v3/core/users/", new JsonObject
|
||||
{
|
||||
["username"] = Text(_input, usernameKey), ["name"] = "Synthetic native qualification subject",
|
||||
["email"] = Text(_input, usernameKey) + "@example.invalid", ["is_active"] = true,
|
||||
["type"] = "internal", ["groups"] = groups,
|
||||
["username"] = Text(_input, usernameKey),
|
||||
["name"] = "Synthetic native qualification subject",
|
||||
["email"] = Text(_input, usernameKey) + "@example.invalid",
|
||||
["is_active"] = true,
|
||||
["type"] = "internal",
|
||||
["groups"] = groups,
|
||||
}, token);
|
||||
FixtureHttp.Reply response = await _admin.SendAsync("POST", "/api/v3/core/users/" + Text(user, "pk") + "/set_password/",
|
||||
new JsonObject { ["password"] = Text(_input, passwordKey) }, token);
|
||||
if(response.Status != HttpStatusCode.NoContent) { throw new InvalidOperationException("Synthetic account password configuration failed."); }
|
||||
if(response.Status != HttpStatusCode.NoContent)
|
||||
{
|
||||
throw new InvalidOperationException("Synthetic account password configuration failed.");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Read bounded provider token metadata scoped to this synthetic application.</summary>
|
||||
private async Task<JsonArray> GrantsAsync(string kind, CancellationToken token)
|
||||
{
|
||||
JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", "/api/v3/oauth2/" + kind + "/?provider=" + _provider + "&user=" + _authorizedUser, null, token), HttpStatusCode.OK);
|
||||
JsonArray records = page["results"]!.AsArray();
|
||||
if(records.Count is 0 or > 20) { throw new InvalidOperationException("The synthetic provider grant set is missing or unexpectedly large."); }
|
||||
if(records.Count is 0 or > 20)
|
||||
{
|
||||
throw new InvalidOperationException("The synthetic provider grant set is missing or unexpectedly large.");
|
||||
}
|
||||
foreach(JsonNode? node in records)
|
||||
{
|
||||
JsonObject record = node!.AsObject();
|
||||
string provider = record["provider"] is JsonObject nested ? Text(nested, "pk") : Text(record, "provider");
|
||||
if(provider != _provider || Text(record["user"]!.AsObject(), "pk") != _authorizedUser)
|
||||
{ throw new InvalidOperationException("The provider grant escaped the synthetic subject scope."); }
|
||||
if(!string.Equals(provider, _provider, StringComparison.Ordinal) || !string.Equals(Text(record["user"]!.AsObject(), "pk"), _authorizedUser, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The provider grant escaped the synthetic subject scope.");
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Snapshot actual access-grant identities for the renewal assertion.</summary>
|
||||
internal async Task<HashSet<string>> AccessGrantIdsAsync(CancellationToken token)
|
||||
=> (await GrantsAsync("access_tokens", token)).Select(node => Text(node!.AsObject(), "pk")).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>Revoke only refresh grants belonging to the owned synthetic provider.</summary>
|
||||
internal async Task RevokeRefreshGrantsAsync(CancellationToken token)
|
||||
{
|
||||
foreach(JsonNode? node in await GrantsAsync("refresh_tokens", token))
|
||||
{
|
||||
string id = Text(node!.AsObject(), "pk");
|
||||
if(!Guid.TryParse(id, out _) && !long.TryParse(id, out _)) { throw new InvalidOperationException("Unexpected provider grant identifier."); }
|
||||
if(!Guid.TryParse(id, out _) && !long.TryParse(id, System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected provider grant identifier.");
|
||||
}
|
||||
FixtureHttp.Reply response = await _admin.SendAsync("DELETE", "/api/v3/oauth2/refresh_tokens/" + id + "/", null, token);
|
||||
if(response.Status != HttpStatusCode.NoContent) { throw new InvalidOperationException("Synthetic provider revocation failed."); }
|
||||
if(response.Status != HttpStatusCode.NoContent)
|
||||
{
|
||||
throw new InvalidOperationException("Synthetic provider revocation failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Dispose the private provider administration transport.</summary>
|
||||
public void Dispose() => _admin.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.296, )",
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,10 @@
|
||||
*/
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.sql.Connection;
|
||||
import java.util.List;
|
||||
@@ -54,22 +56,28 @@ import io.javalin.http.ForbiddenResponse;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Opt-in container fixture for the pinned upstream native-session candidate.
|
||||
* Standard OAuth2 code exchange, session storage and encrypted renewal are used
|
||||
* unchanged. No installed pairing, password authentication double or printer.
|
||||
*/
|
||||
|
||||
/***************************************************************************
|
||||
** Opt-in container fixture for the pinned upstream native-session candidate.
|
||||
** Standard OAuth2 code exchange, session storage and encrypted renewal are used
|
||||
** unchanged. No installed pairing, password authentication double or printer.
|
||||
***************************************************************************/
|
||||
public final class NativeAuthHost extends AbstractQQQApplication
|
||||
{
|
||||
private static final String TENANT = "printing-native-dev";
|
||||
private final String backendName = "printing-native-auth-" + UUID.randomUUID();
|
||||
private final String jdbcUrl = "jdbc:h2:file:/data/printing-native-auth";
|
||||
private final String databasePassword = required("NATIVE_QQQ_DATABASE_PASSWORD");
|
||||
private final URI origin = httpsOrigin(required("NATIVE_QQQ_ORIGIN"));
|
||||
private final int port = Integer.parseInt(required("NATIVE_QQQ_PORT"));
|
||||
private final DriverDataSource source = new DriverDataSource(jdbcUrl, "sa", databasePassword);
|
||||
private final PrintingService service = new PrintingService(source, TENANT);
|
||||
private static final String TENANT = "printing-native-dev";
|
||||
private final String backendName = "printing-native-auth-" + UUID.randomUUID();
|
||||
private final String jdbcUrl = "jdbc:h2:file:/data/printing-native-auth";
|
||||
private final String databasePassword = required("NATIVE_QQQ_DATABASE_PASSWORD");
|
||||
private final URI origin = httpsOrigin(required("NATIVE_QQQ_ORIGIN"));
|
||||
private final int port = Integer.parseInt(required("NATIVE_QQQ_PORT"));
|
||||
private final DriverDataSource source = new DriverDataSource(jdbcUrl, "sa", databasePassword);
|
||||
private final PrintingService service = new PrintingService(source, TENANT);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Initialize only the private fixture database and native session tables.
|
||||
***************************************************************************/
|
||||
private NativeAuthHost() throws Exception
|
||||
{
|
||||
if(port < 1024 || port > 65535)
|
||||
@@ -85,6 +93,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Launch the experimental provider host inside its owned container network.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
NativeAuthHost host = new NativeAuthHost();
|
||||
@@ -92,6 +105,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
.withServerCustomizer(host::configure));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Define the private fixture backend, native OAuth2 provider and printing QBit.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
@@ -105,8 +123,8 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
instance.addTable(new RedirectStateMetaDataProducer(backendName).produce(instance));
|
||||
instance.addTable(new UserSessionRefreshTokenMetaDataProducer(backendName).produce(instance));
|
||||
|
||||
String issuer = required("NATIVE_AUTH_ISSUER");
|
||||
URI issuerUri = URI.create(issuer);
|
||||
String issuer = required("NATIVE_AUTH_ISSUER");
|
||||
URI issuerUri = URI.create(issuer);
|
||||
if(!"https".equals(issuerUri.getScheme()) || issuerUri.getHost() == null
|
||||
|| issuerUri.getRawUserInfo() != null || issuerUri.getRawQuery() != null || issuerUri.getRawFragment() != null || !issuer.endsWith("/"))
|
||||
{
|
||||
@@ -142,6 +160,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Register printing routes behind the fixture TLS proxy.
|
||||
***************************************************************************/
|
||||
private void configure(QApplicationJavalinServer server)
|
||||
{
|
||||
// Only the owned TLS proxy is published; this HTTP listener remains inside
|
||||
@@ -156,6 +179,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Require the configured HTTPS proxy and same-origin cookie mutations.
|
||||
***************************************************************************/
|
||||
private void guardOrigin(Context context)
|
||||
{
|
||||
if(!origin.getRawAuthority().equalsIgnoreCase(context.header("Host"))
|
||||
@@ -181,9 +209,16 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
}
|
||||
}
|
||||
|
||||
/** Only maps claims after the unchanged standard OAuth2 module authenticates. */
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only maps claims after the unchanged standard OAuth2 module authenticates.
|
||||
***************************************************************************/
|
||||
public static final class PrintingPermissions implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
** Grant the printing admin permission only for the fixture group claim.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
@@ -201,6 +236,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Validate the exact root HTTPS origin used by the private TLS fixture.
|
||||
***************************************************************************/
|
||||
private static URI httpsOrigin(String value)
|
||||
{
|
||||
URI parsed = URI.create(value);
|
||||
@@ -212,6 +252,11 @@ public final class NativeAuthHost extends AbstractQQQApplication
|
||||
return parsed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read a required synthetic fixture setting without logging its value.
|
||||
***************************************************************************/
|
||||
private static String required(String name)
|
||||
{
|
||||
String value = System.getenv(name);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Java compiler safety
|
||||
|
||||
The repository compiler runs Error Prone 2.51.0 and NullAway 0.14.3 for
|
||||
`com.kingsrook.qbits.printing`, including test sources. NullAway findings are
|
||||
errors; compiler warnings fail through `-Werror` and Maven `failOnWarning`.
|
||||
This implements K22-CODE-JV-06 and the compiler portion of K22-CODE-02. It is
|
||||
not a claim that every unrelated coding-standard requirement is complete.
|
||||
|
||||
The configuration retains Java 21, compiler plugin 3.14.1, `proc:none`, the
|
||||
existing parent and the frozen QQQ dependency tuple. The explicit compiler
|
||||
plugin path loads the analyzers even with annotation processing disabled.
|
||||
JSpecify 1.0.0 is declared directly for nullable contracts; it was already in
|
||||
the runtime closure. NullAway's contract annotations are provided-only.
|
||||
Neither analyzer implementation is an application runtime dependency.
|
||||
|
||||
Run the usual quality gate:
|
||||
|
||||
```sh
|
||||
./mvnw -B -ntp -Pui-demo verify
|
||||
```
|
||||
|
||||
For a compiler-configuration change, also run the existing bounded guard locally:
|
||||
|
||||
```sh
|
||||
python3 .github/scripts/check-java-compiler.py
|
||||
```
|
||||
|
||||
The guard copies the actual POM compiler block into fresh, isolated modules
|
||||
under `target/compiler-guard`. Both main and test sources have a positive
|
||||
probe and negative probes for NullAway, Error Prone's `DeadException`, and a
|
||||
deprecated call rejected by warnings-as-errors. A random Maven failure does
|
||||
not satisfy a negative probe: the expected diagnostic must be present.
|
||||
Logs and a result manifest are written under `target/compiler-guard`. The former GitHub workflow ran this probe explicitly; primary Gitea verification enforces the POM compiler checks during the Maven build.
|
||||
No artifact is installed or published. Optional `--maven`, `--settings`,
|
||||
`--local-repository` and `--offline` arguments support an isolated local cache.
|
||||
|
||||
Null annotations describe existing optional inputs and transitions; they do
|
||||
not convert absent data into required configuration. Existing lifecycle
|
||||
checks provide checked initializer contracts. HELLO-dependent work receives
|
||||
the validated payload explicitly. Scheduled retention captures its service
|
||||
under the existing missing-configuration error contract. A missing realtime
|
||||
QQQ instance now fails descriptively at revalidation, without moving host
|
||||
registration timing or caching user authority.
|
||||
|
||||
Narrow member suppressions retain required framework override return types,
|
||||
exact socket identity fencing and the framework's ordered presentation enum.
|
||||
Two private accessors document the storage/page lifecycle invariants that
|
||||
cannot be inferred from separate nullable fields. They preserve the existing
|
||||
use-after-finish or misconfigured-storage failure rather than inventing valid
|
||||
null operations. Suppressions are not package-wide, and tests remain analyzed.
|
||||
|
||||
The migration separates redundant return-expression parentheses from null
|
||||
contracts and compiler configuration for review. Public JVM descriptors and
|
||||
the resolved runtime JAR set are verified against the unchanged baseline.
|
||||
The ordinary test suite intentionally skips the separately opt-in 10,000-job
|
||||
qualification unless `printing.qualification.load=true` is supplied.
|
||||
+4
-4
@@ -10,7 +10,7 @@
|
||||
| `PrintingUiDemo` | Isolated in-memory synthetic data, mock administrator, no device routes | Native UI demonstration only |
|
||||
| `PrintingLocalHost` | Durable H2 shared by native QQQ and device routes, real password/session authentication | Authenticated local application |
|
||||
|
||||
The local host adds its own `printing_admin_user` and `printing_admin_session` tables. It preserves existing printing tables, device credential hashes, receipt history and tenant identity. Physical table/column names are mapped to the existing H2 case convention; it does not rename an uppercase database or change `DATABASE_TO_UPPER`. Every host instance uses a distinct internal backend name to avoid cross-instance provider-cache collisions in QQQ RC20.
|
||||
The local host adds its own `printing_admin_user` and `printing_admin_session` tables. It preserves existing printing tables, device credential hashes, receipt history and tenant identity. Physical table/column names are mapped to the existing H2 case convention; it does not rename an uppercase database or change `DATABASE_TO_UPPER`. Every host instance uses a distinct internal backend name to avoid cross-instance provider-cache collisions observed during RC.20 development.
|
||||
|
||||
## Required configuration
|
||||
|
||||
@@ -36,7 +36,7 @@ $dependencies = (Get-Content target/local-host-classpath.txt -Raw).Trim()
|
||||
& "$env:JAVA_HOME/bin/java.exe" -cp "target/classes;$dependencies" com.kingsrook.qbits.printing.PrintingLocalHost
|
||||
```
|
||||
|
||||
Set the required environment values securely before launch. Maven contract tests require the documented sibling protocol fixture checkout or `printing.protocol.fixtures` override. Browse to `http://localhost:18081/` (or the configured port) and use the native username/password screen. The existing Windows connector continues using the same server origin and device token; it does not receive the browser password or management token.
|
||||
Set the required environment values securely before launch. Maven contract tests require the documented sibling protocol fixture checkout or `printing.protocol.fixtures` override. Browse to `http://localhost:18081/` (or the configured port) and use the native username/password screen. Configure that origin in the Windows connector Connection settings and sign in through the discovered QQQ authentication flow. Preserve an existing pairing when upgrading; the native path requires the current user session as well as device proof. The connector does not receive the management token.
|
||||
|
||||
This command runs the host in the foreground. `QApplicationLauncher` owns graceful HTTP/scheduler shutdown. It does not install an operating-system service or auto-start registration; the deploying operator must provide supervision, protected credential loading, logs and backups for unattended use.
|
||||
|
||||
@@ -60,7 +60,7 @@ Existing facility IDs are retained. The standalone enrollment form offers the na
|
||||
|
||||
QQQ supplies password verification, failed-sign-in throttling, HttpOnly sessions, session expiry and logout. The local authorization subclass calls native verification first and maps only the configured account to printing permissions. Other valid accounts cannot gain those permissions. Native QQQ permission and tenant checks still apply to every table/process.
|
||||
|
||||
The host rejects non-loopback Host headers, cross-origin Origin headers and cross-site browser requests. Cookie-authenticated unsafe requests require an exact same-origin Origin header, including logout and native process actions. Explicit bearer/device API calls without ambient cookies remain usable from the connector. Use one loopback hostname consistently in the browser; `localhost` and `127.0.0.1` are different browser origins. These controls do not turn a local HTTP host into an Internet-facing service.
|
||||
The host rejects non-loopback Host headers, cross-origin Origin headers and cross-site browser requests. Cookie-authenticated unsafe requests require an exact same-origin Origin header, including logout and native process actions. Explicit API calls without ambient cookies still require their normal route credentials; native bound devices use QQQ session authentication plus device proof. Use one loopback hostname consistently in the browser; `localhost` and `127.0.0.1` are different browser origins. These controls do not turn a local HTTP host into an Internet-facing service.
|
||||
|
||||
## Retention, storage and remaining deployment work
|
||||
|
||||
@@ -68,4 +68,4 @@ The native Quartz scheduler runs the existing bounded cleanup process every five
|
||||
|
||||
This local host deliberately leaves the S3 binding unconfigured. Native settings report legacy database storage. New and existing documents therefore remain in the database; merely running this host does not move documents to S3. A deployed application must supply its existing dedicated `printing_*` S3 table/backend and scheduler configuration, with bucket/IAM/tenant separation described in [storage and retention](STORAGE-AND-RETENTION.md). The host must retain historical storage bindings when changing configuration.
|
||||
|
||||
Remote HTTPS deployment, real S3/IAM, production OAuth roles, PostgreSQL and operating-system supervision remain separate qualification work. Record Files source-version/actor integration remains pending its concrete public contract; standalone printing delivery storage must not own or delete canonical attachments.
|
||||
Remote HTTPS deployment, real S3/IAM, production OAuth roles, PostgreSQL and operating-system supervision remain separate qualification work. The optional [retained-source facade](RETAINED-PRINT-FACADE.md) requires a host-supplied source authority; standalone printing delivery storage must not own or delete canonical attachments.
|
||||
+12
-8
@@ -24,7 +24,7 @@ Normal progression is QUEUED → CLAIMED → RECEIVED → SUBMITTING → SPOOLED
|
||||
|
||||
Use a stable business idempotency key for retries of the same request. A changed target/document/options with the same key is a conflict. A deliberately new print requires a new key. The initial implementation does not automatically reclaim RECEIVED/SUBMITTING jobs or resend ambiguous output; inspect the queue and physical result before deciding to print again.
|
||||
|
||||
The pending 0.1.4 implementation stores new documents through the host's configured dedicated QQQ S3 storage table; existing inline database content remains readable. Administration defaults to 365 days after server-observed successful completion, with bounded scheduled cleanup when a host scheduler is configured. Active, failed, uncertain and unknown-completion legacy jobs remain protected. See [storage and retention](STORAGE-AND-RETENTION.md) for bucket separation, recovery and versioning details. No live storage migration or cleanup is implied by a source upgrade.
|
||||
Version 1.0.0 stores new documents through the host's configured dedicated QQQ S3 storage table; existing inline database content remains readable. Administration defaults to 365 days after server-observed successful completion, with bounded scheduled cleanup when a host scheduler is configured. Active, failed, uncertain and unknown-completion legacy jobs remain protected. See [storage and retention](STORAGE-AND-RETENTION.md) for bucket separation, recovery and versioning details. No live storage migration or cleanup is implied by a source upgrade.
|
||||
|
||||
## Admission capacity and inventory history
|
||||
|
||||
@@ -49,16 +49,17 @@ Inventory snapshots remain limited to 200 entries. Rotating IDs cannot bypass cu
|
||||
## Device controls and durable action receipts
|
||||
|
||||
The native client API is the registered `printing` extension, semantic version
|
||||
`1.0.0`, under `/qqq/v1/qbit/printing`. Discover it through QQQ's version endpoint;
|
||||
`2.4.0`, under `/qqq/v1/qbit/printing`. Discover it through QQQ's version endpoint;
|
||||
its generated OpenAPI fragment is `src/main/resources/openapi/qqq-v1/printing.yaml`.
|
||||
The contract test checks this fragment against the registered endpoint specs and
|
||||
the initial semantic compatibility baseline. Core QQQ session authentication and
|
||||
credential scopes run before printing operations; the application does not issue
|
||||
another kind of user login token.
|
||||
|
||||
Every native operation requires the authenticated QQQ principal, `printing_admin`,
|
||||
the configured tenant security key, and applicable native table, process and row
|
||||
access. Device operations additionally require `X-QQQ-Device-Id` and
|
||||
Native administration and device operations require the authenticated QQQ principal,
|
||||
`printing_admin`, the configured tenant security key, and applicable native table,
|
||||
process and row access. The separate [operator catalog and submission API](OPERATOR-CATALOG.md)
|
||||
also permits `printing_operator`, with its narrower destination/process checks. Device operations additionally require `X-QQQ-Device-Id` and
|
||||
`X-QQQ-Device-Token` as secondary proof. Enrollment at `/devices/enroll` validates
|
||||
the user's facility access before consuming the enrollment code and atomically
|
||||
binds the device to the core user's stable identity. A legacy unbound device can
|
||||
@@ -94,7 +95,7 @@ reach that context; printing does not assert that an upstream cached session is
|
||||
fresh. Cookie-authenticated unsafe calls must also satisfy the host's Origin/CSRF
|
||||
policy. The local host requires its canonical same-origin authority.
|
||||
|
||||
In the qualified QQQ 4.1.0-RC.20 runtime, OAuth2 `sessionStoreEnabled` defaults
|
||||
In the recorded RC.20 authentication qualification, OAuth2 `sessionStoreEnabled` defaults
|
||||
to `false`. Ordinary requests and session resume rerun the configured
|
||||
`QAuthenticationModuleCustomizerInterface` hooks; the host can load current
|
||||
permissions, tenant keys, and account status from its authoritative directory.
|
||||
@@ -151,7 +152,8 @@ seven days; an unchanged old request still expires after its receipt is removed.
|
||||
|
||||
Remote inventory refresh and owned spool cancellation require advertised agent
|
||||
capabilities, expire after 15 minutes, and have a limit of 100 pending commands
|
||||
per device. Polls deliver at most 20 commands. The retained action history is
|
||||
per device. Control delivery is bounded to 20 commands per batch over the real-time
|
||||
channel; there is no timed job/control polling fallback. The retained action history is
|
||||
limited to 1,000 records per device, with one additional reserved revocation;
|
||||
eligible old terminal records are reclaimed before admission. Capacity returns
|
||||
429 explicitly. History pages contain at most 200 records and use the preceding
|
||||
@@ -180,4 +182,6 @@ cancellation request, not that paper was withdrawn or a physical print was undon
|
||||
|
||||
## Release qualification
|
||||
|
||||
Prior released checks include user-confirmed laser PDF and Zebra ZPL/PDF output with correct machine driver settings. Audit source checks cover H2, native processes, host HTTP registration, synthetic storage and small real connection pools. Follow-up qualification includes live S3/IAM, PostgreSQL, production host authentication, upgrade/migration paths, multiple QQQ nodes, prolonged disconnects and a broader printer/driver set. Current prerelease artifacts must not be described as certifying those scenarios.
|
||||
The stable 1.0.0 library and its matching release/registry bytes are published; see [release evidence](RELEASES.md). Local checks cover H2 persistence, native QQQ operations, real HTTP/WebSocket transport, S3-compatible storage and bounded job histories. User-confirmed physical output includes laser PDF and Zebra ZPL/PDF.
|
||||
|
||||
A deployment still supplies and validates its own database, S3/IAM, authentication mapping, supervision and printer drivers. PostgreSQL and multiple-node operation are not qualified by the local H2/MinIO fixture. The default real-time bus is in-process; consult [host integration](REALTIME.md) before scaling hosts. These are deployment boundaries, not pending 1.0 publication tasks.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Operator catalog and queue media
|
||||
|
||||
This source adds native printing extension 2.3.0 (2.2.0 is the rendering extension and carries no media negotiation). Publication and Windows media
|
||||
agent qualification are pending. Existing administrator and device routes keep
|
||||
QQQ Printing 1.0.0 includes native printing extension 2.3.0, paired with Windows
|
||||
connector 1.0.1. Extension 2.2.0 introduced rendering; 2.3.0 adds this operator/media API. 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
|
||||
@@ -55,8 +55,11 @@ 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.
|
||||
ordinary existing queue behavior. An explicit job paper size instead uses both
|
||||
`paperWidthMm` and `paperHeightMm` (positive, at most 2000 mm); it cannot be
|
||||
combined with `mediaProfile`. The matching connector applies that job paper
|
||||
without changing the machine-wide queue defaults. Select media matching the
|
||||
loaded stock and calibrate the physical printer after changing label sizes.
|
||||
|
||||
Generated/retained source authorization belongs to the configured source owner.
|
||||
The retained facade shares this operator permission and destination boundary; it
|
||||
|
||||
+29
-7
@@ -1,9 +1,10 @@
|
||||
# Real-time printing host integration
|
||||
|
||||
Release candidate [0.3.0-rc.1](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.3.0-rc.1)
|
||||
implements printing extension **2.0.0** (wire envelope v1). Upgrade the server
|
||||
before the matching Windows connector; a real-time connector rejects a server that advertises only extension
|
||||
1.x. There is no timed job/control polling fallback.
|
||||
QQQ Printing 1.1.0 (the released library is [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0))
|
||||
implements native printing extension **2.4.0** (wire envelope v1), paired with Windows
|
||||
connector **1.2.0**; connector 1.0.1 remains compatible. Upgrade the server before the connector; a real-time connector
|
||||
rejects a server that advertises only extension 1.x. There is no timed job/control
|
||||
polling fallback.
|
||||
|
||||
## Connection and authorization
|
||||
|
||||
@@ -97,13 +98,34 @@ notifications to one another.
|
||||
The existing `/printing/v1` routes remain explicit legacy diagnostic compatibility.
|
||||
They are not a real-time fallback and cannot authorize a native bound device.
|
||||
The full message and settings contract is in
|
||||
[the shared protocol](https://github.com/QRun-IO/qqq-print-protocol).
|
||||
[the shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol).
|
||||
|
||||
## Printing API major upgrade
|
||||
|
||||
The extension is **2.0.0** because native HTTP claims now require a synchronized
|
||||
Extension **2.0.0** introduced the major-version change because native HTTP claims require a synchronized
|
||||
WebSocket. A published 1.0 HTTP-only connector no longer meets that precondition;
|
||||
this is a breaking behavior change even though request bodies remain wire v1.
|
||||
The QQQ framework API `/qqq/v1`, extension path `/qbit/printing` and WebSocket
|
||||
subprotocol `qqq-printing.v1` remain unchanged. Upgrade server and client together;
|
||||
an older client must report upgrade-required instead of using polling fallback.
|
||||
an incompatible older client must report upgrade-required instead of using polling fallback.
|
||||
Extension 2.3.0 retained that requirement and added operator/media support; 2.4.0
|
||||
adds printer approval over the socket (below). The product/library version is
|
||||
independently 1.1.0.
|
||||
|
||||
## Printer approval (extension 2.4.0)
|
||||
|
||||
A HELLO may carry up to five capabilities; the fifth, `printer-approval-v1`, is
|
||||
also accepted and stored by the HTTP control poll. Only on a connection that
|
||||
advertised it, the server sends a `PRINTERS` frame
|
||||
(`{ "printers": [ { "printerId", "enabled" } ] }`) with the complete approval
|
||||
state of every printer in the device's latest accepted inventory: after the ACK
|
||||
of each accepted INVENTORY, and whenever a printer's `enabled` changes for that
|
||||
device through any path (the admin route, the native approve/disable/set-approval
|
||||
processes or application code calling `enablePrinter`). Every approval path
|
||||
publishes the existing commit wakeup; the connection's serialized writer compares
|
||||
the current state with the last frame it sent and resends only on a change, so an
|
||||
unrelated job wakeup does not repeat it. An empty inventory yields
|
||||
`{ "printers": [] }`. A printer removed in QQQ (`removed_at` set) is left out and
|
||||
the frame is pushed again; it reappears, not approved, once the device shares the
|
||||
queue again. The client does not acknowledge the frame; it carries no job
|
||||
or document data. Connections without the capability never receive it.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Installation and release operations
|
||||
|
||||
The stable server library is [QQQ Printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), paired with protocol 1.0.0 and Windows connector 1.0.1. It requires Java 21 and the QQQ 4.1.0-RC.21 dependency tuple. Its native printing extension is 2.3.0; the WebSocket subprotocol remains `qqq-printing.v1`.
|
||||
|
||||
Gitea is primary for source, pull requests, CI, packages and server releases. This forge requires sign-in even for download links on public repositories. An HTML sign-in page is not an artifact. Use your existing approved forge identity; never commit its token or put it in a command line.
|
||||
|
||||
## Use the Maven registry
|
||||
|
||||
Configure the consuming application's existing Maven repository setup with repository ID `qrun-io` and URL `https://git.kof22.com/api/packages/QRun-IO/maven`. Enable releases and disable snapshots. Match that ID to a `<server>` entry in your private Maven `settings.xml`, using your existing managed username/token or encrypted Maven credentials. For example, environment references keep literal credentials out of the configuration:
|
||||
|
||||
```xml
|
||||
<servers>
|
||||
<server>
|
||||
<id>qrun-io</id>
|
||||
<username>${env.GITEA_USERNAME}</username>
|
||||
<password>${env.GITEA_TOKEN}</password>
|
||||
</server>
|
||||
</servers>
|
||||
```
|
||||
|
||||
Merge this entry into the existing settings; preserve other servers, mirrors and profiles. Supply the environment through your approved credential loader. The credential needs package read access. Add `com.kingsrook.qbits:qbit-printing:1.0.0` as shown in the [README](../README.md#add-to-a-qqq-application). QQQ's RC.21 dependencies resolve from Maven Central; the printing 1.0.0 library resolves from Gitea.
|
||||
|
||||
## Download and verify release files
|
||||
|
||||
Sign in to Gitea and download all three assets from the stable release: `qbit-printing-1.0.0.jar`, `qbit-printing-1.0.0.pom` and `SHA256SUMS`. In their download directory:
|
||||
|
||||
```powershell
|
||||
Get-Content ./SHA256SUMS | ForEach-Object {
|
||||
$hash, $name = $_ -split ' ', 2
|
||||
if((Get-FileHash -LiteralPath $name -Algorithm SHA256).Hash -ine $hash) {
|
||||
throw "Checksum mismatch: $name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
On Linux, use `sha256sum -c SHA256SUMS`; on macOS, use `shasum -a 256 -c SHA256SUMS`. Install the verified JAR/POM locally with the README's `install:install-file` command if your application does not consume the registry directly. The library needs its normal Maven runtime dependencies; copying the JAR alone does not deploy a complete host.
|
||||
|
||||
## Maintainer release path
|
||||
|
||||
1. Update the POM version and active documentation on a branch in Gitea. Keep the immutable protocol fixture ref in `.gitea/workflows/qrun-release.yaml` aligned with the reviewed contract.
|
||||
2. Open a normal pull request and satisfy required CI checks. The caller delegates to the protected `qrun-release-v1` channel; shared CI changes belong to its owner, not an application-specific bypass.
|
||||
3. Merge normally and wait for the successful main run. Its `maven-reactor` artifact contains tested JAR/POM bytes, a source commit, manifest and checksums. The trusted publisher verifies that evidence before publishing immutable Maven packages. A passing build alone does not prove registry publication.
|
||||
4. Verify the exact version's registry JAR/POM against the captured bytes. Create `v<POM version>` at the qualified source without moving an existing tag. Create a draft Gitea release, attach the same JAR/POM and a flat `SHA256SUMS`, read them back with the approved forge identity, then publish. Reconcile identical existing artifacts; never replace different bytes under a released version.
|
||||
5. Record the source, successful run, artifact, release and verified hashes. Publication is separate from installing a host or accepting physical printer output.
|
||||
|
||||
For 1.0.0, source `2906c2bd5049924abd2114c8ef51a0ac9aac325e`, main run `3594`, reactor artifact `2741` and release `1356` were verified. Release assets and registry files matched byte-for-byte on authenticated readback. The JAR SHA-256 is `7ecb00e1f49272d6e885dc5f515bd4832aa52dd84e0613d9b6751f9e235a0961`; the POM SHA-256 is `8be7c30208a7fbb875dcebedb0ba6baf0e172aa0e7cf478a3d2863d1316ddf5f`.
|
||||
|
||||
GitHub remains a shadow/archive. GitHub Actions for this server repository and the protocol repository were retired on 2026-11-04. Their checked-in workflows and older release evidence are historical. The Windows repository retains its separate signing/update-feed workflow. Do not restart shadow workflows to publish a server release, claim GitHub attestations for 1.0.0, or imply the QBit was published to Maven Central.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Retained print acceptance (additive candidate)
|
||||
# Retained print acceptance
|
||||
|
||||
This optional Java integration preserves the existing native generation print
|
||||
Included in QQQ Printing 1.0.0, 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`
|
||||
@@ -108,7 +108,7 @@ 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.
|
||||
preservation when rollback fails. The library is released; [release evidence](RELEASES.md) identifies its source
|
||||
and artifacts. Configuring and qualifying a cross-QBit source authority remains
|
||||
the integrating host's responsibility. Merely upgrading the library does not
|
||||
enable an adapter, migrate stored source bindings or grant source access.
|
||||
+46
-1
@@ -1,4 +1,49 @@
|
||||
# Session state — 2026-10-31
|
||||
# Session state — 2026-11-05
|
||||
|
||||
## In progress — 2026-11-05: printer approval over realtime (1.1.0 / extension 2.4.0)
|
||||
|
||||
Branch `feat/printer-approval-20261105` adds the `printer-approval-v1` capability and the `PRINTERS` frame
|
||||
(see [CHANGELOG](../CHANGELOG.md) 1.1.0 and [real-time integration](REALTIME.md)). Product version is bumped to
|
||||
1.1.0 in the POM; nothing is tagged, released or published, and the released state below is unchanged.
|
||||
|
||||
## Current — 1.0.0 released
|
||||
|
||||
[QQQ Printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) is published on primary Gitea and its Maven registry. Source `2906c2bd5049924abd2114c8ef51a0ac9aac325e` passed main run `3594`; reactor artifact `2741`, release `1356` and both registry files were verified against the captured bytes by authenticated readback. Gitea requires sign-in. See [release operations and hashes](RELEASES.md).
|
||||
|
||||
Windows connector 1.0.1 and protocol 1.0.0 are published. The server uses QQQ 4.1.0-RC.21, native printing 2.3.0, wire v1 and client-initiated WebSocket control with separate HTTPS documents. The release preparation's existing `-Pui-demo verify` reported 193 tests, zero failures/errors, one opt-in workload skip and zero Checkstyle violations; the normal hosted main checks subsequently passed.
|
||||
|
||||
GitHub is a shadow/archive; server and protocol Actions were retired on 2026-11-04. Windows signing/update-feed workflows remain separate. Current setup, operator, storage and release documentation now describe this released state. This documentation closeout changes no application code or workflows and performs no new hardware/provider qualification. No server 1.0 publication task remains open.
|
||||
|
||||
## Historical checkpoints
|
||||
|
||||
The entries below are dated development evidence, including superseded pending tasks and older versions. They are not the current release status; use the section above and [current tasks](TODO.md).
|
||||
|
||||
## Standards and small-label release cohort — 2026-11-01
|
||||
|
||||
The strict standards tree (commit `43253f6`) combines the 2.3.0 operator/media/retained source with the
|
||||
Error Prone 2.51.0 / NullAway / `-Werror` compiler gate, null contracts, strict headers, the development
|
||||
fixture style gates and the official-npm Pyright script gates. Observed locally on that tree: main and test
|
||||
compile clean and the full `-Pui-demo verify` passed (189 reported, 0 failures, 0 errors, one intentional
|
||||
opt-in 10,000-job skip, 0 Checkstyle violations). This qualifies that integrated source only.
|
||||
|
||||
The 4 x 1 and 4 x 2 inch label work (compact layouts, explicit PDF job paper in millimetres that never mixes
|
||||
with a media profile, and the 4 x 6 path unchanged) is being merged onto it in the release-integration tree.
|
||||
Its four conflicts (`PrintingProcesses`, `PrintingService`, `PrintingTestDocuments`, `TestPrintProfile`) were
|
||||
resolved by keeping all label behavior and tests and layering the strict contracts: `jobOptions` is
|
||||
`@Nullable` (null means the queue default applies), `validPaperMillimetres` accepts a missing value, redundant
|
||||
return wrappers are removed, and no suppression or rule change was added. Submission, authority, retained
|
||||
binding, replay and queue code paths were not edited.
|
||||
|
||||
**Local gate passed (observed):** on the merged cohort, main and test compile clean under the strict compiler and
|
||||
the full `-Pui-demo verify` passed with 193 tests, 0 failures, 0 errors, one intentional opt-in 10,000-job skip and
|
||||
0 Checkstyle violations. CI and the README pin the final protocol commit
|
||||
`42c0d249f81395f00408ec0f92638dcbbf5deb03` (merged Gitea protocol PR 2); earlier pins elsewhere in this file are
|
||||
historical. **Still pending, not observed:** hosted Gitea CI, publication, installation and any physical print. The
|
||||
installed printer default is untouched; no printing was performed.
|
||||
|
||||
**Release version:** the project version is now the immutable prerelease `0.4.0-rc.1` (it was `0.4.0-SNAPSHOT`, which the
|
||||
tag workflow cannot release). The release tag must be exactly `v0.4.0-rc.1`. The QQQ `4.1.0-RC.20` dependency tuple, the
|
||||
parent and all code are unchanged. Maven Central publication and deployment remain separate operations.
|
||||
|
||||
## Echo operator/media and retained-source candidate — 2026-11-01
|
||||
|
||||
|
||||
@@ -13,5 +13,5 @@ Only leading language comments count. Copyright text in a string, Python docstri
|
||||
|
||||
JSON/schema/fixture/lock data, explicit generated output directories, vendor legal assets and upstream Maven wrappers are excluded with reasons in the policy. Solution/activation/test-runner metadata and `.editorconfig` remain outside this completed pass; their applicable root LICENSE/NOTICE remains authoritative. No broad claim about third-party licensing follows from this check. New owned file types or provenance must be reviewed before changing the allowlist; do not add blanket exclusions to make a failure disappear.
|
||||
|
||||
The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. CI invokes them in existing jobs using the runner's available Python, with no new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged.
|
||||
This repository accepts Kingsrook, LLC / Apache-2.0 for its reviewed source/build/config families. The existing CI verify job runs the guard.
|
||||
The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. The former GitHub jobs invoked them using the runner's available Python; the scripts remain usable locally without a new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged.
|
||||
This repository accepts Kingsrook, LLC / Apache-2.0 for its reviewed source/build/config families. Run these checks locally when modifying owned notices; the primary shared CI channel owns its current check selection. The old `.github` workflow invocation is retained as history.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Print document storage and retention
|
||||
|
||||
Status: audit implementation pending final verification and release. QQQ 4.1+ hosts provide the existing storage and scheduler metadata; this module does not create buckets or copy credentials into printing settings.
|
||||
Included in the released QQQ Printing 1.0.0 library. QQQ 4.1+ hosts provide the existing storage and scheduler metadata; this module does not create buckets or copy credentials into printing settings.
|
||||
|
||||
## Deployment boundary
|
||||
|
||||
@@ -10,15 +10,15 @@ Keep access keys, role credentials and endpoint configuration in the host's esta
|
||||
|
||||
The code validates the selected S3 metadata, detects conflicting bucket bindings within the QQQ instance and uses tenant-specific object namespaces. Each job retains its storage table, destination identity and exact document reference. Retrieval and cleanup validate those values and fail closed if metadata is retargeted or the reference belongs to another tenant/job. Cleanup deletes exact eligible objects; it never lists and wipes a prefix. Tenant prefixes are an application isolation control, not a substitute for separately scoped IAM roles or separate customer buckets.
|
||||
|
||||
Application metadata cannot prove that a bucket is unused by another application or that deployed IAM/bucket policies are correct. Deployment qualification must check those external controls, public-access blocking, transport/encryption policy and the intended customer/environment boundary. No live bucket has been created or changed by the audit.
|
||||
Application metadata cannot prove that a bucket is unused by another application or that deployed IAM/bucket policies are correct. Deployment qualification must check those external controls, public-access blocking, transport/encryption policy and the intended customer/environment boundary. The QBit does not create or change bucket policies.
|
||||
|
||||
## S3 key layout and migration
|
||||
|
||||
QQQ filesystem 4.1.0-RC.20 builds S3 keys with the host filesystem separator. With empty base paths, Windows writes two leading backslashes before the logical `printing/<tenant-hash>/<job>.ext` reference; Linux writes the canonical reference. Same-platform object operations have been qualified in isolated S3-compatible storage. The logical reference alone is therefore insufficient for IAM prefix rules or migration between platforms.
|
||||
The reviewed `qqq-s3-filesystem-v1` layout uses the host filesystem separator; the original platform comparison was recorded against QQQ 4.1.0-RC.20. With empty base paths, Windows writes two leading backslashes before the logical `printing/<tenant-hash>/<job>.ext` reference; Linux writes the canonical reference. Same-platform object operations have been qualified in isolated S3-compatible storage. The logical reference alone is therefore insufficient for IAM prefix rules or migration between platforms.
|
||||
|
||||
Printing fingerprints the reviewed `qqq-s3-filesystem-v1` layout and the actual `File.separatorChar` code point (47 or 92), alongside the endpoint, bucket, region, path-style setting and backend/table base paths. Ordered JSON encoding preserves the identity component boundaries, including valid multiline base paths. A different layout rejects historical reads, new retries and cleanup with a destination-mismatch error before accessing those objects. New retries whose source binding is already incompatible fail before reserving another upload; the document loader also rechecks the binding when reading. An already-created retry still returns its idempotent receipt. Cleanup preserves content availability, retained-byte accounting and orphan reservations on mismatch.
|
||||
|
||||
This S3 feature is unreleased. Old experimental fingerprints without the layout marker are deliberately rejected, even on their original platform. Startup never rewrites those bindings or moves/deletes their objects. Preserve the original database and objects until an explicitly reviewed migration or disposal; do not replace hashes merely to bypass the guard. Inline database documents remain readable and can be explicitly retried into the configured destination without any automatic storage migration.
|
||||
The released storage implementation requires this layout marker. Old experimental fingerprints without the layout marker are deliberately rejected, even on their original platform. Startup never rewrites those bindings or moves/deletes their objects. Preserve the original database and objects until an explicitly reviewed migration or disposal; do not replace hashes merely to bypass the guard. Inline database documents remain readable and can be explicitly retried into the configured destination without any automatic storage migration.
|
||||
|
||||
This guard does not make QQQ keys portable. The upstream owner must provide portable key construction and legacy-key handling. Adopting that change, including a change on the same operating system, requires an explicit layout-marker review and a migration plan for historical bindings and exact objects. The marker is a reviewed compatibility contract, not automatic detection of dependency implementation changes. Cross-platform migration and production IAM remain separate qualification work.
|
||||
|
||||
@@ -26,9 +26,9 @@ This guard does not make QQQ keys portable. The upstream owner must provide port
|
||||
|
||||
Record Files owns original versions and attachments; document generation owns rendering. Printing owns a separate delivery payload and queue receipts. Never bind printing cleanup to a Record Files bucket or pass an original's storage key as a printing-owned reference. Printing expiration must not remove a source attachment or weaken its retention policy.
|
||||
|
||||
The current independent integration seam accepts bytes and a stable business idempotency key. An authorized immutable-version reader and source/job correlation contract are being coordinated with Record Files; that API is not yet implemented or assumed here. Printing currently checks device/destination authorization at dispatch, not later revocation of the source attachment's permissions. A shared adapter must define that boundary before promising revocation propagation.
|
||||
Direct submission accepts bytes and a stable business idempotency key. The optional [retained-source facade](RETAINED-PRINT-FACADE.md) additionally binds an authorized immutable source, actor, destination and acceptance receipt. It is disabled until the host supplies its source authority; this repository does not configure a Record Files adapter automatically. Printing checks device/destination authorization at dispatch, not later revocation of the source attachment's permissions. The integrating owner must define that boundary before promising revocation propagation.
|
||||
|
||||
The existing `PrintEnqueueEffectTarget` accepts base64 document bytes in the host's durable outbox. That outbox may retain another delivery copy outside this module's S3 cleanup policy. Hosts must manage its retention separately; do not claim that configuring printing S3 moves existing outbox payloads or all upstream copies into the printing bucket. The future retained-version adapter should carry authorized opaque source IDs/hash/length/media type and actor/correlation, not bucket URLs or duplicated document bytes. Direct byte submission stores the printing-owned payload through the configured storage backend without adding this host-outbox copy.
|
||||
The existing `PrintEnqueueEffectTarget` accepts base64 document bytes in the host's durable outbox. That outbox may retain another delivery copy outside this module's S3 cleanup policy. Hosts must manage its retention separately; do not claim that configuring printing S3 moves existing outbox payloads or all upstream copies into the printing bucket. A host-provided retained-version adapter should carry authorized opaque source IDs/hash/length/media type and actor/correlation, not bucket URLs or duplicated document bytes. Direct byte submission stores the printing-owned payload through the configured storage backend without adding this host-outbox copy.
|
||||
|
||||
## Administration and scheduled cleanup
|
||||
|
||||
@@ -36,6 +36,8 @@ Printing administration exposes retention settings and a reviewed **Run cleanup*
|
||||
|
||||
When the host supplies a scheduler, maintenance runs every five minutes in bounded batches of at most 200. That permits a nominal 57,600 eligible documents per day before storage latency or failures; it is not measured production throughput. Changing a retention setting does not itself purge documents. Disabling cleanup preserves content. Without a configured scheduler there is no automatic job; the administration screen must show that condition.
|
||||
|
||||
The same run purges archived inventory. **Remove printers** and **Remove device** never delete rows; they set `removed_at` (shown as **Removed at**) so the rows leave the default views, selectors, catalog, claims and the realtime `PRINTERS` frame while job history keeps resolving to them. After the document step, each cleanup run hard-deletes, within the remaining batch allowance, removed printers that no `printing_job` references (neither by `printer_row_id` nor by device and queue identity) and then removed devices that have no jobs and no printer rows left; a removed device's control receipts and resource accounting go with it. Anything a job references is never deleted, so a removed printer or device stays, hidden, for as long as its jobs exist. The purge counts toward the run's reported total and is logged with the printer and device counts; disabling cleanup pauses it together with document removal. Cleanup never deletes an active (non-removed) printer or device, and never deletes a job.
|
||||
|
||||
Only eligible successful terminal jobs lose their document content. Active, failed or uncertain work remains protected. Job metadata, document hashes, receipts and idempotency records remain available for auditing and duplicate prevention. Legacy completion times are recovered only from durable successful events; unknown completion dates remain protected. Existing inline database documents remain readable; the initial storage integration does not silently migrate old bytes to S3. Reprinting a job whose document has been removed returns an explicit unavailable-document result.
|
||||
|
||||
## Failure and versioning behavior
|
||||
|
||||
+37
-3
@@ -1,9 +1,17 @@
|
||||
# Follow-up
|
||||
|
||||
## Script quality gates
|
||||
## Current — release closeout (2026-11-04)
|
||||
|
||||
- [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.
|
||||
- [x] Adopt published QQQ 4.1.0-RC.21 and complete the existing local verification.
|
||||
- [x] Pass normal primary CI, publish qbit-printing 1.0.0 and verify the release/registry bytes.
|
||||
- [x] Publish protocol 1.0.0 and Windows connector 1.0.1; keep their distinct API/product versions documented.
|
||||
- [x] Update active setup, operating, security and release documentation for Gitea and the released features.
|
||||
|
||||
No server 1.0 release task remains open. Deployment-specific database, IAM/provider policy and optional cross-QBit adapters remain the integrating application's responsibilities, described in the operating guides. The legacy optional Authentik diagnostic is documented as an RC.20-era fixture; updating it is not part of this release closeout.
|
||||
|
||||
## Historical checklists
|
||||
|
||||
Everything below records earlier development checkpoints. Unchecked historical items do not reopen completed or excluded release work.
|
||||
|
||||
## Echo integration candidate
|
||||
|
||||
@@ -24,6 +32,32 @@
|
||||
- [ ] Publish the reviewed additive contract and coordinate client/runtime adoption
|
||||
before owner-operated end-to-end physical acceptance.
|
||||
|
||||
## Script quality gates
|
||||
|
||||
- [x] Pin Ruff and strict Pyright (official npm package) with locked restores; 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.
|
||||
|
||||
## Integrated compiler, standards and fixture gates
|
||||
|
||||
- [x] Layer the reviewed Error Prone/NullAway compiler gate, null contracts and
|
||||
style corrections over the merged 2.3.0 source without relaxing any security check.
|
||||
- [x] Integrated source compiles clean under Error Prone 2.51.0/NullAway/-Werror for
|
||||
main and test sources (R27), and the full local verify passed: 189 reported, 0
|
||||
failures, 0 errors, one intentional 10k-workload opt-in skip, zero Checkstyle
|
||||
violations (R28). This qualifies the integrated source only; the earlier
|
||||
133-test/204-jar results describe the old baseline.
|
||||
- [ ] Observe the `development-clients` and `development-servers` jobs, the compiler
|
||||
guard and the script-quality gates pass on hosted CI.
|
||||
|
||||
## Small-label release cohort
|
||||
|
||||
- [x] Merge the 4 x 1 / 4 x 2 label profiles, compact PDF/ZPL layouts, explicit PDF job paper and their tests
|
||||
onto the strict standards tree, keeping the strict headers, null contracts and Error Prone fixes.
|
||||
- [x] Cohort gate passed locally on the merged tree: strict main/test compile and full `-Pui-demo verify`, 193 tests,
|
||||
0 failures, 0 errors, one intentional opt-in skip, 0 Checkstyle violations.
|
||||
- [x] CI and the README pin the final protocol commit `42c0d249f81395f00408ec0f92638dcbbf5deb03` (merged Gitea PR 2).
|
||||
- [ ] Observe hosted Gitea CI, then publish and install; physical confirmation of the short labels stays an owner step (4 x 6 stock is loaded).
|
||||
|
||||
## Current local development qualification
|
||||
|
||||
- [x] Reuse production discovery and the shared major-2 gate in the optional
|
||||
|
||||
@@ -18,7 +18,13 @@ Open **Printers** in QQQ. The operational columns show the printer name, named p
|
||||
|
||||
## Act on one printer or a selection
|
||||
|
||||
Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer.
|
||||
Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Remove printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer.
|
||||
|
||||
**Remove printers** archives the selection: each printer gets a **Removed at** time, loses approval and exposure, and disappears from the operator catalog, the connector's approval frame and the device/printer choices in the setup and test-print forms. Its record and job history stay. The Printers table has no server-side default filter, so add **Removed at is empty** to the Printers view you keep (the screenshots above predate this field) to hide archived rows; clear it to audit them. If the Windows companion shares the same queue again, the next inventory brings the printer back as exposed but not approved, so approve it again before it can print.
|
||||
|
||||
## Remove a device
|
||||
|
||||
Open a Print devices record or select rows and choose **Remove device**. The action requires the confirmation checkbox. It revokes the device's access permanently through the same control as **Revoke device access**, then marks the device and all of its printers removed. The device drops out of the Print devices view (add **Removed at is empty** there too), the setup and test-print device choices and every enrollment, claim and control path; its jobs remain in Print jobs and its control history keeps the REVOKE receipt. A removed device cannot be un-removed; enroll the computer again to create a new device. Retention cleanup eventually hard-deletes removed devices and printers that no job references.
|
||||
|
||||

|
||||
|
||||
@@ -38,4 +44,4 @@ The Windows companion shows recent jobs and a persistent status footer. **View s
|
||||
|
||||
## Preview acceptance and limits
|
||||
|
||||
The live preview was inspected with frontend 1.0.0-RC.37: the six operational printer columns, named device relationship, disabled generic write actions, and dashboard-to-filtered-table navigation were observed. Selecting the synthetic Zebra, opening Print a test label, reviewing and submitting completed with one printer processed. No physical printer was connected to this demo. Native process authorization and multi-printer execution are also covered by Java tests. A broad browser/accessibility run and final elevated companion interaction run remain separate acceptance tasks; see the README and the Windows repository's UX review.
|
||||
The live preview was inspected with frontend 1.0.0-RC.37: the six operational printer columns, named device relationship, disabled generic write actions, and dashboard-to-filtered-table navigation were observed. Selecting the synthetic Zebra, opening Print a test label, reviewing and submitting completed with one printer processed. No physical printer was connected to this demo. Native process authorization and multi-printer execution are also covered by Java tests. These are historical preview observations, not evidence for every browser size or deployed printer driver. Current release status is in the [README](../README.md) and [release guide](RELEASES.md).
|
||||
@@ -9,13 +9,13 @@
|
||||
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.0.0</version><relativePath/></parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-printing</artifactId>
|
||||
<version>0.4.0-SNAPSHOT</version>
|
||||
<version>1.1.0</version>
|
||||
<name>QQQ Printing</name>
|
||||
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url></license></licenses>
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<qqq.version>4.1.0-RC.20</qqq.version>
|
||||
<qqq.version>4.1.0-RC.21</qqq.version>
|
||||
<maven.compiler.proc>none</maven.compiler.proc>
|
||||
</properties>
|
||||
<dependencyManagement><dependencies>
|
||||
@@ -25,6 +25,9 @@
|
||||
<dependency><groupId>org.junit</groupId><artifactId>junit-bom</artifactId><version>6.0.1</version><type>pom</type><scope>import</scope></dependency>
|
||||
</dependencies></dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.uber.nullaway</groupId><artifactId>nullaway-annotations</artifactId><version>0.14.3</version><scope>provided</scope></dependency>
|
||||
<!-- Direct nullable contracts reuse the already-resolved runtime annotation version. -->
|
||||
<dependency><groupId>org.jspecify</groupId><artifactId>jspecify</artifactId><version>1.0.0</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId><version>${qqq.version}</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId><version>${qqq.version}</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-filesystem</artifactId><version>${qqq.version}</version></dependency>
|
||||
@@ -49,10 +52,57 @@
|
||||
<plugins>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-checkstyle-plugin</artifactId><version>3.3.1</version>
|
||||
<dependencies><dependency><groupId>com.puppycrawl.tools</groupId><artifactId>checkstyle</artifactId><version>10.12.6</version></dependency></dependencies>
|
||||
<configuration><configLocation>checkstyle/config.xml</configLocation><includeTestSourceDirectory>true</includeTestSourceDirectory><consoleOutput>true</consoleOutput><violationSeverity>warning</violationSeverity><failOnViolation>true</failOnViolation></configuration>
|
||||
<configuration>
|
||||
<configLocation>checkstyle/config.xml</configLocation>
|
||||
<!-- Development fixtures use distinct runtime tuples, but share the source style gate. -->
|
||||
<sourceDirectories>
|
||||
<sourceDirectory>${project.build.sourceDirectory}</sourceDirectory>
|
||||
<sourceDirectory>${project.basedir}/dev/java</sourceDirectory>
|
||||
<sourceDirectory>${project.basedir}/dev/native-auth/java</sourceDirectory>
|
||||
</sourceDirectories>
|
||||
<includeTestSourceDirectory>true</includeTestSourceDirectory>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
</configuration>
|
||||
<executions><execution><phase>validate</phase><goals><goal>check</goal></goals></execution></executions>
|
||||
</plugin>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version></plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.1</version>
|
||||
<configuration>
|
||||
<release>21</release>
|
||||
<proc>none</proc>
|
||||
<fork>true</fork>
|
||||
<executable>${java.home}/bin/javac</executable>
|
||||
<showWarnings>true</showWarnings>
|
||||
<failOnWarning>true</failOnWarning>
|
||||
<compilerArgs>
|
||||
<arg>-Werror</arg>
|
||||
<arg>-Xlint:deprecation</arg>
|
||||
<arg>-Xlint:unchecked</arg>
|
||||
<arg>-XDcompilePolicy=simple</arg>
|
||||
<arg>--should-stop=ifError=FLOW</arg>
|
||||
<arg>-XDaddTypeAnnotationsToSymbol=true</arg>
|
||||
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.kingsrook.qbits.printing</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
|
||||
</compilerArgs>
|
||||
<annotationProcessorPaths>
|
||||
<path><groupId>com.google.errorprone</groupId><artifactId>error_prone_core</artifactId><version>2.51.0</version></path>
|
||||
<path><groupId>com.uber.nullaway</groupId><artifactId>nullaway</artifactId><version>0.14.3</version></path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.4.2</version><configuration><archive><manifestEntries><Implementation-Version>${project.version}</Implementation-Version></manifestEntries></archive></configuration></plugin>
|
||||
<plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version><configuration><mainClass>com.kingsrook.qbits.printing.PrintingDemo</mainClass></configuration></plugin>
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.util.logging.Logger;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -37,7 +38,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public Connection getConnection() throws SQLException
|
||||
{
|
||||
return (DriverManager.getConnection(url, user, password));
|
||||
return DriverManager.getConnection(url, user, password);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,15 +46,15 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public Connection getConnection(String username, String secret) throws SQLException
|
||||
{
|
||||
return (DriverManager.getConnection(url, username, secret));
|
||||
return DriverManager.getConnection(url, username, secret);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public PrintWriter getLogWriter()
|
||||
public @Nullable PrintWriter getLogWriter()
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +78,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public int getLoginTimeout()
|
||||
{
|
||||
return (DriverManager.getLoginTimeout());
|
||||
return DriverManager.getLoginTimeout();
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +86,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public Logger getParentLogger()
|
||||
{
|
||||
return (Logger.getLogger("com.kingsrook.qbits.printing"));
|
||||
return Logger.getLogger("com.kingsrook.qbits.printing");
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +96,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
{
|
||||
if(type.isInstance(this))
|
||||
{
|
||||
return (type.cast(this));
|
||||
return type.cast(this);
|
||||
}
|
||||
throw new SQLException("Not a wrapper");
|
||||
}
|
||||
@@ -105,7 +106,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public boolean isWrapperFor(Class<?> type)
|
||||
{
|
||||
return (type.isInstance(this));
|
||||
return type.isInstance(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +114,7 @@ public record DriverDataSource(String url, String user, String password) impleme
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("DriverDataSource[credentials redacted]");
|
||||
return "DriverDataSource[credentials redacted]";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,11 @@ public class PrintEnqueueEffectTarget implements EffectTarget
|
||||
try
|
||||
{
|
||||
JSONObject job = serviceForEffect().submit(payload);
|
||||
return (EffectResult.sent(job.getString("jobId")));
|
||||
return EffectResult.sent(job.getString("jobId"));
|
||||
}
|
||||
catch(PrintingException exception)
|
||||
{
|
||||
return (EffectResult.failed(exception.getMessage()));
|
||||
return EffectResult.failed(exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public class PrintEnqueueEffectTarget implements EffectTarget
|
||||
public ReconcileResult reconcile(EffectRequest request) throws Exception
|
||||
{
|
||||
JSONObject job = serviceForEffect().findByIdempotencyKey(request.idempotencyKey());
|
||||
return (job == null ? ReconcileResult.notFound() : ReconcileResult.found(job.getString("jobId")));
|
||||
return job == null ? ReconcileResult.notFound() : ReconcileResult.found(job.getString("jobId"));
|
||||
}
|
||||
|
||||
|
||||
@@ -79,13 +79,13 @@ public class PrintEnqueueEffectTarget implements EffectTarget
|
||||
{
|
||||
if(!(QContext.getQSession() instanceof QSystemUserSession))
|
||||
{
|
||||
return (PrintingProcesses.currentService());
|
||||
return PrintingProcesses.currentService();
|
||||
}
|
||||
for(QBitMetaData metadata : QContext.getQInstance().getQBits().values())
|
||||
{
|
||||
if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() != null)
|
||||
{
|
||||
return (config.getService());
|
||||
return config.getService();
|
||||
}
|
||||
}
|
||||
throw new QException("Printing QBit is not configured for this effect dispatcher");
|
||||
|
||||
@@ -26,6 +26,7 @@ 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;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -47,7 +48,7 @@ public class PrintingAdminPermission extends UseOtherPermissionNameCustomPermiss
|
||||
** Enforce authorization even for direct backend-step invocation.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission("printing_admin"))
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -174,7 +175,7 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** SQL aggregates stay bounded by active capacity and the rolling hour index.
|
||||
***************************************************************************/
|
||||
private static void checkScope(Connection connection, String tenant, String device, int bytes, long jobLimit, long activeByteLimit, long retainedLimit, long hourlyLimit, String cutoff) throws SQLException
|
||||
private static void checkScope(Connection connection, String tenant, @Nullable String device, int bytes, long jobLimit, long activeByteLimit, long retainedLimit, long hourlyLimit, String cutoff) throws SQLException
|
||||
{
|
||||
String predicate = "tenant_id=?" + (device == null ? "" : " AND device_id=?");
|
||||
String outstanding = "SELECT content_length FROM printing_job WHERE " + predicate + " AND status IN " + ACTIVE
|
||||
@@ -205,11 +206,11 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** Failed upload reservations stay charged until exact confirmed removal.
|
||||
***************************************************************************/
|
||||
private static long orphanBytes(Connection connection, String tenant, String device) throws SQLException
|
||||
private static long orphanBytes(Connection connection, String tenant, @Nullable String device) throws SQLException
|
||||
{
|
||||
String sql = "SELECT COALESCE(SUM(COALESCE(content_length,20971520)),0) FROM printing_document_orphan o WHERE tenant_id=?"
|
||||
+ (device == null ? "" : " AND device_id=?") + " AND NOT EXISTS(SELECT 1 FROM printing_job j WHERE j.id=o.id)";
|
||||
return (device == null ? number(connection, sql, tenant) : number(connection, sql, tenant, device));
|
||||
return device == null ? number(connection, sql, tenant) : number(connection, sql, tenant, device);
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +218,7 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** Release only after deletion; an inconsistent counter fails closed.
|
||||
***************************************************************************/
|
||||
static void release(Connection connection, String tenant, String device, long bytes) throws SQLException
|
||||
static void release(Connection connection, String tenant, @Nullable String device, long bytes) throws SQLException
|
||||
{
|
||||
int expected = device == null ? 1 : 2;
|
||||
String predicate = device == null ? "device_id=''" : "device_id IN ('',?)";
|
||||
@@ -255,7 +256,7 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** Read one scalar; never materialize payloads or an unbounded row list.
|
||||
***************************************************************************/
|
||||
private static long number(Connection connection, String sql, Object... values) throws SQLException
|
||||
private static long number(Connection connection, String sql, @Nullable Object... values) throws SQLException
|
||||
{
|
||||
try(var statement = connection.prepareStatement(sql))
|
||||
{
|
||||
@@ -269,7 +270,7 @@ final class PrintingAdmission
|
||||
{
|
||||
throw new SQLException("Missing printing resource accounting row");
|
||||
}
|
||||
return (rows.getLong(1));
|
||||
return rows.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +280,7 @@ final class PrintingAdmission
|
||||
/***************************************************************************
|
||||
** All identifiers are fixed SQL; only values come from callers.
|
||||
***************************************************************************/
|
||||
private static int execute(Connection connection, String sql, Object... values) throws SQLException
|
||||
private static int execute(Connection connection, String sql, @Nullable Object... values) throws SQLException
|
||||
{
|
||||
try(var statement = connection.prepareStatement(sql))
|
||||
{
|
||||
@@ -287,7 +288,7 @@ final class PrintingAdmission
|
||||
{
|
||||
statement.setObject(index + 1, values[index]);
|
||||
}
|
||||
return (statement.executeUpdate());
|
||||
return statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public record PrintingAdmissionLimits(int deviceOutstandingJobs, int tenantOutst
|
||||
public static PrintingAdmissionLimits defaults()
|
||||
{
|
||||
long gibibyte = 1024L * 1024 * 1024;
|
||||
return (new PrintingAdmissionLimits(10_000, 100_000, 10 * gibibyte, 100 * gibibyte,
|
||||
2048 * gibibyte, 20_480 * gibibyte, 2_000, 20_000, 1_000, 20_000));
|
||||
return new PrintingAdmissionLimits(10_000, 100_000, 10 * gibibyte, 100 * gibibyte,
|
||||
2048 * gibibyte, 20_480 * gibibyte, 2_000, 20_000, 1_000, 20_000);
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public class PrintingAdmissionSettings
|
||||
{
|
||||
try(Connection connection = source.getConnection())
|
||||
{
|
||||
return (json(effective(connection)));
|
||||
return json(effective(connection));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public class PrintingAdmissionSettings
|
||||
throw new SQLException("The tenant admission settings row is unavailable");
|
||||
}
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,10 +102,10 @@ public class PrintingAdmissionSettings
|
||||
{
|
||||
if(!rows.next())
|
||||
{
|
||||
return (defaults);
|
||||
return defaults;
|
||||
}
|
||||
String stored = rows.getString(1);
|
||||
return (stored == null ? defaults : parse(new JSONObject(stored)));
|
||||
return stored == null ? defaults : parse(new JSONObject(stored));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,9 +123,9 @@ public class PrintingAdmissionSettings
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new PrintingAdmissionLimits(integer(value, "deviceOutstandingJobs"), integer(value, "tenantOutstandingJobs"),
|
||||
return new PrintingAdmissionLimits(integer(value, "deviceOutstandingJobs"), integer(value, "tenantOutstandingJobs"),
|
||||
number(value, "deviceOutstandingBytes"), number(value, "tenantOutstandingBytes"), number(value, "deviceRetainedBytes"), number(value, "tenantRetainedBytes"),
|
||||
integer(value, "deviceJobsPerHour"), integer(value, "tenantJobsPerHour"), integer(value, "devicePrinters"), integer(value, "tenantPrinters")));
|
||||
integer(value, "deviceJobsPerHour"), integer(value, "tenantJobsPerHour"), integer(value, "devicePrinters"), integer(value, "tenantPrinters"));
|
||||
}
|
||||
catch(ArithmeticException exception)
|
||||
{
|
||||
@@ -140,7 +140,7 @@ public class PrintingAdmissionSettings
|
||||
***************************************************************************/
|
||||
private static int integer(JSONObject value, String field)
|
||||
{
|
||||
return (Math.toIntExact(number(value, field)));
|
||||
return Math.toIntExact(number(value, field));
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ public class PrintingAdmissionSettings
|
||||
{
|
||||
throw new IllegalArgumentException(field + " is outside the supported positive range");
|
||||
}
|
||||
return (parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
|
||||
@@ -169,10 +169,10 @@ public class PrintingAdmissionSettings
|
||||
***************************************************************************/
|
||||
private static JSONObject json(PrintingAdmissionLimits value)
|
||||
{
|
||||
return (new JSONObject().put("deviceOutstandingJobs", value.deviceOutstandingJobs()).put("tenantOutstandingJobs", value.tenantOutstandingJobs())
|
||||
return new JSONObject().put("deviceOutstandingJobs", value.deviceOutstandingJobs()).put("tenantOutstandingJobs", value.tenantOutstandingJobs())
|
||||
.put("deviceOutstandingBytes", value.deviceOutstandingBytes()).put("tenantOutstandingBytes", value.tenantOutstandingBytes())
|
||||
.put("deviceRetainedBytes", value.deviceRetainedBytes()).put("tenantRetainedBytes", value.tenantRetainedBytes())
|
||||
.put("deviceJobsPerHour", value.deviceJobsPerHour()).put("tenantJobsPerHour", value.tenantJobsPerHour())
|
||||
.put("devicePrinters", value.devicePrinters()).put("tenantPrinters", value.tenantPrinters()));
|
||||
.put("devicePrinters", value.devicePrinters()).put("tenantPrinters", value.tenantPrinters());
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ public final class PrintingAgentAuthorization
|
||||
String principal = PrintingControlAuthorization.principal();
|
||||
PrintingControlAuthorization.record(PrintingTables.DEVICE, id);
|
||||
PrintingProcesses.currentService().authorizeAgent(id, token, principal, bind);
|
||||
return (id);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ public final class PrintingAgentAuthorization
|
||||
{
|
||||
throw new QPermissionDeniedException("Printer is not accessible");
|
||||
}
|
||||
return (PrintingControlAuthorization.record(PrintingTables.PRINTER, rows.getFirst().getValueString("id")));
|
||||
return PrintingControlAuthorization.record(PrintingTables.PRINTER, rows.getFirst().getValueString("id"));
|
||||
}
|
||||
|
||||
|
||||
@@ -93,10 +93,20 @@ public final class PrintingAgentAuthorization
|
||||
cursor = page.isNull("nextCursor") ? null : page.getString("nextCursor");
|
||||
}
|
||||
while(cursor != null);
|
||||
// Removed printers are hidden from the page but still belong to the device's row set: a re-shared queue reactivates one.
|
||||
for(var removed : PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.PRINTER)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)).withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_NOT_BLANK)).withLimit(10001))).getRecords())
|
||||
{
|
||||
authorized.add(removed.getValueString("id"));
|
||||
if(authorized.size() > 10000)
|
||||
{
|
||||
throw new PrintingException(429, "Inventory exceeds the bounded native review limit");
|
||||
}
|
||||
}
|
||||
// Even an empty inventory must have native table READ access.
|
||||
PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.PRINTER)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)).withLimit(1)));
|
||||
return (authorized);
|
||||
return authorized;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,9 +134,9 @@ public final class PrintingAgentAuthorization
|
||||
JSONObject result = PrintingProcesses.currentService().claimForJob(device, id);
|
||||
if(!result.isNull("job"))
|
||||
{
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return (new JSONObject().put("job", JSONObject.NULL));
|
||||
return new JSONObject().put("job", JSONObject.NULL);
|
||||
}
|
||||
}
|
||||
@@ -68,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", "printer-media-v1")), "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", "printer-approval-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");
|
||||
@@ -76,28 +76,28 @@ final class PrintingApiSchemas
|
||||
add(schemas, "ActionRequest", fields("requestId", id(), "issuedAt", time(), "action", actions, "jobId", id()), "requestId", "issuedAt", "action");
|
||||
add(schemas, "Action", fields("commandId", id(), "requestId", id(), "issuedAt", time(), "deviceId", id(), "action", actions, "status", enumeration("PENDING", "SUCCEEDED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"), "createdAt", time(), "expiresAt", time(), "jobId", id(), "resultCode", text(80), "cancelledJobCount", number(), "receipt", ref("ReceiptRequest")), "commandId", "requestId", "issuedAt", "deviceId", "action", "status", "createdAt", "expiresAt");
|
||||
add(schemas, "Actions", fields("commands", array(ref("Action")), "nextCursor", text(128).withNullable(true)), "commands", "nextCursor");
|
||||
return (schemas);
|
||||
return schemas;
|
||||
}
|
||||
|
||||
|
||||
/** Every schema has the extension prefix required by the native registry. */
|
||||
static String name(String shortName)
|
||||
{
|
||||
return ("Printing" + shortName + "V1");
|
||||
return "Printing" + shortName + "V1";
|
||||
}
|
||||
|
||||
|
||||
/** Refer to one typed reusable component. */
|
||||
static Schema ref(String name)
|
||||
{
|
||||
return (new Schema().withRefToSchema(name(name)));
|
||||
return new Schema().withRefToSchema(name(name));
|
||||
}
|
||||
|
||||
|
||||
/** Bounded Unicode text. */
|
||||
static Schema text(int limit)
|
||||
{
|
||||
return (new Schema().withType(Type.STRING).withMaxLength(limit));
|
||||
return new Schema().withType(Type.STRING).withMaxLength(limit);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ final class PrintingApiSchemas
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new RequiredSchema(required).withType(Type.OBJECT).withProperties(properties));
|
||||
return new RequiredSchema(required).withType(Type.OBJECT).withProperties(properties);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ final class PrintingApiSchemas
|
||||
/** Published by the core Jackson schema serializer. */
|
||||
public List<String> getRequired()
|
||||
{
|
||||
return (required);
|
||||
return required;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ final class PrintingApiSchemas
|
||||
{
|
||||
fields.put((String) pairs[index], (Schema) pairs[index + 1]);
|
||||
}
|
||||
return (fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
||||
@@ -153,41 +153,41 @@ final class PrintingApiSchemas
|
||||
/** Protocol identifier. */
|
||||
private static Schema id()
|
||||
{
|
||||
return (text(128));
|
||||
return text(128);
|
||||
}
|
||||
|
||||
|
||||
/** UTC wire timestamp. */
|
||||
private static Schema time()
|
||||
{
|
||||
return (text(40).withFormat("date-time").withDescription("RFC3339 UTC ending in uppercase Z."));
|
||||
return text(40).withFormat("date-time").withDescription("RFC3339 UTC ending in uppercase Z.");
|
||||
}
|
||||
|
||||
|
||||
/** Exact JSON integer, never a stringified numeric value. */
|
||||
private static Schema number()
|
||||
{
|
||||
return (new Schema().withType(Type.INTEGER));
|
||||
return new Schema().withType(Type.INTEGER);
|
||||
}
|
||||
|
||||
|
||||
/** Required booleans must never be inferred from missing fields. */
|
||||
private static Schema bool()
|
||||
{
|
||||
return (new Schema().withType(Type.BOOLEAN));
|
||||
return new Schema().withType(Type.BOOLEAN);
|
||||
}
|
||||
|
||||
|
||||
/** Closed action or status vocabulary. */
|
||||
private static Schema enumeration(String... values)
|
||||
{
|
||||
return (new Schema().withType(Type.STRING).withEnumValues(List.of(values)));
|
||||
return new Schema().withType(Type.STRING).withEnumValues(List.of(values));
|
||||
}
|
||||
|
||||
|
||||
/** Homogeneous typed arrays. */
|
||||
private static Schema array(Schema items)
|
||||
{
|
||||
return (new Schema().withType(Type.ARRAY).withItems(items));
|
||||
return new Schema().withType(Type.ARRAY).withItems(items);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ 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;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -47,16 +48,16 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
/***************************************************************************
|
||||
** The fields from process values, with server-side defaults.
|
||||
***************************************************************************/
|
||||
public static PrintingChrome from(RunBackendStepInput input, String fallbackTitle)
|
||||
public static PrintingChrome from(RunBackendStepInput input, @Nullable String fallbackTitle)
|
||||
{
|
||||
return (new PrintingChrome(
|
||||
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", "")));
|
||||
value(input, "footerNote", ""));
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +67,7 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
***************************************************************************/
|
||||
public static PrintingChrome defaults(String title)
|
||||
{
|
||||
return (new PrintingChrome(defaultApp(), title == null ? "" : title, "", defaultUser(), Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), "QQQ printing", ""));
|
||||
return new PrintingChrome(defaultApp(), title == null ? "" : title, "", defaultUser(), Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), "QQQ printing", "");
|
||||
}
|
||||
|
||||
|
||||
@@ -74,9 +75,9 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
/***************************************************************************
|
||||
** The same chrome with a footer note (a grid's filter and sort).
|
||||
***************************************************************************/
|
||||
public PrintingChrome withFooterNote(String note)
|
||||
public PrintingChrome withFooterNote(@Nullable String note)
|
||||
{
|
||||
return (new PrintingChrome(app, title, server, user, printedAt, footerLeft, footerNote == null || footerNote.isBlank() ? (note == null ? "" : note) : footerNote));
|
||||
return new PrintingChrome(app, title, server, user, printedAt, footerLeft, footerNote == null || footerNote.isBlank() ? (note == null ? "" : note) : footerNote);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +105,7 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
out.append(out.isEmpty() ? "" : " · ").append(part);
|
||||
}
|
||||
}
|
||||
return (out.toString());
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +113,7 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
/***************************************************************************
|
||||
** A string value, or the default when absent or blank (bounded length).
|
||||
***************************************************************************/
|
||||
private static String value(RunBackendStepInput input, String name, String fallback)
|
||||
private static String value(RunBackendStepInput input, String name, @Nullable String fallback)
|
||||
{
|
||||
String value = input == null ? null : input.getValueString(name);
|
||||
String chosen = value == null || value.isBlank() ? fallback : value;
|
||||
@@ -133,7 +134,7 @@ public record PrintingChrome(String app, String title, String server, String use
|
||||
}
|
||||
catch(RuntimeException exception)
|
||||
{
|
||||
return ("QQQ");
|
||||
return "QQQ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public class PrintingCommandStatus implements TableCustomizerInterface
|
||||
Set<String> selected = input instanceof QueryInput query ? query.getFieldNamesToInclude() : null;
|
||||
if(selected != null && !selected.contains("command_status"))
|
||||
{
|
||||
return (records);
|
||||
return records;
|
||||
}
|
||||
if(selected != null && !selected.contains("id"))
|
||||
{
|
||||
@@ -72,7 +72,7 @@ public class PrintingCommandStatus implements TableCustomizerInterface
|
||||
QRecord values = dependencies.getOrDefault(record.getValueString("id"), record);
|
||||
record.setValue("command_status", effective(values, now));
|
||||
}
|
||||
return (records);
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ public class PrintingCommandStatus implements TableCustomizerInterface
|
||||
result.put(row.getValueString("id"), row);
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -116,24 +116,24 @@ public class PrintingCommandStatus implements TableCustomizerInterface
|
||||
String status = record.getValueString("status");
|
||||
if(status != null && TERMINAL.contains(status))
|
||||
{
|
||||
return (status);
|
||||
return status;
|
||||
}
|
||||
if(!"PENDING".equals(status))
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
String expiresAt = record.getValueString("expires_at");
|
||||
if(expiresAt == null || expiresAt.isBlank())
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Instant.parse(expiresAt).isBefore(now) ? "EXPIRED" : "PENDING");
|
||||
return Instant.parse(expiresAt).isBefore(now) ? "EXPIRED" : "PENDING";
|
||||
}
|
||||
catch(DateTimeParseException exception)
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public final class PrintingControlAuthorization
|
||||
}
|
||||
new PrintingAdminPermission().checkPermissionsThrowing(new RunBackendStepInput(), null);
|
||||
PrintingProcesses.currentService();
|
||||
return (QContext.getQSession().getUser().getIdReference());
|
||||
return QContext.getQSession().getUser().getIdReference();
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ public final class PrintingControlAuthorization
|
||||
{
|
||||
throw new QPermissionDeniedException("Record is not accessible");
|
||||
}
|
||||
return (row);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ public final class PrintingControlAuthorization
|
||||
static QueryOutput query(QueryInput input) throws QException
|
||||
{
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input));
|
||||
return new QueryAction().execute(input);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ public final class PrintingControlAuthorization
|
||||
{
|
||||
throw new QPermissionDeniedException("Printer is not assigned to this device");
|
||||
}
|
||||
return (row);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,8 @@ public final class PrintingControlAuthorization
|
||||
PrintingDeviceProcesses.Action selected = null;
|
||||
for(PrintingDeviceProcesses.Action action : PrintingDeviceProcesses.Action.values())
|
||||
{
|
||||
if(action.wire.equals(request.optString("action")))
|
||||
// The first action owning a wire value is its control process; Remove device reuses REVOKE's wire without owning it.
|
||||
if(selected == null && action.wire.equals(request.optString("action")))
|
||||
{
|
||||
selected = action;
|
||||
}
|
||||
@@ -152,7 +153,7 @@ public final class PrintingControlAuthorization
|
||||
{
|
||||
job(deviceId, receipt.getValueString("job_id"));
|
||||
}
|
||||
return (service.deviceAction(deviceId, request, actor));
|
||||
return service.deviceAction(deviceId, request, actor);
|
||||
}
|
||||
Set<String> queue = null;
|
||||
if("CLEAR_QUEUE".equals(selected.wire))
|
||||
@@ -181,6 +182,6 @@ public final class PrintingControlAuthorization
|
||||
{
|
||||
job(deviceId, request.optString("jobId"));
|
||||
}
|
||||
return (service.deviceAction(deviceId, request, actor, queue));
|
||||
return service.deviceAction(deviceId, request, actor, queue);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import static com.kingsrook.qbits.printing.PrintingService.query;
|
||||
import static com.kingsrook.qbits.printing.PrintingService.update;
|
||||
|
||||
@@ -88,19 +89,19 @@ final class PrintingDeviceControls
|
||||
{
|
||||
result.put("lastPollAt", row.getString("control_last_poll_at"));
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** Strict action input and original receipt enforce replay before any side effect. */
|
||||
JSONObject action(String deviceId, JSONObject request, String actor) throws SQLException
|
||||
{
|
||||
return (action(deviceId, request, actor, null));
|
||||
return action(deviceId, request, actor, null);
|
||||
}
|
||||
|
||||
|
||||
/** A native authorized queue snapshot must still match after taking the device lock. */
|
||||
JSONObject action(String deviceId, JSONObject request, String actor, Set<String> authorizedQueue) throws SQLException
|
||||
JSONObject action(String deviceId, JSONObject request, String actor, @Nullable Set<String> authorizedQueue) throws SQLException
|
||||
{
|
||||
fields(request, Set.of("requestId", "issuedAt", "action", "jobId"));
|
||||
String requestId = text(request, "requestId", 128);
|
||||
@@ -132,7 +133,7 @@ final class PrintingDeviceControls
|
||||
throw new PrintingException(409, "Request identity was already used for a different action");
|
||||
}
|
||||
connection.commit();
|
||||
return (record(old.getFirst()));
|
||||
return record(old.getFirst());
|
||||
}
|
||||
Instant issued;
|
||||
try
|
||||
@@ -245,7 +246,7 @@ final class PrintingDeviceControls
|
||||
id, tenant, deviceId, requestId, action, jobId, actor, request.toString(), REMOTE.contains(action) ? "PENDING" : "SUCCEEDED", now, expires, result, cancelled);
|
||||
JSONObject response = record(command(connection, deviceId, id));
|
||||
connection.commit();
|
||||
return (response);
|
||||
return response;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -266,8 +267,8 @@ final class PrintingDeviceControls
|
||||
}
|
||||
String version = text(request, "agentVersion", 100);
|
||||
long applied = integer(request, "lastAppliedRevision");
|
||||
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())
|
||||
if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 4
|
||||
|| capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value) && !"printer-media-v1".equals(value) && !"printer-approval-v1".equals(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length())
|
||||
{
|
||||
throw new PrintingException(400, "Invalid control capabilities");
|
||||
}
|
||||
@@ -301,8 +302,8 @@ final class PrintingDeviceControls
|
||||
commands.put(delivery);
|
||||
}
|
||||
connection.commit();
|
||||
return (new JSONObject().put("controlVersion", 1).put("serverTime", now).put("pollIntervalSeconds", 5)
|
||||
.put("desiredState", state(device).getJSONObject("desiredState")).put("commands", commands));
|
||||
return new JSONObject().put("controlVersion", 1).put("serverTime", now).put("pollIntervalSeconds", 5)
|
||||
.put("desiredState", state(device).getJSONObject("desiredState")).put("commands", commands);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -375,7 +376,7 @@ final class PrintingDeviceControls
|
||||
update(connection, "UPDATE printing_device_command SET status=?,receipt_json=? WHERE id=? AND tenant_id=? AND device_id=?", status, receipt.toString(), commandId, tenant, deviceId);
|
||||
}
|
||||
connection.commit();
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -387,7 +388,7 @@ final class PrintingDeviceControls
|
||||
|
||||
|
||||
/** Management history is bounded and remains readable after revocation. */
|
||||
JSONObject page(String deviceId, int limit, String before) throws SQLException
|
||||
JSONObject page(String deviceId, int limit, @Nullable String before) throws SQLException
|
||||
{
|
||||
if(limit < 1 || limit > 200)
|
||||
{
|
||||
@@ -414,7 +415,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
commands.put(record(row));
|
||||
}
|
||||
return (new JSONObject().put("commands", commands).put("nextCursor", rows.size() > limit ? rows.get(limit - 1).getString("id") : JSONObject.NULL));
|
||||
return new JSONObject().put("commands", commands).put("nextCursor", rows.size() > limit ? rows.get(limit - 1).getString("id") : JSONObject.NULL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +425,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
try(Connection connection = source.getConnection())
|
||||
{
|
||||
return (record(command(connection, deviceId, commandId)));
|
||||
return record(command(connection, deviceId, commandId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +438,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(404, "Device not found");
|
||||
}
|
||||
return (rows.getFirst());
|
||||
return rows.getFirst();
|
||||
}
|
||||
|
||||
|
||||
@@ -456,7 +457,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(404, "Command not found");
|
||||
}
|
||||
return (rows.getFirst());
|
||||
return rows.getFirst();
|
||||
}
|
||||
|
||||
|
||||
@@ -486,14 +487,14 @@ final class PrintingDeviceControls
|
||||
{
|
||||
value.put("receipt", new JSONObject(row.getString("receipt_json")));
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
/** Numeric aggregates do not load a receipt history into application memory. */
|
||||
private static long count(Connection connection, String sql, Object... values) throws SQLException
|
||||
{
|
||||
return (query(connection, sql, values).getFirst().getLong("n"));
|
||||
return query(connection, sql, values).getFirst().getLong("n");
|
||||
}
|
||||
|
||||
|
||||
@@ -504,7 +505,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + field);
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -516,7 +517,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(400, "UTC timestamp required");
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Require an exact nonnegative integral JSON value without coercion. */
|
||||
@@ -527,7 +528,7 @@ final class PrintingDeviceControls
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + field);
|
||||
}
|
||||
return (((Number) value).longValue());
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ public final class PrintingDeviceProcesses
|
||||
"Connected agents will be asked to resume normal traffic. An offline computer cannot be reached immediately."),
|
||||
REVOKE("printingRevokeDevices", "Revoke device access", "REVOKE", true, false,
|
||||
"Access will be revoked permanently. The old credential cannot be restored; local re-enrollment is required."),
|
||||
REMOVE("printingRemoveDevices", "Remove device", "REVOKE", true, false,
|
||||
"Access will be revoked permanently and the device and its printers will be hidden from the printing views. Job history is kept. A removed device cannot be restored; a new enrollment creates a new device."),
|
||||
REFRESH("printingRefreshDevices", "Refresh printer inventory", "REFRESH_INVENTORY", false, false,
|
||||
"A bounded refresh request will wait for a compatible agent. Acceptance does not mean it has executed."),
|
||||
CLEAR("printingClearDeviceQueues", "Cancel queued jobs", "CLEAR_QUEUE", true, false,
|
||||
@@ -162,7 +164,7 @@ public final class PrintingDeviceProcesses
|
||||
{
|
||||
if(action.process.equals(input.getProcessName()))
|
||||
{
|
||||
return (action);
|
||||
return action;
|
||||
}
|
||||
}
|
||||
throw new QException("Unknown printing control action");
|
||||
@@ -182,7 +184,7 @@ public final class PrintingDeviceProcesses
|
||||
{
|
||||
throw new QException("Sign in with an authorized QQQ user before controlling a print device");
|
||||
}
|
||||
return (QContext.getQSession().getUser().getIdReference());
|
||||
return QContext.getQSession().getUser().getIdReference();
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +208,7 @@ public final class PrintingDeviceProcesses
|
||||
{
|
||||
throw new QException("Use Cancel queued jobs for work that has not been dispatched to Windows");
|
||||
}
|
||||
return (record);
|
||||
return record;
|
||||
}
|
||||
|
||||
|
||||
@@ -268,10 +270,12 @@ public final class PrintingDeviceProcesses
|
||||
/************************************************************************
|
||||
** Standard summary links preserve the native record/table workflow.
|
||||
************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready)));
|
||||
return new ArrayList<>(List.of(ready));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,8 +328,17 @@ public final class PrintingDeviceProcesses
|
||||
}
|
||||
try
|
||||
{
|
||||
JSONObject result = PrintingControlAuthorization.deviceAction(deviceId, request);
|
||||
String status = result.getString("status");
|
||||
String status;
|
||||
if(action == Action.REMOVE)
|
||||
{
|
||||
// Removal reuses the REVOKE control for the credential; an already revoked device only needs archiving.
|
||||
status = current.getValueString("revoked_at") == null ? PrintingControlAuthorization.deviceAction(deviceId, request).getString("status") : "SUCCEEDED";
|
||||
PrintingProcesses.currentService().removeDevice(deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
status = PrintingControlAuthorization.deviceAction(deviceId, request).getString("status");
|
||||
}
|
||||
ProcessSummaryLine summary = "PENDING".equals(status) ? pending : ("SUCCEEDED".equals(status) ? applied : unresolved);
|
||||
summary.incrementCountAndAddPrimaryKey(current.getValue("id"));
|
||||
output.getRecords().add(current);
|
||||
@@ -342,10 +355,12 @@ public final class PrintingDeviceProcesses
|
||||
/************************************************************************
|
||||
** Pending requests are never presented as completed Windows operations.
|
||||
************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(applied, pending, unresolved)));
|
||||
return new ArrayList<>(List.of(applied, pending, unresolved));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperat
|
||||
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 org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -56,7 +57,7 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
Set<String> selected = input instanceof QueryInput query ? query.getFieldNamesToInclude() : null;
|
||||
if(selected != null && !selected.contains("device_status") && !selected.contains("control_status"))
|
||||
{
|
||||
return (records);
|
||||
return records;
|
||||
}
|
||||
if(selected != null && !selected.contains("id"))
|
||||
{
|
||||
@@ -76,7 +77,7 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
record.setValue("control_status", application(values));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +109,7 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
result.put(row.getValueString("id"), row);
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,24 +121,24 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
{
|
||||
if(record.getValueInteger("enabled") != null && record.getValueInteger("enabled") == 0)
|
||||
{
|
||||
return ("REVOKED");
|
||||
return "REVOKED";
|
||||
}
|
||||
if("DISCONNECTED".equals(record.getValueString("connection_state")))
|
||||
{
|
||||
return ("DISCONNECTED");
|
||||
return "DISCONNECTED";
|
||||
}
|
||||
Instant heartbeat = timestamp(record.getValueString("last_seen_at"));
|
||||
Instant control = timestamp(record.getValueString("control_last_poll_at"));
|
||||
Instant seen = heartbeat == null || (control != null && control.isAfter(heartbeat)) ? control : heartbeat;
|
||||
if(seen == null || seen.isAfter(now.plusSeconds(30)))
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
if(seen.isBefore(now.minusSeconds(90)))
|
||||
{
|
||||
return ("OFFLINE");
|
||||
return "OFFLINE";
|
||||
}
|
||||
return (Integer.valueOf(1).equals(record.getValueInteger("dispatch_paused")) ? "PAUSED" : "ONLINE");
|
||||
return Integer.valueOf(1).equals(record.getValueInteger("dispatch_paused")) ? "PAUSED" : "ONLINE";
|
||||
}
|
||||
|
||||
|
||||
@@ -149,11 +150,11 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
{
|
||||
if(!Integer.valueOf(1).equals(record.getValueInteger("control_version")))
|
||||
{
|
||||
return ("UNKNOWN");
|
||||
return "UNKNOWN";
|
||||
}
|
||||
Long desired = record.getValueLong("control_revision");
|
||||
Long observed = record.getValueLong("control_applied_revision");
|
||||
return (desired != null && observed != null && desired.equals(observed) ? "APPLIED" : "PENDING");
|
||||
return desired != null && observed != null && desired.equals(observed) ? "APPLIED" : "PENDING";
|
||||
}
|
||||
|
||||
|
||||
@@ -161,19 +162,19 @@ public class PrintingDeviceStatus implements TableCustomizerInterface
|
||||
/***************************************************************************
|
||||
** Bad observations must remain unknown rather than imply a healthy device.
|
||||
***************************************************************************/
|
||||
private static Instant timestamp(String value)
|
||||
private static @Nullable Instant timestamp(@Nullable String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Instant.parse(value));
|
||||
return Instant.parse(value);
|
||||
}
|
||||
catch(DateTimeParseException exception)
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,14 +95,14 @@ public class PrintingDocumentStorage
|
||||
***************************************************************************/
|
||||
public byte[] get(String table, String reference) throws SQLException
|
||||
{
|
||||
return (get(table, reference, Math.toIntExact(PrintingRealtimePolicy.defaults().get("maxPdfBytes"))));
|
||||
return get(table, reference, Math.toIntExact(PrintingRealtimePolicy.defaults().get("maxPdfBytes")));
|
||||
}
|
||||
|
||||
|
||||
/** Internal retry callers supply the exact persisted length, independent of the current format policy. */
|
||||
public byte[] get(String table, String reference, int maximum) throws SQLException
|
||||
{
|
||||
return (execute(() ->
|
||||
return execute(() ->
|
||||
{
|
||||
try(InputStream stream = new StorageAction().getInputStream(input(table, reference)))
|
||||
{
|
||||
@@ -113,14 +113,14 @@ public class PrintingDocumentStorage
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** Return the native backend stream; the request owns closing it and enforcing its declared length. */
|
||||
public InputStream open(String table, String reference) throws SQLException
|
||||
{
|
||||
return (execute(() -> new StorageAction().getInputStream(input(table, reference))));
|
||||
return execute(() -> new StorageAction().getInputStream(input(table, reference)));
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ public class PrintingDocumentStorage
|
||||
{
|
||||
throw new IllegalArgumentException("Configured printing storage table does not exist");
|
||||
}
|
||||
return (instance.getTable(table).getBackendName());
|
||||
return instance.getTable(table).getBackendName();
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ public class PrintingDocumentStorage
|
||||
var backend = instance.getBackend(backendName(table));
|
||||
if(allowMemoryTest && "memory".equals(backend.getBackendType()))
|
||||
{
|
||||
return (digest(table + ":" + backend.getName()));
|
||||
return digest(table + ":" + backend.getName());
|
||||
}
|
||||
if(!table.startsWith("printing_") || !(backend instanceof S3BackendMetaData s3) || !(metadata.getBackendDetails() instanceof S3TableBackendDetails details)
|
||||
|| s3.getBucketName() == null || s3.getBucketName().isBlank())
|
||||
@@ -182,9 +182,9 @@ public class PrintingDocumentStorage
|
||||
throw new IllegalArgumentException("Printing bucket is shared with a non-printing table; configure a dedicated bucket");
|
||||
}
|
||||
}
|
||||
return (digest(new JSONArray().put(identity).put(java.util.Objects.toString(s3.getRegion(), "")).put(java.util.Objects.toString(s3.getPathStyleAccess(), "false"))
|
||||
return digest(new JSONArray().put(identity).put(java.util.Objects.toString(s3.getRegion(), "")).put(java.util.Objects.toString(s3.getPathStyleAccess(), "false"))
|
||||
.put(java.util.Objects.toString(s3.getBasePath(), "")).put(java.util.Objects.toString(details.getBasePath(), ""))
|
||||
.put(S3_KEY_LAYOUT).put((int) java.io.File.separatorChar).toString()));
|
||||
.put(S3_KEY_LAYOUT).put((int) java.io.File.separatorChar).toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ public class PrintingDocumentStorage
|
||||
public String bucketName(String table)
|
||||
{
|
||||
var backend = instance.getBackend(backendName(table));
|
||||
return (backend instanceof S3BackendMetaData s3 ? s3.getBucketName() : "In-memory test storage");
|
||||
return backend instanceof S3BackendMetaData s3 ? s3.getBucketName() : "In-memory test storage";
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ public class PrintingDocumentStorage
|
||||
***************************************************************************/
|
||||
public String prefix()
|
||||
{
|
||||
return (prefix);
|
||||
return prefix;
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ public class PrintingDocumentStorage
|
||||
endpoint = uri.getScheme().toLowerCase(java.util.Locale.ROOT) + "://" + uri.getHost().toLowerCase(java.util.Locale.ROOT)
|
||||
+ (uri.getPort() < 0 || ("https".equalsIgnoreCase(uri.getScheme()) && uri.getPort() == 443) || ("http".equalsIgnoreCase(uri.getScheme()) && uri.getPort() == 80) ? "" : ":" + uri.getPort()) + java.util.Objects.toString(uri.getPath(), "").replaceAll("/+$", "");
|
||||
}
|
||||
return (new JSONArray().put(endpoint).put(java.util.Objects.toString(backend.getBucketName())).toString());
|
||||
return new JSONArray().put(endpoint).put(java.util.Objects.toString(backend.getBucketName())).toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ public class PrintingDocumentStorage
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))));
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException exception)
|
||||
{
|
||||
@@ -273,7 +273,7 @@ public class PrintingDocumentStorage
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid owned printing storage reference");
|
||||
}
|
||||
return (new StorageInput(table).withReference(reference));
|
||||
return new StorageInput(table).withReference(reference);
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ public class PrintingDocumentStorage
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
return (operation.call());
|
||||
return operation.call();
|
||||
}
|
||||
catch(PrintingException exception)
|
||||
{
|
||||
|
||||
@@ -60,7 +60,7 @@ final class PrintingDocumentStream extends FilterInputStream
|
||||
public int read() throws IOException
|
||||
{
|
||||
byte[] one = new byte[1];
|
||||
return (read(one, 0, 1) == -1 ? -1 : Byte.toUnsignedInt(one[0]));
|
||||
return read(one, 0, 1) == -1 ? -1 : Byte.toUnsignedInt(one[0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,6 @@ final class PrintingDocumentStream extends FilterInputStream
|
||||
throw new IOException("Stored print document integrity check failed");
|
||||
}
|
||||
}
|
||||
return (read);
|
||||
return read;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public class PrintingException extends RuntimeException
|
||||
***************************************************************************/
|
||||
public int getStatus()
|
||||
{
|
||||
return (status);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,6 @@ public class PrintingExtract extends ExtractViaQueryStep
|
||||
count.setTableName(input.getValueString(FIELD_SOURCE_TABLE));
|
||||
count.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
PermissionsHelper.checkTablePermissionThrowing(count, TablePermissionSubType.READ);
|
||||
return (super.doCount(input));
|
||||
return super.doCount(input);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -63,13 +64,13 @@ final class PrintingFacilities
|
||||
{
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName(DEFAULT_SOURCE).withType(QPossibleValueSourceType.ENUM)
|
||||
.withEnumValues(List.of(new QPossibleValue<>("default", "Default facility"))));
|
||||
return (DEFAULT_SOURCE);
|
||||
return DEFAULT_SOURCE;
|
||||
}
|
||||
if(instance.getPossibleValueSource(source) == null)
|
||||
{
|
||||
throw new QException("The configured printing facility possible-value source does not exist");
|
||||
}
|
||||
return (source);
|
||||
return source;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +81,8 @@ final class PrintingFacilities
|
||||
static QFieldMetaData field(QInstance instance)
|
||||
{
|
||||
String source = instance.getTable(PrintingTables.DEVICE).getField("facility_id").getPossibleValueSourceName();
|
||||
return (new QFieldMetaData("facilityId", QFieldType.STRING).withLabel("Facility").withIsRequired(true).withMaxLength(200)
|
||||
.withPossibleValueSourceName(source).withDefaultValue(DEFAULT_SOURCE.equals(source) ? "default" : null));
|
||||
return new QFieldMetaData("facilityId", QFieldType.STRING).withLabel("Facility").withIsRequired(true).withMaxLength(200)
|
||||
.withPossibleValueSourceName(source).withDefaultValue(DEFAULT_SOURCE.equals(source) ? "default" : null);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +90,7 @@ final class PrintingFacilities
|
||||
/***************************************************************************
|
||||
** Re-query the selected ID under the caller's session, never a system session.
|
||||
***************************************************************************/
|
||||
static String validate(String selected) throws QException
|
||||
static String validate(@Nullable String selected) throws QException
|
||||
{
|
||||
if(selected == null || selected.isBlank() || selected.length() > 200)
|
||||
{
|
||||
@@ -104,6 +105,6 @@ final class PrintingFacilities
|
||||
{
|
||||
throw new QException("The selected facility is unavailable or is not accessible to this user");
|
||||
}
|
||||
return (selected);
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
default -> throw new QException("Unknown printing home widget");
|
||||
};
|
||||
data.setSublabel("Checked " + DISPLAY_TIME.format(now) + " · current tenant · refresh every 60 seconds");
|
||||
return (new RenderWidgetOutput(data));
|
||||
return new RenderWidgetOutput(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -157,11 +157,12 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
.withBlock(text("Printing queue and storage limits").withLink(new BlockLink("/app/printingAdmissionSettings/?returnTo=%2Fapp%2Fprinting%2F")))
|
||||
.withBlock(text("Run retention cleanup").withLink(new BlockLink("/app/printingRunRetention/?returnTo=%2Fapp%2Fprinting%2F")))
|
||||
.withBlock(text("Printing setup and help").withLink(new BlockLink("https://github.com/QRun-IO/qbit-printing/blob/main/docs/OPERATIONS.md")));
|
||||
if(count(DEVICE, new QQueryFilter()) == 0)
|
||||
QQueryFilter present = new QQueryFilter().withCriteria("removed_at", QCriteriaOperator.IS_BLANK);
|
||||
if(count(DEVICE, present) == 0)
|
||||
{
|
||||
data.withBlock(text("No print devices are connected. Open Print devices and use Enroll a print device; then connect the Windows agent and expose its printers.").withLink(new BlockLink(tableUrl(DEVICE, new QQueryFilter()))));
|
||||
data.withBlock(text("No print devices are connected. Open Print devices and use Enroll a print device; then connect the Windows agent and expose its printers.").withLink(new BlockLink(tableUrl(DEVICE, present))));
|
||||
}
|
||||
return (data);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,10 +199,10 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
rows.add(issue(PRINTER, printer, printer.getValueString("name"), printer.getValueString("status"), "Check paper, paused/error state and whether this queue is still exposed by the agent.", "last_seen_at"));
|
||||
}
|
||||
return (table(rows, "No issues in this scope. If no devices are enrolled, start from Print devices.",
|
||||
return table(rows, "No issues in this scope. If no devices are enrolled, start from Print devices.",
|
||||
new TableData.Column("composite", "Issue / record", "issue", null, "left"),
|
||||
new TableData.Column("default", "Next step", "action", null, "left"),
|
||||
new TableData.Column("default", "Observed at", "observed", null, "left")));
|
||||
new TableData.Column("default", "Observed at", "observed", null, "left"));
|
||||
}
|
||||
|
||||
|
||||
@@ -219,12 +220,12 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
rows.add(Map.of("job", column().withBlock(text(job.getValueString("title")).withLink(new BlockLink(recordUrl(JOB, job)))),
|
||||
"status", job.getValueString("status"), "format", job.getValueString("format"), "created", displayTime(job.getValueString("created_at"))));
|
||||
}
|
||||
return (table(rows, "No pending or recent print work. Open Printers to enable an exposed queue and run its test-print action.",
|
||||
return table(rows, "No pending or recent print work. Open Printers to enable an exposed queue and run its test-print action.",
|
||||
new TableData.Column("composite", "Print job", "job", null, "left"),
|
||||
new TableData.Column("default", "Status", "status", null, "left"),
|
||||
new TableData.Column("default", "Format", "format", null, "left"),
|
||||
new TableData.Column("default", "Created at", "created", null, "left"))
|
||||
.withLinkText("Open all pending and recent jobs").withLinkURL(tableUrl(JOB, filter.withLimit(null))));
|
||||
.withLinkText("Open all pending and recent jobs").withLinkURL(tableUrl(JOB, filter.withLimit(null)));
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +235,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static StatisticsData statistic(String table, QQueryFilter filter, String context) throws QException
|
||||
{
|
||||
return (new StatisticsData().withCount(count(table, filter)).withCountContext(context).withCountURL(tableUrl(table, filter)));
|
||||
return new StatisticsData().withCount(count(table, filter)).withCountContext(context).withCountURL(tableUrl(table, filter));
|
||||
}
|
||||
|
||||
|
||||
@@ -246,7 +247,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
QueryInput input = new QueryInput().withTableName(table).withFilter(filter);
|
||||
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
return new QueryAction().execute(input).getRecords();
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +259,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
CountInput input = new CountInput(table).withFilter(filter);
|
||||
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
return (new CountAction().execute(input).getCount());
|
||||
return new CountAction().execute(input).getCount();
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +271,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Math.max(90, PrintingProcesses.currentService().realtimePolicy().get("heartbeatTimeoutSeconds")));
|
||||
return Math.max(90, PrintingProcesses.currentService().realtimePolicy().get("heartbeatTimeoutSeconds"));
|
||||
}
|
||||
catch(java.sql.SQLException exception)
|
||||
{
|
||||
@@ -286,7 +287,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
private static StatisticsData offlineStatistic(Instant now) throws QException
|
||||
{
|
||||
long seconds = freshnessSeconds();
|
||||
return (statistic(DEVICE, offline(now, seconds), "Enabled devices · now · disconnected by an administrator or unseen for " + seconds + " seconds"));
|
||||
return statistic(DEVICE, offline(now, seconds), "Enabled devices · now · disconnected by an administrator or unseen for " + seconds + " seconds");
|
||||
}
|
||||
|
||||
|
||||
@@ -299,7 +300,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
QQueryFilter unseen = new QQueryFilter().withSubFilter(stale("last_seen_at", now, freshnessSeconds)).withSubFilter(stale("control_last_poll_at", now, freshnessSeconds));
|
||||
QQueryFilter disconnected = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("connection_state", QCriteriaOperator.EQUALS, "DISCONNECTED").withSubFilter(unseen);
|
||||
return (new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(disconnected));
|
||||
return new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(disconnected);
|
||||
}
|
||||
|
||||
|
||||
@@ -309,8 +310,8 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static QQueryFilter stale(String field, Instant now, long freshnessSeconds)
|
||||
{
|
||||
return (new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria(field, QCriteriaOperator.IS_BLANK).withCriteria(field, QCriteriaOperator.LESS_THAN, now.minusSeconds(freshnessSeconds).toString()));
|
||||
return new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria(field, QCriteriaOperator.IS_BLANK).withCriteria(field, QCriteriaOperator.LESS_THAN, now.minusSeconds(freshnessSeconds).toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -320,8 +321,8 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static QQueryFilter printerIssues()
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("status", QCriteriaOperator.NOT_EQUALS, "READY").withCriteria("available", QCriteriaOperator.NOT_EQUALS, 1)));
|
||||
return new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1).withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("status", QCriteriaOperator.NOT_EQUALS, "READY").withCriteria("available", QCriteriaOperator.NOT_EQUALS, 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -331,7 +332,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static QQueryFilter statuses(List<String> states)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, states));
|
||||
return new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, states);
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +342,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static QQueryFilter failures(Instant now)
|
||||
{
|
||||
return (statuses(FAILURES).withCriteria("created_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400).toString()));
|
||||
return statuses(FAILURES).withCriteria("created_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400).toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -351,8 +352,8 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static Map<String, Object> issue(String table, QRecord record, String label, String status, String action, String timestamp) throws QException
|
||||
{
|
||||
return (Map.of("issue", column().withBlock(text(label + " — " + status).withLink(new BlockLink(recordUrl(table, record)))),
|
||||
"action", action, "observed", displayTime(record.getValueString(timestamp))));
|
||||
return Map.of("issue", column().withBlock(text(label + " — " + status).withLink(new BlockLink(recordUrl(table, record)))),
|
||||
"action", action, "observed", displayTime(record.getValueString(timestamp)));
|
||||
}
|
||||
|
||||
|
||||
@@ -364,15 +365,15 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("Unknown time");
|
||||
return "Unknown time";
|
||||
}
|
||||
try
|
||||
{
|
||||
return (DISPLAY_TIME.format(Instant.parse(value)));
|
||||
return DISPLAY_TIME.format(Instant.parse(value));
|
||||
}
|
||||
catch(DateTimeParseException exception)
|
||||
{
|
||||
return ("Unknown time");
|
||||
return "Unknown time";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +384,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static TableData table(List<Map<String, Object>> rows, String empty, TableData.Column... columns)
|
||||
{
|
||||
return (new TableData(null, List.of(columns), rows).withRowsPerPage(25).withHidePaginationDropdown(true).withNoRowsFoundHTML(empty));
|
||||
return new TableData(null, List.of(columns), rows).withRowsPerPage(25).withHidePaginationDropdown(true).withNoRowsFoundHTML(empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +394,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static CompositeWidgetData column()
|
||||
{
|
||||
return (new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_COLUMN));
|
||||
return new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_COLUMN);
|
||||
}
|
||||
|
||||
|
||||
@@ -403,7 +404,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static TextBlockData text(String value)
|
||||
{
|
||||
return (new TextBlockData().withValues(new TextValues(value)));
|
||||
return new TextBlockData().withValues(new TextValues(value));
|
||||
}
|
||||
|
||||
|
||||
@@ -413,7 +414,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static String recordUrl(String table, QRecord record) throws QException
|
||||
{
|
||||
return (QContext.getQInstance().getTablePath(table) + "/" + URLEncoder.encode(record.getValueString("id"), StandardCharsets.UTF_8));
|
||||
return QContext.getQInstance().getTablePath(table) + "/" + URLEncoder.encode(record.getValueString("id"), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
@@ -423,7 +424,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static String tableUrl(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (QContext.getQInstance().getTablePath(table) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), StandardCharsets.UTF_8));
|
||||
return QContext.getQInstance().getTablePath(table) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ public class PrintingJobProcesses
|
||||
{
|
||||
throw new QException("Job " + job.getValueString("title") + " is " + state + " and cannot be processed by this action");
|
||||
}
|
||||
return (job);
|
||||
return job;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ public class PrintingJobProcesses
|
||||
{
|
||||
throw new QException("Unknown print job action");
|
||||
}
|
||||
return (PrintingProcesses.currentService());
|
||||
return PrintingProcesses.currentService();
|
||||
}
|
||||
|
||||
|
||||
@@ -191,10 +191,12 @@ public class PrintingJobProcesses
|
||||
/***************************************************************************
|
||||
** Standard QQQ preview links selected job titles to their records.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready, risk)));
|
||||
return new ArrayList<>(List.of(ready, risk));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,10 +253,12 @@ public class PrintingJobProcesses
|
||||
/***************************************************************************
|
||||
** Standard QQQ results refer to the selected source job records.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(done)));
|
||||
return new ArrayList<>(List.of(done));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,13 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.uber.nullaway.annotations.EnsuresNonNull;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -50,7 +52,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
private final PDType0Font font;
|
||||
private final PDRectangle size;
|
||||
private final String title;
|
||||
private PDPageContentStream content;
|
||||
private @Nullable PDPageContentStream content;
|
||||
private float y;
|
||||
private List<String> repeatHeader = List.of();
|
||||
private int repeatSize = 8;
|
||||
@@ -73,7 +75,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
/***************************************************************************
|
||||
** 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
|
||||
PrintingLayout(String pageSize, boolean landscape, String title, String subtitle, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
this.chrome = chrome == null ? PrintingChrome.defaults(title) : chrome;
|
||||
try(InputStream stream = PrintingLayout.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE))
|
||||
@@ -111,7 +113,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
***************************************************************************/
|
||||
int columns(float fontSize)
|
||||
{
|
||||
return (Math.max(20, (int) ((size.getWidth() - 2 * MARGIN) / (0.6f * fontSize))));
|
||||
return Math.max(20, (int) ((size.getWidth() - 2 * MARGIN) / (0.6f * fontSize)));
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +132,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
/***************************************************************************
|
||||
** A note above the footer of every page (a grid's filter and sort).
|
||||
***************************************************************************/
|
||||
void footer(String text)
|
||||
void footer(@Nullable String text)
|
||||
{
|
||||
chrome = chrome.withFooterNote(text);
|
||||
}
|
||||
@@ -195,9 +197,9 @@ final class PrintingLayout implements AutoCloseable
|
||||
***************************************************************************/
|
||||
void rule() throws IOException
|
||||
{
|
||||
content.moveTo(MARGIN, y);
|
||||
content.lineTo(size.getWidth() - MARGIN, y);
|
||||
content.stroke();
|
||||
activeContent().moveTo(MARGIN, y);
|
||||
activeContent().lineTo(size.getWidth() - MARGIN, y);
|
||||
activeContent().stroke();
|
||||
y -= 4;
|
||||
}
|
||||
|
||||
@@ -210,7 +212,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
{
|
||||
try
|
||||
{
|
||||
content.close();
|
||||
activeContent().close();
|
||||
content = null;
|
||||
int total = document.getNumberOfPages();
|
||||
for(int i = 0; i < total; i++)
|
||||
@@ -234,7 +236,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
}
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
document.save(output);
|
||||
return (output.toByteArray());
|
||||
return output.toByteArray();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -269,6 +271,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
/***************************************************************************
|
||||
** Start a page; later pages show the running title.
|
||||
***************************************************************************/
|
||||
@EnsuresNonNull("content")
|
||||
private void newPage() throws IOException
|
||||
{
|
||||
if(document.getNumberOfPages() >= MAX_PAGES)
|
||||
@@ -281,9 +284,10 @@ final class PrintingLayout implements AutoCloseable
|
||||
}
|
||||
PDPage page = new PDPage(size);
|
||||
document.addPage(page);
|
||||
content = new PDPageContentStream(document, page);
|
||||
PDPageContentStream opened = new PDPageContentStream(document, page);
|
||||
content = opened;
|
||||
y = size.getHeight() - MARGIN + 14;
|
||||
header();
|
||||
header(opened);
|
||||
if(document.getNumberOfPages() > 1)
|
||||
{
|
||||
draw(PrintingRecordDocuments.truncate(title, columns(8)) + " (continued)", 8, 12);
|
||||
@@ -297,25 +301,25 @@ 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
|
||||
private void header(PDPageContentStream page) 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);
|
||||
at(page, MARGIN, y, PrintingRecordDocuments.truncate(chrome.app(), Math.max(10, columns(9) - rightText.length())), 9);
|
||||
at(page, 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);
|
||||
at(page, 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);
|
||||
page.setLineWidth(0.5f);
|
||||
page.moveTo(MARGIN, y);
|
||||
page.lineTo(size.getWidth() - MARGIN, y);
|
||||
page.stroke();
|
||||
page.setLineWidth(1f);
|
||||
y -= 6;
|
||||
}
|
||||
|
||||
@@ -361,11 +365,24 @@ final class PrintingLayout implements AutoCloseable
|
||||
private void draw(String text, float fontSize, float step) throws IOException
|
||||
{
|
||||
y -= step;
|
||||
content.beginText();
|
||||
content.setFont(font, fontSize);
|
||||
content.newLineAtOffset(MARGIN, y);
|
||||
content.showText(safe(PrintingRecordDocuments.truncate(text, columns(fontSize))));
|
||||
content.endText();
|
||||
activeContent().beginText();
|
||||
activeContent().setFont(font, fontSize);
|
||||
activeContent().newLineAtOffset(MARGIN, y);
|
||||
activeContent().showText(safe(PrintingRecordDocuments.truncate(text, columns(fontSize))));
|
||||
activeContent().endText();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Construction/newPage opens content; drawing and finish precede its null
|
||||
** transition. Preserve the existing failure on use after finish, without
|
||||
** imposing a non-null precondition on pure footer formatting operations.
|
||||
***************************************************************************/
|
||||
@SuppressWarnings("NullAway")
|
||||
private PDPageContentStream activeContent()
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -394,7 +411,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
out.append('?');
|
||||
}
|
||||
});
|
||||
return (out.toString());
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -404,7 +421,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
***************************************************************************/
|
||||
static int[] columnWidths(List<String> headers, List<List<String>> rows, int available)
|
||||
{
|
||||
return (fit(naturalWidths(headers, rows), available));
|
||||
return fit(naturalWidths(headers, rows), available);
|
||||
}
|
||||
|
||||
|
||||
@@ -428,7 +445,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
int typical = lengths.isEmpty() ? 0 : lengths.get(Math.max(0, (int) Math.ceil(lengths.size() * 0.9) - 1));
|
||||
widths[c] = Math.max(4, Math.min(60, Math.max(headers.get(c).length(), typical)));
|
||||
}
|
||||
return (widths);
|
||||
return widths;
|
||||
}
|
||||
|
||||
|
||||
@@ -438,7 +455,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
***************************************************************************/
|
||||
static int lineWidth(int[] widths)
|
||||
{
|
||||
return (sum(widths) + Math.max(0, widths.length - 1) * 2);
|
||||
return sum(widths) + Math.max(0, widths.length - 1) * 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -465,7 +482,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
}
|
||||
widths[widest]--;
|
||||
}
|
||||
return (widths);
|
||||
return widths;
|
||||
}
|
||||
|
||||
|
||||
@@ -480,7 +497,7 @@ final class PrintingLayout implements AutoCloseable
|
||||
{
|
||||
total += value;
|
||||
}
|
||||
return (total);
|
||||
return total;
|
||||
}
|
||||
|
||||
|
||||
@@ -497,6 +514,6 @@ final class PrintingLayout implements AutoCloseable
|
||||
cell = PrintingRecordDocuments.truncate(cell, widths[c]);
|
||||
out.append(c == 0 ? "" : " ").append(String.format("%-" + widths[c] + "s", cell));
|
||||
}
|
||||
return (out.toString().stripTrailing());
|
||||
return out.toString().stripTrailing();
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMeta
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -125,14 +126,14 @@ public final class PrintingListProcesses
|
||||
fields.add(PrintingRecordProcesses.printerField());
|
||||
fields.add(new QFieldMetaData(PrintingRecordProcesses.COPIES_FIELD, QFieldType.INTEGER).withLabel("Copies").withIsRequired(true).withDefaultValue(1));
|
||||
fields.add(new QFieldMetaData("pageSize", QFieldType.STRING).withLabel("Paper").withPossibleValueSourceName("printing_pageSize").withDefaultValue("LETTER"));
|
||||
return (new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules())
|
||||
return new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules())
|
||||
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("options").withLabel("Choose a printer").withFormFields(fields)
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)))
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(Execute.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Result")
|
||||
.withViewFields(List.of(new QFieldMetaData("result", QFieldType.STRING).withLabel("Result")))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))));
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM)));
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +169,7 @@ public final class PrintingListProcesses
|
||||
{
|
||||
throw new QException(table.getLabel() + " is not printable");
|
||||
}
|
||||
return (table);
|
||||
return table;
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +207,7 @@ public final class PrintingListProcesses
|
||||
filter.setLimit(filter.getLimit() == null ? 50 : MAX_ROWS);
|
||||
}
|
||||
}
|
||||
return (filter);
|
||||
return filter;
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +221,7 @@ public final class PrintingListProcesses
|
||||
if(given == null || given.isBlank())
|
||||
{
|
||||
table.getFields().values().stream().filter(PrintingRecordProcesses::printable).forEach(out::add);
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
for(String name : Arrays.stream(given.split(",")).map(String::trim).filter(n -> !n.isEmpty()).toList())
|
||||
{
|
||||
@@ -238,7 +239,7 @@ public final class PrintingListProcesses
|
||||
{
|
||||
throw new QException("None of the columns can be printed");
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +269,7 @@ public final class PrintingListProcesses
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -280,7 +281,7 @@ public final class PrintingListProcesses
|
||||
{
|
||||
if(filter.getOrderBys() == null)
|
||||
{
|
||||
return ("");
|
||||
return "";
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
for(var orderBy : filter.getOrderBys())
|
||||
@@ -289,7 +290,7 @@ public final class PrintingListProcesses
|
||||
QFieldMetaData field = name == null ? null : table.getFields().get(name);
|
||||
parts.add((field == null || field.getLabel() == null ? name : field.getLabel()) + (orderBy.getIsAscending() ? "" : " descending"));
|
||||
}
|
||||
return (String.join(", ", parts));
|
||||
return String.join(", ", parts);
|
||||
}
|
||||
|
||||
|
||||
@@ -297,19 +298,19 @@ public final class PrintingListProcesses
|
||||
/***************************************************************************
|
||||
** The grid's column widths ("12,30,8"), or null.
|
||||
***************************************************************************/
|
||||
static int[] widths(String text)
|
||||
static int @Nullable [] widths(@Nullable String text)
|
||||
{
|
||||
if(text == null || text.isBlank())
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Arrays.stream(text.split(",")).map(String::trim).mapToInt(Integer::parseInt).toArray());
|
||||
return Arrays.stream(text.split(",")).map(String::trim).mapToInt(Integer::parseInt).toArray();
|
||||
}
|
||||
catch(NumberFormatException exception)
|
||||
{
|
||||
return (null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +322,7 @@ public final class PrintingListProcesses
|
||||
static String orientation(RunBackendStepInput input)
|
||||
{
|
||||
String value = input.getValueString("orientation");
|
||||
return (value == null || value.isBlank() ? "AUTO" : value.toUpperCase(Locale.ROOT));
|
||||
return value == null || value.isBlank() ? "AUTO" : value.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
|
||||
@@ -329,8 +330,54 @@ 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)
|
||||
public static final class Rendered
|
||||
{
|
||||
private final String title;
|
||||
private final byte[] bytes;
|
||||
private final @Nullable String message;
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** Hold one finished document. The PDF array is handed over, not copied,
|
||||
** exactly as the former record component was.
|
||||
***********************************************************************/
|
||||
public Rendered(String title, byte[] bytes, @Nullable String message)
|
||||
{
|
||||
this.title = title;
|
||||
this.bytes = bytes;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** The document title.
|
||||
***********************************************************************/
|
||||
public String title()
|
||||
{
|
||||
return title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** The PDF bytes (the same array, not a copy).
|
||||
***********************************************************************/
|
||||
public byte[] bytes()
|
||||
{
|
||||
return bytes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** A note such as rows left out, or null when nothing was omitted.
|
||||
***********************************************************************/
|
||||
public @Nullable String message()
|
||||
{
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -350,9 +397,9 @@ public final class PrintingListProcesses
|
||||
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.sectionsPdf(title, subtitle, sections(sections), pageSize, landscape, chrome), null);
|
||||
}
|
||||
return (new Rendered(title, PrintingTextDocuments.pdf(title, subtitle, input.getValueString("text"), 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"));
|
||||
@@ -388,7 +435,7 @@ public final class PrintingListProcesses
|
||||
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));
|
||||
return new Rendered(title, bytes, message);
|
||||
}
|
||||
|
||||
|
||||
@@ -418,7 +465,7 @@ public final class PrintingListProcesses
|
||||
}
|
||||
out.add(new PrintingTextDocuments.Section(section.optString("title", ""), lines));
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
catch(org.json.JSONException exception)
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@ public class PrintingLocalAuthentication extends TableBasedAuthenticationModule
|
||||
}
|
||||
session.setPermissions(Set.of("printing_admin"));
|
||||
session.withSecurityKeyValue("printing_tenant", metadata.tenant);
|
||||
return (session);
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.ForbiddenResponse;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -117,7 +118,7 @@ public class PrintingLocalHost extends AbstractQQQApplication
|
||||
new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backendName).withService(service)
|
||||
.withSchedulerName(SCHEDULER)).produce(instance, "printing");
|
||||
mapPhysicalNames(instance);
|
||||
return (instance);
|
||||
return instance;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -191,7 +192,7 @@ public class PrintingLocalHost extends AbstractQQQApplication
|
||||
/***************************************************************************
|
||||
** Add native auth tables; an existing account is never implicitly reset.
|
||||
***************************************************************************/
|
||||
private void initializeAuthentication(String initialPassword) throws Exception
|
||||
private void initializeAuthentication(@Nullable String initialPassword) throws Exception
|
||||
{
|
||||
try(Connection connection = source.getConnection(); var statement = connection.createStatement())
|
||||
{
|
||||
|
||||
@@ -22,13 +22,14 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit local-host configuration. Never render credentials in diagnostics.
|
||||
*******************************************************************************/
|
||||
record PrintingLocalSettings(String jdbcUrl, String databaseUser, String databasePassword, String tenant,
|
||||
int port, String administratorToken, String username, String initialPassword)
|
||||
int port, String administratorToken, String username, @Nullable String initialPassword)
|
||||
{
|
||||
/***************************************************************************
|
||||
** Environment launch supports a durable local H2 file, never a remote bind.
|
||||
@@ -45,9 +46,9 @@ record PrintingLocalSettings(String jdbcUrl, String databaseUser, String databas
|
||||
{
|
||||
throw new IllegalArgumentException("QQQ_PRINT_PORT must be between 1 and 65535");
|
||||
}
|
||||
return (new PrintingLocalSettings(jdbc, environment.getOrDefault("QQQ_PRINT_DB_USER", "sa"),
|
||||
return new PrintingLocalSettings(jdbc, environment.getOrDefault("QQQ_PRINT_DB_USER", "sa"),
|
||||
environment.getOrDefault("QQQ_PRINT_DB_PASSWORD", ""), environment.getOrDefault("QQQ_PRINT_TENANT", "demo"), port,
|
||||
required(environment, "QQQ_PRINT_ADMIN_TOKEN"), required(environment, "QQQ_PRINT_UI_USERNAME"), environment.get("QQQ_PRINT_UI_INITIAL_PASSWORD")));
|
||||
required(environment, "QQQ_PRINT_ADMIN_TOKEN"), required(environment, "QQQ_PRINT_UI_USERNAME"), environment.get("QQQ_PRINT_UI_INITIAL_PASSWORD"));
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +78,7 @@ record PrintingLocalSettings(String jdbcUrl, String databaseUser, String databas
|
||||
{
|
||||
throw new IllegalArgumentException("Set " + name);
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +89,6 @@ record PrintingLocalSettings(String jdbcUrl, String databaseUser, String databas
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("PrintingLocalSettings[loopbackPort=" + port + ", credentials=redacted]");
|
||||
return "PrintingLocalSettings[loopbackPort=" + port + ", credentials=redacted]";
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ 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;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -165,7 +166,7 @@ public final class PrintingOperatorAuthorization
|
||||
/***************************************************************************
|
||||
** Page through authorized rows; hidden parent devices never leak through joins.
|
||||
***************************************************************************/
|
||||
public static JSONObject catalog(int limit, String after) throws QException
|
||||
public static JSONObject catalog(int limit, @Nullable String after) throws QException
|
||||
{
|
||||
CallContext caller = capture();
|
||||
if(limit < 1 || limit > 200 || (after != null && !after.matches("[A-Za-z0-9._-]{1,200}")))
|
||||
@@ -174,6 +175,7 @@ public final class PrintingOperatorAuthorization
|
||||
}
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK))
|
||||
.withCriteria(new QFilterCriteria("tenant_id", QCriteriaOperator.EQUALS, PrintingProcesses.currentService().getTenantId()))
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(limit + 1);
|
||||
if(after != null)
|
||||
@@ -311,18 +313,33 @@ public final class PrintingOperatorAuthorization
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Exact instance identity for the captured caller authority. A replaced
|
||||
** instance, session, user or service must fail even when value equality
|
||||
** would hold, so this is deliberately not equals().
|
||||
***************************************************************************/
|
||||
// Identity of the captured authority objects is the security contract; equals() would weaken it.
|
||||
@SuppressWarnings("ReferenceEquality")
|
||||
private static boolean same(@Nullable Object captured, @Nullable Object current)
|
||||
{
|
||||
return captured == current;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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<String, List<Serializable>> keys, Map<String, Serializable> variants)
|
||||
Map<String, List<Serializable>> keys, @Nullable Map<String, Serializable> 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()
|
||||
if(!same(instance, QContext.getQInstance()) || !same(session, QContext.getQSession()) || !same(user, session.getUser())
|
||||
|| !actor.equals(principal()) || !same(service, PrintingProcesses.currentService())
|
||||
|| !keys.equals(session.getSecurityKeyValues() == null ? Map.of() : session.getSecurityKeyValues())
|
||||
|| !Objects.equals(variants, session.getBackendVariants()))
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ 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;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -46,7 +47,7 @@ public class PrintingOperatorPermission extends UseOtherPermissionNameCustomPerm
|
||||
** Administrators retain submission access; operators receive no other role.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(QContext.getQSession() == null || (!QContext.getQSession().hasPermission("printing_operator") && !QContext.getQSession().hasPermission("printing_admin")))
|
||||
{
|
||||
|
||||
@@ -62,6 +62,7 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -88,6 +89,7 @@ public class PrintingProcesses implements BackendStep
|
||||
selected(instance, "printingTestSelectedZpl", "Print a test label", "Each selected ZPL printer will receive one test label after you continue.");
|
||||
selected(instance, "printingEnableSelectedPrinters", "Approve printers", "The selected printers will be approved for printing after you continue.");
|
||||
selected(instance, "printingDisableSelectedPrinters", "Disable printers", "The selected printers will stop receiving new print jobs after you continue.");
|
||||
selected(instance, "printingRemoveSelectedPrinters", "Remove printers", "The selected printers will be removed from Printers, the operator catalog and their connectors after you continue. Job history is kept; sharing a queue again from its device brings it back awaiting approval.");
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +104,18 @@ public class PrintingProcesses implements BackendStep
|
||||
for(TestPrintProfile profile : TestPrintProfile.values())
|
||||
{
|
||||
QPossibleValue<String> option = new QPossibleValue<>(profile.name(), profile.getLabel());
|
||||
pdf.add(option);
|
||||
if(profile.getDpi() > 0 && profile.getHeightInches() != 6)
|
||||
{
|
||||
// PDF is vector, so the printer resolution is irrelevant: one friendly choice per short stock.
|
||||
if(profile.getDpi() == 300)
|
||||
{
|
||||
pdf.add(new QPossibleValue<>(profile.name(), "4 x " + profile.getHeightInches() + " in label"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
pdf.add(option);
|
||||
}
|
||||
if(profile.getDpi() > 0)
|
||||
{
|
||||
zpl.add(option);
|
||||
@@ -119,9 +132,9 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData profileField(boolean pdf)
|
||||
{
|
||||
return (field("testProfile", "Test stock / printer resolution (match every selected printer)")
|
||||
return field("testProfile", "Test stock / printer resolution (match every selected printer)")
|
||||
.withPossibleValueSourceName(pdf ? "printing_testProfilePdf" : "printing_testProfileZpl")
|
||||
.withDefaultValue(pdf ? TestPrintProfile.LETTER.name() : TestPrintProfile.LABEL_4X6_203.name()));
|
||||
.withDefaultValue(pdf ? TestPrintProfile.LETTER.name() : TestPrintProfile.LABEL_4X6_203.name());
|
||||
}
|
||||
|
||||
|
||||
@@ -131,8 +144,8 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
public static QPermissionRules permissionRules()
|
||||
{
|
||||
return (new QPermissionRules().withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withCustomPermissionChecker(new QCodeReference(PrintingAdminPermission.class)));
|
||||
return new QPermissionRules().withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withCustomPermissionChecker(new QCodeReference(PrintingAdminPermission.class));
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +155,8 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData deviceField()
|
||||
{
|
||||
return (field("deviceId", "Print device").withPossibleValueSourceName(PrintingTables.DEVICE));
|
||||
return field("deviceId", "Print device").withPossibleValueSourceName(PrintingTables.DEVICE)
|
||||
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK)));
|
||||
}
|
||||
|
||||
|
||||
@@ -150,17 +164,18 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Queue row IDs resolve to the agent's printer ID only after authorization.
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData printerField(String format)
|
||||
private static QFieldMetaData printerField(@Nullable String format)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, "${input.deviceId}"));
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, "${input.deviceId}"))
|
||||
.withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK));
|
||||
if(format != null)
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1));
|
||||
filter.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1));
|
||||
filter.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, format));
|
||||
}
|
||||
return (field("printerId", "Printer").withPossibleValueSourceName(PrintingTables.PRINTER)
|
||||
.withPossibleValueSourceFilter(filter).withPossibleValueSourceDependsOn(List.of("deviceId")));
|
||||
return field("printerId", "Printer").withPossibleValueSourceName(PrintingTables.PRINTER)
|
||||
.withPossibleValueSourceFilter(filter).withPossibleValueSourceDependsOn(List.of("deviceId"));
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +232,7 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData field(String name, String label)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withIsRequired(true).withMaxLength("deviceName".equals(name) ? 1024 : 200));
|
||||
return new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withIsRequired(true).withMaxLength("deviceName".equals(name) ? 1024 : 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +252,7 @@ public class PrintingProcesses implements BackendStep
|
||||
{
|
||||
throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("The current session cannot manage this printing tenant");
|
||||
}
|
||||
return (config.getService());
|
||||
return config.getService();
|
||||
}
|
||||
}
|
||||
throw new QException("Printing QBit is not configured");
|
||||
@@ -289,7 +304,7 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Re-read records with the caller's permissions; form values are not trusted.
|
||||
***************************************************************************/
|
||||
static QRecord resolvePrinter(String id, String deviceId) throws QException
|
||||
static QRecord resolvePrinter(String id, @Nullable String deviceId) throws QException
|
||||
{
|
||||
GetInput get = new GetInput();
|
||||
get.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
@@ -300,7 +315,7 @@ public class PrintingProcesses implements BackendStep
|
||||
{
|
||||
throw new QException("Choose an accessible printer belonging to the selected device");
|
||||
}
|
||||
return (printer);
|
||||
return printer;
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +325,7 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
static String submitTest(QRecord printer, boolean pdf) throws QException
|
||||
{
|
||||
return (submitTest(printer, pdf, null));
|
||||
return submitTest(printer, pdf, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -318,9 +333,9 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** All built-in document entrypoints share profile validation and generation.
|
||||
***************************************************************************/
|
||||
static String submitTest(QRecord printer, boolean pdf, String profileName) throws QException
|
||||
static String submitTest(QRecord printer, boolean pdf, @Nullable String profileName) throws QException
|
||||
{
|
||||
return (submitTest(printer, pdf, profileName, "test:" + UUID.randomUUID()));
|
||||
return submitTest(printer, pdf, profileName, "test:" + UUID.randomUUID());
|
||||
}
|
||||
|
||||
|
||||
@@ -335,7 +350,7 @@ public class PrintingProcesses implements BackendStep
|
||||
{
|
||||
throw new QException("Restart the test-print action to obtain a safe request identity");
|
||||
}
|
||||
return ("native-test:" + request + ":" + printer.getValueString("id"));
|
||||
return "native-test:" + request + ":" + printer.getValueString("id");
|
||||
}
|
||||
|
||||
|
||||
@@ -343,15 +358,21 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Generate one immutable document and deduplicate replays of a process load.
|
||||
***************************************************************************/
|
||||
private static String submitTest(QRecord printer, boolean pdf, String profileName, String key) throws QException
|
||||
private static String submitTest(QRecord printer, boolean pdf, @Nullable String profileName, String key) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
TestPrintProfile profile = TestPrintProfile.resolve(pdf ? "PDF" : "ZPL", profileName);
|
||||
byte[] bytes = pdf ? PrintingTestDocuments.pdf(profile) : PrintingTestDocuments.zpl(profile);
|
||||
JSONObject job = currentService().submit(new JSONObject().put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id"))
|
||||
.put("title", "QQQ print test").put("format", pdf ? "PDF" : "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key));
|
||||
return (job.getString("jobId"));
|
||||
JSONObject request = new JSONObject().put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id"))
|
||||
.put("title", "QQQ print test").put("format", pdf ? "PDF" : "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key);
|
||||
JSONObject options = profile.jobOptions(pdf ? "PDF" : "ZPL");
|
||||
if(options != null)
|
||||
{
|
||||
request.put("options", options);
|
||||
}
|
||||
JSONObject job = currentService().submit(request);
|
||||
return job.getString("jobId");
|
||||
}
|
||||
catch(java.sql.SQLException | PrintingException exception)
|
||||
{
|
||||
@@ -476,10 +497,12 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Standard QQQ review summary.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready)));
|
||||
return new ArrayList<>(List.of(ready));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +535,7 @@ public class PrintingProcesses implements BackendStep
|
||||
case "printingTestSelectedPdf" -> submitTest(printer, true, input.getValueString("testProfile"), testRequestKey(input, printer));
|
||||
case "printingTestSelectedZpl" -> submitTest(printer, false, input.getValueString("testProfile"), testRequestKey(input, printer));
|
||||
case "printingEnableSelectedPrinters", "printingDisableSelectedPrinters" -> currentService().enablePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id"), "printingEnableSelectedPrinters".equals(input.getProcessName()));
|
||||
case "printingRemoveSelectedPrinters" -> currentService().removePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id"));
|
||||
default -> throw new QException("Unknown selected printing action");
|
||||
}
|
||||
done.incrementCountAndAddPrimaryKey(printer.getValue("id"));
|
||||
@@ -529,10 +553,12 @@ public class PrintingProcesses implements BackendStep
|
||||
/***************************************************************************
|
||||
** Standard QQQ result summary links back to the selected queues.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(done)));
|
||||
return new ArrayList<>(List.of(done));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +569,7 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
static byte[] testPdf() throws QException
|
||||
{
|
||||
return (PrintingTestDocuments.pdf());
|
||||
return PrintingTestDocuments.pdf();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -29,28 +30,35 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
*******************************************************************************/
|
||||
public class PrintingQBitConfig implements QBitConfig
|
||||
{
|
||||
private String backendName;
|
||||
private transient PrintingService service;
|
||||
private @Nullable String backendName;
|
||||
private transient @Nullable PrintingService service;
|
||||
private PrintingAdmissionLimits admissionLimits = PrintingAdmissionLimits.defaults();
|
||||
private PrintingRetentionPolicy retentionPolicy = new PrintingRetentionPolicy(true, 365, 200);
|
||||
private String storageTableName;
|
||||
private String schedulerName;
|
||||
private String facilityPossibleValueSourceName;
|
||||
private transient RetainedPrintSourceAuthority retainedSourceAuthority;
|
||||
private @Nullable String storageTableName;
|
||||
private @Nullable String schedulerName;
|
||||
private @Nullable String facilityPossibleValueSourceName;
|
||||
private transient @Nullable RetainedPrintSourceAuthority retainedSourceAuthority;
|
||||
|
||||
|
||||
/** Explicit trusted host integration; absent means retained submissions are disabled. */
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit trusted host integration; absent means retained submissions are disabled.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withRetainedSourceAuthority(RetainedPrintSourceAuthority value)
|
||||
{
|
||||
retainedSourceAuthority = java.util.Objects.requireNonNull(value);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/** Never serialized into client metadata or supplied by a request. */
|
||||
public RetainedPrintSourceAuthority getRetainedSourceAuthority()
|
||||
|
||||
/***************************************************************************
|
||||
** Never serialized into client metadata or supplied by a request; null
|
||||
** means the host did not enable retained submissions.
|
||||
***************************************************************************/
|
||||
public @Nullable RetainedPrintSourceAuthority getRetainedSourceAuthority()
|
||||
{
|
||||
return (retainedSourceAuthority);
|
||||
return retainedSourceAuthority;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +68,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
***************************************************************************/
|
||||
public PrintingAdmissionLimits getAdmissionLimits()
|
||||
{
|
||||
return (admissionLimits);
|
||||
return admissionLimits;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +79,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
public PrintingQBitConfig withAdmissionLimits(PrintingAdmissionLimits value)
|
||||
{
|
||||
admissionLimits = java.util.Objects.requireNonNull(value);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,10 +87,10 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Reuse the host's facility selector and its existing record security.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withFacilityPossibleValueSourceName(String value)
|
||||
public PrintingQBitConfig withFacilityPossibleValueSourceName(@Nullable String value)
|
||||
{
|
||||
facilityPossibleValueSourceName = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,9 +98,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Missing configuration uses the named standalone default facility.
|
||||
***************************************************************************/
|
||||
public String getFacilityPossibleValueSourceName()
|
||||
public @Nullable String getFacilityPossibleValueSourceName()
|
||||
{
|
||||
return (facilityPossibleValueSourceName);
|
||||
return facilityPossibleValueSourceName;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,10 +108,10 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Deployment-owned dedicated S3 table; operators cannot retarget old files.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withStorageTableName(String value)
|
||||
public PrintingQBitConfig withStorageTableName(@Nullable String value)
|
||||
{
|
||||
storageTableName = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,9 +119,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Existing storage metadata owns bucket, endpoint and credentials.
|
||||
***************************************************************************/
|
||||
public String getStorageTableName()
|
||||
public @Nullable String getStorageTableName()
|
||||
{
|
||||
return (storageTableName);
|
||||
return storageTableName;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,10 +129,10 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Bind bounded five-minute maintenance to a scheduler already configured by the host.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withSchedulerName(String value)
|
||||
public PrintingQBitConfig withSchedulerName(@Nullable String value)
|
||||
{
|
||||
schedulerName = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,9 +140,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Missing host scheduler means no automatic background maintenance.
|
||||
***************************************************************************/
|
||||
public String getSchedulerName()
|
||||
public @Nullable String getSchedulerName()
|
||||
{
|
||||
return (schedulerName);
|
||||
return schedulerName;
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +153,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
public PrintingQBitConfig withRetentionPolicy(PrintingRetentionPolicy value)
|
||||
{
|
||||
retentionPolicy = java.util.Objects.requireNonNull(value);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +163,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
***************************************************************************/
|
||||
public PrintingRetentionPolicy getRetentionPolicy()
|
||||
{
|
||||
return (retentionPolicy);
|
||||
return retentionPolicy;
|
||||
}
|
||||
|
||||
|
||||
@@ -163,10 +171,10 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Bind metadata reads to the host's existing RDBMS backend.
|
||||
***************************************************************************/
|
||||
public PrintingQBitConfig withBackendName(String value)
|
||||
public PrintingQBitConfig withBackendName(@Nullable String value)
|
||||
{
|
||||
backendName = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +185,7 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
public PrintingQBitConfig withService(PrintingService value)
|
||||
{
|
||||
service = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,9 +193,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Return the table backend name.
|
||||
***************************************************************************/
|
||||
public String getBackendName()
|
||||
public @Nullable String getBackendName()
|
||||
{
|
||||
return (backendName);
|
||||
return backendName;
|
||||
}
|
||||
|
||||
|
||||
@@ -195,9 +203,9 @@ public class PrintingQBitConfig implements QBitConfig
|
||||
/***************************************************************************
|
||||
** Return the printing runtime without serializing credentials or connections.
|
||||
***************************************************************************/
|
||||
public PrintingService getService()
|
||||
public @Nullable PrintingService getService()
|
||||
{
|
||||
return (service);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMe
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.uber.nullaway.annotations.RequiresNonNull;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -61,7 +63,7 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDe
|
||||
*******************************************************************************/
|
||||
public class PrintingQBitProducer implements QBitProducer
|
||||
{
|
||||
private PrintingQBitConfig config;
|
||||
private @Nullable PrintingQBitConfig config;
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +73,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
public PrintingQBitProducer withConfig(PrintingQBitConfig value)
|
||||
{
|
||||
config = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +96,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
config.getService().configureStorage(instance, config.getStorageTableName());
|
||||
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion(version()).withNamespace("printing").withConfig(config));
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("printing_tenant"));
|
||||
QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at"));
|
||||
QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at", "removed_at"));
|
||||
devices.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Enabled").withPossibleValueSourceName("printing_enabled"));
|
||||
devices.withField(new QFieldMetaData("dispatch_paused", QFieldType.INTEGER).withLabel("Printing paused").withPossibleValueSourceName("printing_paused"));
|
||||
devices.withField(new QFieldMetaData("connection_state", QFieldType.STRING).withLabel("Requested connection").withPossibleValueSourceName("printing_connectionState"));
|
||||
@@ -108,7 +110,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
devices.withVirtualField(new QVirtualFieldMetaData("control_status", QFieldType.STRING).withLabel("Control application").withPossibleValueSourceName("printing_controlApplication").withIsQuerySelectable(true));
|
||||
devices.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PrintingDeviceStatus.class));
|
||||
devices.getField("facility_id").withLabel("Facility").withPossibleValueSourceName(PrintingFacilities.register(instance, config));
|
||||
QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at"));
|
||||
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", "removed_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"));
|
||||
@@ -223,7 +225,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
private static String version()
|
||||
{
|
||||
String version = PrintingQBitProducer.class.getPackage().getImplementationVersion();
|
||||
return (version == null || version.isBlank() ? "development" : version);
|
||||
return version == null || version.isBlank() ? "development" : version;
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +233,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
/***************************************************************************
|
||||
** Produce read-only table metadata protected by tenant and administrator keys.
|
||||
***************************************************************************/
|
||||
@RequiresNonNull("config")
|
||||
private QTableMetaData table(String name, String label, List<String> columns)
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName(name).withLabel(label).withBackendName(config.getBackendName()).withPrimaryKeyField("id")
|
||||
@@ -243,7 +246,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
{
|
||||
table.withField(new QFieldMetaData(column, QFieldType.STRING).withLabel(column.substring(0, 1).toUpperCase(java.util.Locale.ROOT) + column.substring(1).replace('_', ' ')));
|
||||
}
|
||||
return (table);
|
||||
return table;
|
||||
}
|
||||
|
||||
|
||||
@@ -323,7 +326,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("nextUi");
|
||||
return "nextUi";
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +337,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
@Override
|
||||
public boolean includeInFullFrontendMetaData()
|
||||
{
|
||||
return (true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -344,7 +347,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
***************************************************************************/
|
||||
public List<String> getDefaultColumns()
|
||||
{
|
||||
return (defaultColumns);
|
||||
return defaultColumns;
|
||||
}
|
||||
|
||||
|
||||
@@ -355,7 +358,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
@Override
|
||||
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone)
|
||||
{
|
||||
return (clone);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -41,7 +42,7 @@ public class PrintingReadPermission extends PrintingAdminPermission
|
||||
** Require explicit printing administrator access and reject generic writes.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table
|
||||
&& (PrintingTables.DEVICE.equals(table.getName()) || PrintingTables.PRINTER.equals(table.getName())))
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/** Host-owned commit notification bridge. Callbacks only schedule bounded work. */
|
||||
@@ -47,7 +48,7 @@ public class PrintingRealtimeBus
|
||||
public boolean owns(String tenant, String device, String generation)
|
||||
{
|
||||
Owner current = owners.get(tenant + ":" + device);
|
||||
return (current != null && current.generation().equals(generation));
|
||||
return current != null && current.generation().equals(generation);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,12 +69,12 @@ public class PrintingRealtimeBus
|
||||
public AutoCloseable subscribe(BiConsumer<String, String> listener)
|
||||
{
|
||||
listeners.add(listener);
|
||||
return (() -> listeners.remove(listener));
|
||||
return () -> listeners.remove(listener);
|
||||
}
|
||||
|
||||
|
||||
/** Publish only after commit. Shared-host implementations must preserve this nonblocking contract. */
|
||||
public void publish(String tenant, String device)
|
||||
public void publish(String tenant, @Nullable String device)
|
||||
{
|
||||
for(BiConsumer<String, String> listener : listeners)
|
||||
{
|
||||
|
||||
@@ -46,7 +46,7 @@ public final class PrintingRealtimePolicy
|
||||
{
|
||||
JSONObject values = new JSONObject();
|
||||
FIELDS.forEach((name, range) -> values.put(name, range[0]));
|
||||
return (new PrintingRealtimePolicy(values));
|
||||
return new PrintingRealtimePolicy(values);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,21 +86,21 @@ public final class PrintingRealtimePolicy
|
||||
policy.atLeast("downloadTimeoutMillis", Math.max(policy.get("downloadConnectTimeoutMillis"), policy.get("downloadIdleTimeoutMillis")));
|
||||
policy.atLeast("heartbeatTimeoutSeconds", 2 * policy.get("heartbeatIntervalSeconds"));
|
||||
policy.atLeast("reconnectMaxDelayMillis", policy.get("reconnectMinDelayMillis"));
|
||||
return (policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
|
||||
/** Read a validated named setting without exposing mutable JSON. */
|
||||
public long get(String name)
|
||||
{
|
||||
return (values.getLong(name));
|
||||
return values.getLong(name);
|
||||
}
|
||||
|
||||
|
||||
/** Wire settings include a server-generated durable revision. */
|
||||
public JSONObject toJson(long revision)
|
||||
{
|
||||
return (new JSONObject(values.toString()).put("revision", revision));
|
||||
return new JSONObject(values.toString()).put("revision", revision);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ public final class PrintingRealtimePolicy
|
||||
{
|
||||
throw new PrintingException(400, "Only PDF and ZPL are supported");
|
||||
}
|
||||
return (get("PDF".equals(format) ? "maxPdfBytes" : "maxZplBytes"));
|
||||
return get("PDF".equals(format) ? "maxPdfBytes" : "maxZplBytes");
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,6 @@ public final class PrintingRealtimePolicy
|
||||
fields.put("reconnectMinDelayMillis", new long[] {1000, 100, 60000});
|
||||
fields.put("reconnectMaxDelayMillis", new long[] {30000, 1000, 300000});
|
||||
fields.put("reconnectJitterPercent", new long[] {20, 0, 50});
|
||||
return (java.util.Collections.unmodifiableMap(fields));
|
||||
return java.util.Collections.unmodifiableMap(fields);
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new BigDecimal(value.toString()).multiply(BigDecimal.valueOf(setting.unit())).longValueExact());
|
||||
return new BigDecimal(value.toString()).multiply(BigDecimal.valueOf(setting.unit())).longValueExact();
|
||||
}
|
||||
catch(NumberFormatException | ArithmeticException exception)
|
||||
{
|
||||
@@ -209,7 +209,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static List<List<Setting>> groups(boolean admission)
|
||||
{
|
||||
return (admission ? List.of(ADMISSION_COUNTS, ADMISSION_STORAGE) : List.of(DOCUMENTS, TRANSFERS, CONNECTION, CONTROL));
|
||||
return admission ? List.of(ADMISSION_COUNTS, ADMISSION_STORAGE) : List.of(DOCUMENTS, TRANSFERS, CONNECTION, CONTROL);
|
||||
}
|
||||
|
||||
|
||||
@@ -219,10 +219,10 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFrontendStepMetaData form(String name, String label, List<Setting> settings)
|
||||
{
|
||||
return (new QFrontendStepMetaData().withName(name).withLabel(label)
|
||||
return new QFrontendStepMetaData().withName(name).withLabel(label)
|
||||
.withFormFields(settings.stream().map(setting -> new QFieldMetaData(setting.input(), setting.unit() == 1 ? QFieldType.INTEGER : QFieldType.DECIMAL)
|
||||
.withLabel(setting.label()).withIsRequired(true)).toList())
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QBackendStepMetaData backend(String name)
|
||||
{
|
||||
return (new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRealtimeProcesses.class)));
|
||||
return new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRealtimeProcesses.class));
|
||||
}
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData text(String name, String label)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label));
|
||||
return new QFieldMetaData(name, QFieldType.STRING).withLabel(label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsM
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
|
||||
import com.uber.nullaway.annotations.MonotonicNonNull;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpResponseException;
|
||||
@@ -50,6 +51,7 @@ import io.javalin.websocket.WsContext;
|
||||
import org.eclipse.jetty.websocket.api.Callback;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/** Current-user authenticated outbound channels. SQL is authoritative; notifications are only wakeups. */
|
||||
@@ -61,7 +63,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
private final Map<String, Channel> channels = new ConcurrentHashMap<>();
|
||||
private final ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final AutoCloseable subscription;
|
||||
private QInstance instance;
|
||||
private @MonotonicNonNull QInstance instance;
|
||||
|
||||
|
||||
/** Register this provider alongside the host's ordinary native QQQ endpoints. */
|
||||
@@ -143,10 +145,12 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
/** Resolve only the current socket generation. */
|
||||
|
||||
|
||||
private Channel current(WsContext context)
|
||||
// Identity is the socket-generation fence; value equality must never admit a replaced socket.
|
||||
@SuppressWarnings("ReferenceEquality")
|
||||
private @Nullable Channel current(WsContext context)
|
||||
{
|
||||
Channel channel = channels.get(context.header(PrintingAgentAuthorization.DEVICE_HEADER));
|
||||
return (channel != null && channel.socket.session == context.session ? channel : null);
|
||||
return channel != null && channel.socket.session == context.session ? channel : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +215,22 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Revalidation requires the host instance already used during upgrade.
|
||||
** Check at use, so route registration ordering remains unchanged.
|
||||
***************************************************************************/
|
||||
private QInstance configuredInstance()
|
||||
{
|
||||
QInstance configured = instance;
|
||||
if(configured == null)
|
||||
{
|
||||
throw new IllegalStateException("The realtime route provider has no configured QQQ instance");
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Only transport credentials survive upgrade; user roles and session authority are never cached. */
|
||||
private static final class Credential
|
||||
{
|
||||
@@ -233,7 +253,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("Printing realtime credential [redacted]");
|
||||
return "Printing realtime credential [redacted]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +310,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
private final Credential credential;
|
||||
private final ArrayDeque<String> mailbox = new ArrayDeque<>();
|
||||
private PrintingRealtimePolicy policy = PrintingRealtimePolicy.defaults();
|
||||
private JSONObject hello;
|
||||
private @MonotonicNonNull JSONObject hello;
|
||||
private boolean running;
|
||||
private boolean dirty;
|
||||
private boolean stopped;
|
||||
@@ -299,6 +319,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
private long appliedSettings = -1;
|
||||
private long sentState = -1;
|
||||
private long sentSettings = -1;
|
||||
private @Nullable String sentPrinters;
|
||||
|
||||
|
||||
/** Bind one mailbox to its authenticated socket. */
|
||||
@@ -315,6 +336,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
/** Re-enter the configured native authentication module for every operation after HTTP upgrade. */
|
||||
private void authorizeCurrent() throws Exception
|
||||
{
|
||||
QInstance instance = configuredInstance();
|
||||
QContext.init(instance, null);
|
||||
var module = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication());
|
||||
var session = module.createSession(instance, Map.of(credential.cookieName, credential.cookie));
|
||||
@@ -339,8 +361,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
|
||||
private synchronized boolean ready(long revision)
|
||||
{
|
||||
return (!stopped && socket.session.isOpen() && service.getRealtimeBus().owns(service.getTenantId(), device, generation)
|
||||
&& hello != null && appliedState == sentState && appliedSettings == sentSettings && appliedSettings == revision);
|
||||
return !stopped && socket.session.isOpen() && service.getRealtimeBus().owns(service.getTenantId(), device, generation)
|
||||
&& hello != null && appliedState == sentState && appliedSettings == sentSettings && appliedSettings == revision;
|
||||
}
|
||||
|
||||
|
||||
@@ -418,7 +440,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
authorizeCurrent();
|
||||
if(frame == null)
|
||||
{
|
||||
snapshot(false);
|
||||
snapshot(false, helloPayload());
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -461,13 +483,13 @@ 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() > 4
|
||||
|| !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").containsAll(payload.getJSONArray("capabilities").toList()))
|
||||
|| payload.getJSONArray("capabilities").length() > 5
|
||||
|| !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1").containsAll(payload.getJSONArray("capabilities").toList()))
|
||||
{
|
||||
throw new PrintingException(409, "Realtime capability is required");
|
||||
}
|
||||
hello = payload;
|
||||
snapshot(true);
|
||||
snapshot(true, payload);
|
||||
return;
|
||||
}
|
||||
case "STATE_APPLIED" ->
|
||||
@@ -478,7 +500,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
{
|
||||
throw new PrintingException(409, "Only the current delivered state and settings may be acknowledged");
|
||||
}
|
||||
service.applyRealtimeState(device, controlRequest(state), settings);
|
||||
service.applyRealtimeState(device, controlRequest(helloPayload(), state), settings);
|
||||
synchronized(this)
|
||||
{
|
||||
appliedState = state;
|
||||
@@ -502,7 +524,13 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
changed();
|
||||
}
|
||||
case "HEARTBEAT" -> service.heartbeat(device, payload);
|
||||
case "INVENTORY" -> service.inventory(device, payload.getJSONArray("printers"), PrintingAgentAuthorization.inventory(device));
|
||||
case "INVENTORY" ->
|
||||
{
|
||||
service.inventory(device, payload.getJSONArray("printers"), PrintingAgentAuthorization.inventory(device));
|
||||
send("ACK", new JSONObject().put("acknowledgedMessageId", messageId));
|
||||
printers(true, true);
|
||||
return;
|
||||
}
|
||||
case "PING" ->
|
||||
{
|
||||
service.realtimeContact(device);
|
||||
@@ -557,32 +585,48 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
{
|
||||
stop("Realtime operation rejected");
|
||||
}
|
||||
return (connectionError);
|
||||
return connectionError;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Pass a validated immutable HELLO reference into subsequent operations.
|
||||
** Pre-HELLO operations keep the protocol's existing rejection contract.
|
||||
***************************************************************************/
|
||||
private JSONObject helloPayload()
|
||||
{
|
||||
JSONObject payload = hello;
|
||||
if(payload == null)
|
||||
{
|
||||
throw new PrintingException(409, "HELLO is required first");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Reuse the durable control protocol with advertised supported operations. */
|
||||
|
||||
|
||||
private JSONObject controlRequest(long state)
|
||||
private JSONObject controlRequest(JSONObject hello, long state)
|
||||
{
|
||||
JSONArray capabilities = new JSONArray();
|
||||
for(Object value : hello.getJSONArray("capabilities"))
|
||||
{
|
||||
if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").contains(value))
|
||||
if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1").contains(value))
|
||||
{
|
||||
capabilities.put(value);
|
||||
}
|
||||
}
|
||||
return (new JSONObject().put("controlVersion", 1).put("agentVersion", hello.getString("agentVersion"))
|
||||
.put("capabilities", capabilities).put("lastAppliedRevision", state));
|
||||
return new JSONObject().put("controlVersion", 1).put("agentVersion", hello.getString("agentVersion"))
|
||||
.put("capabilities", capabilities).put("lastAppliedRevision", state);
|
||||
}
|
||||
|
||||
|
||||
/** Reconstruct current state and pending commands after registration or commit. */
|
||||
|
||||
|
||||
private void snapshot(boolean initial) throws Exception
|
||||
private void snapshot(boolean initial, JSONObject hello) throws Exception
|
||||
{
|
||||
JSONObject settings = service.getRealtimeSettings();
|
||||
if(initial && integer(hello, "lastSettingsRevision") > settings.getLong("revision"))
|
||||
@@ -595,7 +639,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
policy.requireClientLimits(hello.getJSONObject("limits"));
|
||||
socket.session.setMaxTextMessageSize(policy.get("maxFrameBytes"));
|
||||
socket.session.setIdleTimeout(Duration.ofSeconds(policy.get("heartbeatTimeoutSeconds")));
|
||||
JSONObject state = service.deviceControlPoll(device, controlRequest(initial ? integer(hello, "lastAppliedRevision") : Math.max(0, appliedState)));
|
||||
JSONObject state = service.deviceControlPoll(device, controlRequest(hello, initial ? integer(hello, "lastAppliedRevision") : Math.max(0, appliedState)));
|
||||
PrintingControlAuthorization.table(PrintingTables.COMMAND);
|
||||
JSONObject desired = state.getJSONObject("desiredState");
|
||||
long previousState;
|
||||
@@ -623,6 +667,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
send("DESIRED_STATE", desired);
|
||||
}
|
||||
}
|
||||
printers(!initial, false);
|
||||
if(!ready(sentSettings))
|
||||
{
|
||||
return;
|
||||
@@ -637,6 +682,34 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Push the complete approval state of the device's current inventory to a
|
||||
** connection that advertised printer-approval-v1. An accepted INVENTORY
|
||||
** always sends it; a commit wakeup sends it only when approval changed since
|
||||
** the last frame, and the initial snapshot only records the baseline so an
|
||||
** unrelated wakeup never produces a frame. Other connections never receive it.
|
||||
***************************************************************************/
|
||||
private void printers(boolean push, boolean always) throws Exception
|
||||
{
|
||||
if(!helloPayload().getJSONArray("capabilities").toList().contains("printer-approval-v1"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
JSONObject payload = new JSONObject().put("printers", service.printerApprovals(device));
|
||||
String current = payload.toString();
|
||||
boolean changed;
|
||||
synchronized(this)
|
||||
{
|
||||
changed = always || (sentPrinters != null && !current.equals(sentPrinters));
|
||||
sentPrinters = current;
|
||||
}
|
||||
if(push && changed)
|
||||
{
|
||||
send("PRINTERS", payload);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Revalidate authority and await one bounded asynchronous socket write. */
|
||||
|
||||
|
||||
@@ -689,6 +762,6 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
{
|
||||
throw new PrintingException(400, "Expected nonnegative integer " + name);
|
||||
}
|
||||
return (number.longValue());
|
||||
return number.longValue();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -76,7 +77,7 @@ public final class PrintingRecordDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, Map<String, String> fields) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, List.of(new Section("", fields)), List.of()));
|
||||
return pdf(title, subtitle, List.of(new Section("", fields)), List.of());
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +87,7 @@ public final class PrintingRecordDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, sections, children, null));
|
||||
return pdf(title, subtitle, sections, children, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +95,7 @@ public final class PrintingRecordDocuments
|
||||
/***************************************************************************
|
||||
** 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
|
||||
public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle, chrome))
|
||||
{
|
||||
@@ -143,7 +144,7 @@ public final class PrintingRecordDocuments
|
||||
layout.line("... and " + (child.total() - child.rows().size()) + " more", CHILD_SIZE);
|
||||
}
|
||||
}
|
||||
return (layout.finish());
|
||||
return layout.finish();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -180,7 +181,7 @@ public final class PrintingRecordDocuments
|
||||
}
|
||||
lines.add(rest);
|
||||
}
|
||||
return (lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +192,6 @@ public final class PrintingRecordDocuments
|
||||
static String truncate(String text, int length)
|
||||
{
|
||||
String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' ');
|
||||
return (value.length() <= length ? value : value.substring(0, Math.max(0, length - 1)) + "~");
|
||||
return value.length() <= length ? value : value.substring(0, Math.max(0, length - 1)) + "~";
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -99,7 +100,7 @@ public final class PrintingRecordProcesses
|
||||
***************************************************************************/
|
||||
public static String processName(String tableName)
|
||||
{
|
||||
return (PREFIX + tableName);
|
||||
return PREFIX + tableName;
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +155,7 @@ public final class PrintingRecordProcesses
|
||||
register(instance, table.getName());
|
||||
added.add(table.getName());
|
||||
}
|
||||
return (added);
|
||||
return added;
|
||||
}
|
||||
|
||||
|
||||
@@ -166,9 +167,10 @@ public final class PrintingRecordProcesses
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK))
|
||||
.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, "PDF"));
|
||||
return (new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter));
|
||||
return new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +191,7 @@ public final class PrintingRecordProcesses
|
||||
{
|
||||
throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its device, approved, and support PDF");
|
||||
}
|
||||
return (printer);
|
||||
return printer;
|
||||
}
|
||||
|
||||
|
||||
@@ -202,13 +204,13 @@ public final class PrintingRecordProcesses
|
||||
Integer copies = input.getValueInteger(COPIES_FIELD);
|
||||
if(copies == null)
|
||||
{
|
||||
return (1);
|
||||
return 1;
|
||||
}
|
||||
if(copies < 1 || copies > 100)
|
||||
{
|
||||
throw new QException("Copies must be between 1 and 100");
|
||||
}
|
||||
return (copies);
|
||||
return copies;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,11 +218,11 @@ 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, PrintingChrome chrome) throws QException
|
||||
static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request, @Nullable 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")));
|
||||
return queue(printer, rendered.title(), rendered.bytes(), copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id"));
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +230,7 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** The record sheet of one record (read with the caller's permissions).
|
||||
***************************************************************************/
|
||||
static PrintingListProcesses.Rendered render(QTableMetaData table, Serializable primaryKey, PrintingChrome chrome) throws QException
|
||||
static PrintingListProcesses.Rendered render(QTableMetaData table, Serializable primaryKey, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
GetInput get = new GetInput();
|
||||
get.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
@@ -246,7 +248,7 @@ public final class PrintingRecordProcesses
|
||||
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));
|
||||
return new PrintingListProcesses.Rendered(tableLabel + ": " + label, bytes, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +259,7 @@ public final class PrintingRecordProcesses
|
||||
static String printedBy()
|
||||
{
|
||||
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getFullName();
|
||||
return ("printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user));
|
||||
return "printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user);
|
||||
}
|
||||
|
||||
|
||||
@@ -265,11 +267,11 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** Display text of one field of a record (display value, else raw value).
|
||||
***************************************************************************/
|
||||
static String text(QRecord record, QFieldMetaData field)
|
||||
static @Nullable String text(QRecord record, QFieldMetaData field)
|
||||
{
|
||||
String display = record.getDisplayValue(field.getName());
|
||||
Serializable raw = record.getValue(field.getName());
|
||||
return (display != null ? display : raw == null ? null : String.valueOf(raw));
|
||||
return display != null ? display : raw == null ? null : String.valueOf(raw);
|
||||
}
|
||||
|
||||
|
||||
@@ -277,9 +279,9 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** Whether a field belongs on paper: visible and not binary.
|
||||
***************************************************************************/
|
||||
static boolean printable(QFieldMetaData field)
|
||||
static boolean printable(@Nullable QFieldMetaData field)
|
||||
{
|
||||
return (field != null && !Boolean.TRUE.equals(field.getIsHidden()) && field.getType() != QFieldType.BLOB);
|
||||
return field != null && !Boolean.TRUE.equals(field.getIsHidden()) && field.getType() != QFieldType.BLOB;
|
||||
}
|
||||
|
||||
|
||||
@@ -289,20 +291,22 @@ public final class PrintingRecordProcesses
|
||||
** view shows them; fields in no section follow under "Other fields". A
|
||||
** table without sections prints one unlabeled section of every field.
|
||||
***************************************************************************/
|
||||
// QQQ declares record-section display tiers in their required presentation order.
|
||||
@SuppressWarnings("EnumOrdinal")
|
||||
static List<PrintingRecordDocuments.Section> sections(QTableMetaData table, QRecord record)
|
||||
{
|
||||
List<PrintingRecordDocuments.Section> out = new ArrayList<>();
|
||||
java.util.Set<String> placed = new java.util.HashSet<>();
|
||||
List<com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection> sections = new ArrayList<>(table.getSections() == null ? List.of() : table.getSections());
|
||||
sections.removeIf(section -> Boolean.TRUE.equals(section.getIsHidden()) || section.getFieldNames() == null || section.getFieldNames().isEmpty());
|
||||
sections.sort(java.util.Comparator.comparing(section -> section.getTier() == null ? 9 : section.getTier().ordinal()));
|
||||
sections.sort(java.util.Comparator.comparingInt(section -> section.getTier() == null ? 9 : section.getTier().ordinal()));
|
||||
for(var section : sections)
|
||||
{
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
for(String name : section.getFieldNames())
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(name);
|
||||
if(printable(field) && placed.add(name))
|
||||
if(field != null && printable(field) && placed.add(name))
|
||||
{
|
||||
fields.put(field.getLabel() == null ? name : field.getLabel(), text(record, field));
|
||||
}
|
||||
@@ -321,7 +325,7 @@ public final class PrintingRecordProcesses
|
||||
{
|
||||
out.add(new PrintingRecordDocuments.Section(out.isEmpty() ? "" : "Other fields", rest));
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +387,7 @@ public final class PrintingRecordProcesses
|
||||
// a child list the user may not read (or that cannot load) is left out of the sheet
|
||||
}
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +405,7 @@ public final class PrintingRecordProcesses
|
||||
.put("contentBase64", Base64.getEncoder().encodeToString(bytes))
|
||||
.put("idempotencyKey", idempotencyKey)
|
||||
.put("options", new JSONObject().put("copies", copies).put("scale", "FIT")));
|
||||
return (job.getString("jobId"));
|
||||
return job.getString("jobId");
|
||||
}
|
||||
catch(java.sql.SQLException | PrintingException exception)
|
||||
{
|
||||
@@ -464,10 +468,12 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** Standard review summary.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready)));
|
||||
return new ArrayList<>(List.of(ready));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +519,8 @@ public final class PrintingRecordProcesses
|
||||
/***************************************************************************
|
||||
** Result summary: what was sent, and the job IDs to follow.
|
||||
***************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
@@ -521,7 +529,7 @@ public final class PrintingRecordProcesses
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Print job" + (jobIds.size() == 1 ? " " : "s ") + String.join(", ", jobIds) + " queued; follow them in Print jobs."));
|
||||
}
|
||||
return (lines);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,10 +135,11 @@ public final class PrintingRenderProcesses
|
||||
output.addValue("serverFilePath", ProcessFileDownload.register(file));
|
||||
output.addValue("printingTitle", rendered.title());
|
||||
output.addValue("printingBytes", rendered.bytes().length);
|
||||
if(rendered.message() != null)
|
||||
String message = rendered.message();
|
||||
if(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"));
|
||||
output.addValue("printingMessage", 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)
|
||||
|
||||
@@ -44,7 +44,7 @@ public record PrintingRetentionPolicy(boolean enabled, int days, int batchSize)
|
||||
***************************************************************************/
|
||||
public static PrintingRetentionPolicy disabled()
|
||||
{
|
||||
return (new PrintingRetentionPolicy(false, 365, 200));
|
||||
return new PrintingRetentionPolicy(false, 365, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QProcessMetaData manual(String name, String label)
|
||||
{
|
||||
return (new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules()).withStep(backend("prepare")));
|
||||
return new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules()).withStep(backend("prepare"));
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QBackendStepMetaData backend(String name)
|
||||
{
|
||||
return (new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRetentionProcesses.class)));
|
||||
return new QBackendStepMetaData().withName(name).withCode(new QCodeReference(PrintingRetentionProcesses.class));
|
||||
}
|
||||
|
||||
|
||||
@@ -107,9 +107,9 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFrontendStepMetaData review(String label)
|
||||
{
|
||||
return (new QFrontendStepMetaData().withName("review").withLabel(label)
|
||||
return new QFrontendStepMetaData().withName("review").withLabel(label)
|
||||
.withViewFields(List.of(text("policy", "Retention policy"), text("storage", "Document storage"), text("schedule", "Automatic schedule"), text("progress", "Cleanup progress"), text("safety", "What cleanup does")))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM)));
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM));
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,8 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFrontendStepMetaData result()
|
||||
{
|
||||
return (new QFrontendStepMetaData().withName("result").withLabel("Result").withViewFields(List.of(text("result", "Result")))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM)));
|
||||
return new QFrontendStepMetaData().withName("result").withLabel("Result").withViewFields(List.of(text("result", "Result")))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM));
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData text(String name, String label)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label));
|
||||
return new QFieldMetaData(name, QFieldType.STRING).withLabel(label);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
{
|
||||
throw new QPermissionDeniedException("The scheduled retention entrypoint requires the system session; use Run retention cleanup instead");
|
||||
}
|
||||
service = configuration().getService();
|
||||
service = configuredService();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -177,7 +177,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
else if("execute".equals(input.getStepName()) && (RUN_NOW.equals(input.getProcessName()) || scheduled))
|
||||
{
|
||||
int removed = service.runRetention();
|
||||
output.addValue("result", "Removed " + removed + " eligible documents or abandoned uploads. Job receipts and uncertain or active work were preserved. Cleanup processes one bounded batch per run.");
|
||||
output.addValue("result", "Removed " + removed + " eligible documents, abandoned uploads or purged removed printers and devices that no job references. Job receipts and uncertain or active work were preserved. Cleanup processes one bounded batch per run.");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -212,7 +212,23 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
output.addValue("progress", "Eligible documents waiting: " + settings.optLong("eligibleBacklog", 0)
|
||||
+ "; last run: " + (lastRun.isBlank() ? "Not run yet" : lastRun) + "; removed: " + settings.optInt("lastRunCount", 0)
|
||||
+ (settings.optString("lastRunError", "").isBlank() ? "" : "; last run problem: " + settings.optString("lastRunError")));
|
||||
output.addValue("safety", "Cleanup removes only old successful-terminal document content. Active, failed and uncertain jobs, receipt metadata and deduplication records remain. Removed documents cannot be reprinted from history. Continue to edit the policy or run the selected cleanup action.");
|
||||
output.addValue("safety", "Cleanup removes only old successful-terminal document content, and purges removed printers and devices that no job references. Active, failed and uncertain jobs, receipt metadata and deduplication records remain. Removed documents cannot be reprinted from history. Continue to edit the policy or run the selected cleanup action.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Capture the configured service for this invocation, retaining the existing
|
||||
** missing-configuration exception if the host has withdrawn it.
|
||||
***************************************************************************/
|
||||
private static PrintingService configuredService() throws QException
|
||||
{
|
||||
PrintingService service = configuration().getService();
|
||||
if(service == null)
|
||||
{
|
||||
throw new QException("Printing QBit is not configured");
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +242,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
{
|
||||
if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() != null)
|
||||
{
|
||||
return (config);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
throw new QException("Printing QBit is not configured");
|
||||
|
||||
@@ -28,6 +28,7 @@ import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -192,7 +193,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
***************************************************************************/
|
||||
private String device(Context context) throws java.sql.SQLException
|
||||
{
|
||||
return (service.authenticate(bearer(context)));
|
||||
return service.authenticate(bearer(context));
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +217,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
public static String bearer(Context context)
|
||||
{
|
||||
String header = context.header("Authorization");
|
||||
return (header != null && header.startsWith("Bearer ") ? header.substring(7) : "");
|
||||
return header != null && header.startsWith("Bearer ") ? header.substring(7) : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +234,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
{
|
||||
throw new PrintingException(413, "Request is too large");
|
||||
}
|
||||
return (new JSONObject(context.body()));
|
||||
return new JSONObject(context.body());
|
||||
}
|
||||
|
||||
|
||||
@@ -247,14 +248,14 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
{
|
||||
throw new PrintingException(413, "Control request is too large");
|
||||
}
|
||||
return (new JSONObject(context.body()));
|
||||
return new JSONObject(context.body());
|
||||
}
|
||||
|
||||
|
||||
/** Standard mutation acknowledgment. */
|
||||
private static JSONObject accepted()
|
||||
{
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +293,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
/***************************************************************************
|
||||
** Emit the shared protocol error envelope.
|
||||
***************************************************************************/
|
||||
private static void error(Context context, int status, String message)
|
||||
private static void error(Context context, int status, @Nullable String message)
|
||||
{
|
||||
context.status(status).contentType("application/json").result(new JSONObject().put("code", "PRINTING_" + status).put("message", message).toString());
|
||||
}
|
||||
@@ -308,7 +309,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
/************************************************************************
|
||||
** Execute the route's service operation.
|
||||
************************************************************************/
|
||||
JSONObject run() throws Exception;
|
||||
@Nullable JSONObject run() throws Exception;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.printing;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -51,7 +52,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", null));
|
||||
return pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT", null);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +64,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, PrintingChrome chrome) throws QException
|
||||
public static byte[] pdf(String title, String subtitle, @Nullable String footer, List<String> columns, List<List<String>> rows, int @Nullable [] preferredWidths, String pageSize, String orientation, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
if(columns.isEmpty())
|
||||
{
|
||||
@@ -94,7 +95,7 @@ public final class PrintingTableDocuments
|
||||
layout.repeatOnNewPages(List.of(), FONT_SIZE);
|
||||
layout.gap(4);
|
||||
layout.line(rows.size() + (rows.size() == 1 ? " row" : " rows"), FONT_SIZE);
|
||||
return (layout.finish());
|
||||
return layout.finish();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -112,13 +113,13 @@ public final class PrintingTableDocuments
|
||||
{
|
||||
if("LANDSCAPE".equalsIgnoreCase(orientation))
|
||||
{
|
||||
return (true);
|
||||
return true;
|
||||
}
|
||||
if(!"AUTO".equalsIgnoreCase(orientation))
|
||||
{
|
||||
return (false);
|
||||
return false;
|
||||
}
|
||||
float width = ("A4".equalsIgnoreCase(pageSize) ? 595.28f : 612f) - 2 * PrintingLayout.MARGIN;
|
||||
return (PrintingLayout.lineWidth(natural) > (int) (width / (0.6f * FONT_SIZE)));
|
||||
return PrintingLayout.lineWidth(natural) > (int) (width / (0.6f * FONT_SIZE));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -41,9 +42,9 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
/***************************************************************************
|
||||
** The settings of a table, or null when it has none.
|
||||
***************************************************************************/
|
||||
public static PrintingTableSettings of(QTableMetaData table)
|
||||
public static @Nullable PrintingTableSettings of(QTableMetaData table)
|
||||
{
|
||||
return (table.getSupplementalMetaData() == null ? null : (PrintingTableSettings) table.getSupplementalMetaData(TYPE));
|
||||
return table.getSupplementalMetaData() == null ? null : (PrintingTableSettings) table.getSupplementalMetaData(TYPE);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +55,7 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (TYPE);
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +65,7 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
***************************************************************************/
|
||||
public boolean getPrintable()
|
||||
{
|
||||
return (printable);
|
||||
return printable;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +76,7 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
public PrintingTableSettings withPrintable(boolean value)
|
||||
{
|
||||
printable = value;
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +87,6 @@ public class PrintingTableSettings extends QSupplementalTableMetaData
|
||||
@Override
|
||||
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone)
|
||||
{
|
||||
return (clone);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -55,6 +56,10 @@ public final class PrintingTestDocuments
|
||||
static final String FONT_RESOURCE = "/com/kingsrook/qbits/printing/fonts/JetBrainsMono-Bold.ttf";
|
||||
private static final String LOGO_RESOURCE = "/com/kingsrook/qbits/printing/images/query-mascot.png";
|
||||
static final String BARCODE_VALUE = "QQQ-TEST-123456";
|
||||
private static final int LEGACY_LABEL_HEIGHT_INCHES = 6;
|
||||
static final int COMPACT_BARCODE_X = 16;
|
||||
static final int COMPACT_BARCODE_Y = 118;
|
||||
static final int COMPACT_BARCODE_HEIGHT = 16;
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +68,7 @@ public final class PrintingTestDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] pdf() throws QException
|
||||
{
|
||||
return (pdf(TestPrintProfile.LETTER));
|
||||
return pdf(TestPrintProfile.LETTER);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,16 +85,21 @@ public final class PrintingTestDocuments
|
||||
identifiers.add(new COSString("QQQ-TEST-" + profile.name()));
|
||||
document.getDocument().getTrailer().setItem(COSName.ID, identifiers);
|
||||
PDType0Font font = PDType0Font.load(document, fontStream, true);
|
||||
PDRectangle media = profile.getDpi() > 0 ? new PDRectangle(288, 432) : (profile == TestPrintProfile.A4 ? PDRectangle.A4 : PDRectangle.LETTER);
|
||||
PDRectangle media = profile.getDpi() > 0 ? new PDRectangle(profile.getWidthPoints(), profile.getHeightPoints()) : (profile == TestPrintProfile.A4 ? PDRectangle.A4 : PDRectangle.LETTER);
|
||||
PDPage page = new PDPage(media);
|
||||
document.addPage(page);
|
||||
try(PDPageContentStream content = new PDPageContentStream(document, page))
|
||||
{
|
||||
if(profile.getDpi() > 0)
|
||||
if(profile.getDpi() > 0 && profile.getHeightInches() == LEGACY_LABEL_HEIGHT_INCHES)
|
||||
{
|
||||
pdfLabel(content, font);
|
||||
content.drawImage(LosslessFactory.createFromImage(document, logo()), 210, 348, 54, 54);
|
||||
}
|
||||
else if(profile.getDpi() > 0)
|
||||
{
|
||||
content.setLineWidth(1);
|
||||
compactLabel(new PdfCanvas(content, font, LosslessFactory.createFromImage(document, logo()), profile.getHeightPoints()), profile.getHeightInches(), "PDF / ACTUAL SIZE");
|
||||
}
|
||||
else
|
||||
{
|
||||
pdfText(content, font, 56, 48, 690, "QQQ");
|
||||
@@ -119,7 +129,7 @@ public final class PrintingTestDocuments
|
||||
}
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
document.save(bytes);
|
||||
return (bytes.toByteArray());
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -134,7 +144,7 @@ public final class PrintingTestDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] zpl() throws QException
|
||||
{
|
||||
return (zpl(203));
|
||||
return zpl(203);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,18 +154,30 @@ public final class PrintingTestDocuments
|
||||
***************************************************************************/
|
||||
public static byte[] zpl(TestPrintProfile profile) throws QException
|
||||
{
|
||||
return (zpl(profile.getDpi()));
|
||||
return zpl(profile.getDpi(), profile.getHeightInches());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A four-by-six-inch label at the explicitly chosen printer resolution.
|
||||
** Text uses the bundled font rasterized to one bit; Code 128 stays native.
|
||||
***************************************************************************/
|
||||
public static byte[] zpl(int dpi) throws QException
|
||||
{
|
||||
BufferedImage image = labelImage(dpi);
|
||||
return zpl(dpi, LEGACY_LABEL_HEIGHT_INCHES);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A four-inch-wide label of the explicit height at the chosen resolution.
|
||||
** Text uses the bundled font rasterized to one bit; Code 128 stays native.
|
||||
** No media-type, darkness or speed commands are sent, so the printer keeps
|
||||
** its own direct-thermal configuration and this never changes its defaults.
|
||||
***************************************************************************/
|
||||
static byte[] zpl(int dpi, int heightInches) throws QException
|
||||
{
|
||||
BufferedImage image = labelImage(dpi, heightInches);
|
||||
int rowBytes = (image.getWidth() + 7) / 8;
|
||||
byte[] bitmap = new byte[rowBytes * image.getHeight()];
|
||||
for(int y = 0; y < image.getHeight(); y++)
|
||||
@@ -171,9 +193,18 @@ public final class PrintingTestDocuments
|
||||
int module = Math.max(2, dpi / 100);
|
||||
StringBuilder zpl = new StringBuilder("^XA^PW" + image.getWidth() + "^LL" + image.getHeight() + "^LH0,0");
|
||||
appendGraphicStrips(zpl, bitmap, rowBytes, image.getHeight());
|
||||
zpl.append("^FO").append(dots(24, dpi)).append(',').append(dots(276, dpi)).append("^BY").append(module)
|
||||
.append("^BCN,").append(dots(42, dpi)).append(",N,N,N^FD").append(BARCODE_VALUE).append("^FS^PQ1^XZ");
|
||||
return (zpl.toString().getBytes(StandardCharsets.US_ASCII));
|
||||
if(heightInches == LEGACY_LABEL_HEIGHT_INCHES)
|
||||
{
|
||||
zpl.append("^FO").append(dots(24, dpi)).append(',').append(dots(276, dpi)).append("^BY").append(module)
|
||||
.append("^BCN,").append(dots(42, dpi)).append(",N,N,N^FD").append(BARCODE_VALUE).append("^FS");
|
||||
}
|
||||
else if(heightInches == 2)
|
||||
{
|
||||
zpl.append("^FO").append(dots(COMPACT_BARCODE_X, dpi)).append(',').append(dots(COMPACT_BARCODE_Y, dpi)).append("^BY").append(module)
|
||||
.append("^BCN,").append(dots(COMPACT_BARCODE_HEIGHT, dpi)).append(",N,N,N^FD").append(BARCODE_VALUE).append("^FS");
|
||||
}
|
||||
zpl.append("^PQ1^XZ");
|
||||
return zpl.toString().getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
|
||||
@@ -201,15 +232,29 @@ public final class PrintingTestDocuments
|
||||
** Deterministic image also supports inspection without contacting a printer.
|
||||
***************************************************************************/
|
||||
static BufferedImage labelImage(int dpi) throws QException
|
||||
{
|
||||
return labelImage(dpi, LEGACY_LABEL_HEIGHT_INCHES);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Four-inch-wide label raster of an explicit supported height.
|
||||
***************************************************************************/
|
||||
static BufferedImage labelImage(int dpi, int heightInches) throws QException
|
||||
{
|
||||
if(!Set.of(203, 300, 600).contains(dpi))
|
||||
{
|
||||
throw new IllegalArgumentException("Label resolution must be 203, 300 or 600 dpi");
|
||||
}
|
||||
if(!Set.of(1, 2, LEGACY_LABEL_HEIGHT_INCHES).contains(heightInches))
|
||||
{
|
||||
throw new IllegalArgumentException("Label height must be 1, 2 or 6 inches");
|
||||
}
|
||||
try(InputStream stream = fontStream())
|
||||
{
|
||||
Font font = Font.createFont(Font.TRUETYPE_FONT, stream);
|
||||
BufferedImage image = new BufferedImage(dpi * 4, dpi * 6, BufferedImage.TYPE_BYTE_BINARY);
|
||||
BufferedImage image = new BufferedImage(dpi * 4, dpi * heightInches, BufferedImage.TYPE_BYTE_BINARY);
|
||||
Graphics2D graphics = image.createGraphics();
|
||||
try
|
||||
{
|
||||
@@ -218,6 +263,12 @@ public final class PrintingTestDocuments
|
||||
graphics.scale(dpi / 72.0, dpi / 72.0);
|
||||
graphics.setColor(Color.BLACK);
|
||||
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF);
|
||||
if(heightInches != LEGACY_LABEL_HEIGHT_INCHES)
|
||||
{
|
||||
compactLabel(new ImageCanvas(graphics, font, logo()), heightInches, dpi + " dpi / 1 copy");
|
||||
scanText(graphics, font, heightInches);
|
||||
return image;
|
||||
}
|
||||
graphics.drawRect(12, 12, 264, 408);
|
||||
graphics.drawImage(logo(), 210, 30, 54, 54, null);
|
||||
labelText(graphics, font, 43, 24, 58, "QQQ");
|
||||
@@ -241,7 +292,7 @@ public final class PrintingTestDocuments
|
||||
{
|
||||
graphics.dispose();
|
||||
}
|
||||
return (image);
|
||||
return image;
|
||||
}
|
||||
catch(IOException | FontFormatException exception)
|
||||
{
|
||||
@@ -261,7 +312,7 @@ public final class PrintingTestDocuments
|
||||
{
|
||||
throw new IOException("Bundled test-document font is missing");
|
||||
}
|
||||
return (stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +343,7 @@ public final class PrintingTestDocuments
|
||||
{
|
||||
graphics.dispose();
|
||||
}
|
||||
return (image);
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +354,7 @@ public final class PrintingTestDocuments
|
||||
***************************************************************************/
|
||||
private static int dots(int points, int dpi)
|
||||
{
|
||||
return ((int) Math.round(points * dpi / 72.0));
|
||||
return (int) Math.round(points * dpi / 72.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -378,5 +429,203 @@ public final class PrintingTestDocuments
|
||||
content.addRect(12, 12, 264, 408);
|
||||
content.stroke();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Top-left origin drawing surface in points, shared by the PDF and raster
|
||||
** paths so the compact 4 x 1 and 4 x 2 layouts cannot drift apart.
|
||||
***************************************************************************/
|
||||
private interface LabelCanvas
|
||||
{
|
||||
/************************************************************************
|
||||
** Draw a text baseline at the given point size and position.
|
||||
************************************************************************/
|
||||
void text(int size, int x, int y, String text) throws IOException;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Stroke a one-point line between two points.
|
||||
************************************************************************/
|
||||
void line(int x1, int y1, int x2, int y2) throws IOException;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Stroke a rectangle outline from its top-left corner.
|
||||
************************************************************************/
|
||||
void rect(int x, int y, int width, int height) throws IOException;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Draw the square Query mascot from its top-left corner.
|
||||
************************************************************************/
|
||||
void logo(int x, int y, int size) throws IOException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Own layouts for the short stocks; the 4 x 6 layout is never scaled down.
|
||||
** Text starts at 7 pt (about 20 dots at 203 dpi); only a 4 x 2 label has
|
||||
** room for the native barcode, which the ZPL path places at COMPACT_BARCODE_*.
|
||||
***************************************************************************/
|
||||
private static void compactLabel(LabelCanvas canvas, int heightInches, String medium) throws IOException
|
||||
{
|
||||
String stock = "4 x " + heightInches + " in / " + medium;
|
||||
if(heightInches == 2)
|
||||
{
|
||||
canvas.rect(5, 5, 278, 134);
|
||||
canvas.logo(240, 11, 36);
|
||||
canvas.text(26, 12, 38, "QQQ");
|
||||
canvas.text(9, 70, 26, "TEST PRINT - NOT POSTAGE");
|
||||
canvas.text(8, 70, 38, stock);
|
||||
canvas.line(12, 54, 232, 54);
|
||||
canvas.text(16, 12, 72, "0O 1Il 5S 8B 2Z");
|
||||
canvas.text(9, 12, 86, "BIN A01-I1 / SKU O0-5S8B");
|
||||
canvas.text(9, 12, 98, "ORDER QQQ-123456 / QTY 02");
|
||||
ruler(canvas, 108, 8, 92, 111);
|
||||
}
|
||||
else
|
||||
{
|
||||
canvas.rect(5, 5, 278, 62);
|
||||
canvas.logo(248, 9, 28);
|
||||
canvas.text(20, 12, 28, "QQQ");
|
||||
canvas.text(8, 56, 17, "TEST PRINT - NOT POSTAGE");
|
||||
canvas.text(7, 56, 27, stock);
|
||||
canvas.line(12, 33, 240, 33);
|
||||
canvas.text(12, 12, 46, "0O 1Il 5S 8B 2Z");
|
||||
canvas.text(7, 158, 44, "BIN A01-I1 / SKU O0-5S8B");
|
||||
ruler(canvas, 57, 7, 92, 60);
|
||||
canvas.text(7, 165, 60, "ORDER QQQ-123456 / QTY 02");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One-inch (25.4 mm) measuring line with end ticks and its caption.
|
||||
***************************************************************************/
|
||||
private static void ruler(LabelCanvas canvas, int y, int size, int captionX, int captionY) throws IOException
|
||||
{
|
||||
canvas.line(12, y, 84, y);
|
||||
canvas.line(12, y - 3, 12, y + 3);
|
||||
canvas.line(84, y - 3, 84, y + 3);
|
||||
canvas.text(size, captionX, captionY, "25.4 mm / 1 inch");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Caption beside the native 4 x 2 barcode; it exists only in the ZPL raster.
|
||||
***************************************************************************/
|
||||
private static void scanText(Graphics2D graphics, Font font, int heightInches)
|
||||
{
|
||||
if(heightInches == 2)
|
||||
{
|
||||
labelText(graphics, font, 8, 170, 130, "SCAN: " + BARCODE_VALUE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Vector PDF surface; converts the top-left layout to PDF's bottom-left origin.
|
||||
***************************************************************************/
|
||||
private static final class PdfCanvas implements LabelCanvas
|
||||
{
|
||||
private final PDPageContentStream content;
|
||||
private final PDType0Font font;
|
||||
private final PDImageXObject mascot;
|
||||
private final int height;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Draw onto one page whose height converts top-left layout coordinates.
|
||||
************************************************************************/
|
||||
PdfCanvas(PDPageContentStream content, PDType0Font font, PDImageXObject mascot, int height)
|
||||
{
|
||||
this.content = content;
|
||||
this.font = font;
|
||||
this.mascot = mascot;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void text(int size, int x, int y, String text) throws IOException
|
||||
{
|
||||
pdfText(content, font, size, x, height - y, text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void line(int x1, int y1, int x2, int y2) throws IOException
|
||||
{
|
||||
content.moveTo(x1, height - y1);
|
||||
content.lineTo(x2, height - y2);
|
||||
content.stroke();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void rect(int x, int y, int width, int rectHeight) throws IOException
|
||||
{
|
||||
content.addRect(x, height - y - rectHeight, width, rectHeight);
|
||||
content.stroke();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void logo(int x, int y, int size) throws IOException
|
||||
{
|
||||
content.drawImage(mascot, x, height - y - size, size, size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One-bit raster surface drawing the same layout with the same bundled font.
|
||||
***************************************************************************/
|
||||
private record ImageCanvas(Graphics2D graphics, Font font, BufferedImage mascot) implements LabelCanvas
|
||||
{
|
||||
@Override
|
||||
public void text(int size, int x, int y, String text)
|
||||
{
|
||||
labelText(graphics, font, size, x, y, text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void line(int x1, int y1, int x2, int y2)
|
||||
{
|
||||
graphics.drawLine(x1, y1, x2, y2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void rect(int x, int y, int width, int height)
|
||||
{
|
||||
graphics.drawRect(x, y, width, height);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void logo(int x, int y, int size)
|
||||
{
|
||||
graphics.drawImage(mascot, x, y, size, size, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.printing;
|
||||
|
||||
import java.io.IOException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -49,9 +50,9 @@ 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
|
||||
public static byte[] pdf(String title, String subtitle, @Nullable String text, String pageSize, boolean landscape) throws QException
|
||||
{
|
||||
return (pdf(title, subtitle, text, pageSize, landscape, null));
|
||||
return pdf(title, subtitle, text, pageSize, landscape, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +60,7 @@ public final class PrintingTextDocuments
|
||||
/***************************************************************************
|
||||
** 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
|
||||
public static byte[] pdf(String title, String subtitle, @Nullable String text, String pageSize, boolean landscape, @Nullable PrintingChrome chrome) throws QException
|
||||
{
|
||||
if(text == null || text.isEmpty())
|
||||
{
|
||||
@@ -85,7 +86,7 @@ public final class PrintingTextDocuments
|
||||
}
|
||||
while(!rest.isEmpty());
|
||||
}
|
||||
return (layout.finish());
|
||||
return layout.finish();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
@@ -107,7 +108,7 @@ public final class PrintingTextDocuments
|
||||
widest = Math.max(widest, line.stripTrailing().length());
|
||||
}
|
||||
float fits = charactersAtOnePoint / widest;
|
||||
return (Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2)));
|
||||
return Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +161,7 @@ public final class PrintingTextDocuments
|
||||
}
|
||||
}
|
||||
}
|
||||
return (layout.finish());
|
||||
return layout.finish();
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user