Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/
|
||||
+18
-1
@@ -1,6 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased (0.4.0-SNAPSHOT)
|
||||
## 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 +21,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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# QQQ Printing
|
||||
|
||||
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. 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.0.1 and the [shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol) 1.0.0. 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.0.0** supports native printing **2.3**, wire payload v1, real-time jobs and controls, and native browser sign-in through QQQ. 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.
|
||||
**Current dependency:** QQQ **4.1.0-RC.21**, published on Maven Central with native session login, and Java 21. See the [printing releases](https://git.kof22.com/QRun-IO/qbit-printing/releases) for downloads. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with build attestations for all three assets. Verify the exact downloaded release; publication does not establish installed or physical-output acceptance. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR.
|
||||
|
||||
Implemented: single-use enrollment, device credentials, inventory/approval, PDF/ZPL submission, durable JDBC queue, idempotency, claim/content/events, native QQQ setup/test-print processes, read-only device/printer/job tables and a durable enqueue effect target.
|
||||
|
||||
@@ -27,7 +27,7 @@ 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. This source pins protocol commit `42c0d249f81395f00408ec0f92638dcbbf5deb03` (the integrated contract under Gitea PR 2; the same ref CI checks out); use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11).
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd verify
|
||||
@@ -149,7 +149,7 @@ For durable QQQ jobs register `PrintEnqueueEffectTarget` as an `IDEMPOTENT_EXTER
|
||||
|
||||
## Deliberate first-version limits
|
||||
|
||||
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.
|
||||
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. Job paper (`paperWidthMm` and `paperHeightMm`, both positive, at most 2000, never with `mediaProfile`) is accepted; the built-in 4 x 1 and 4 x 2 in PDF test labels use it (101.6 x 25.4 / 50.8 mm) so Windows does not feed 4 x 6 stock. Only a client from the same unreleased 0.4 cohort applies it; older clients reject the job. Other jobs follow the Windows queue default. Received/submitting jobs are not automatically offered again. The queue records SPOOLED honestly and does not assert 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+21
-1
@@ -128,6 +128,26 @@ It is opt-in, Test-only, and does not change the default released QQQ dependency
|
||||
|
||||
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
|
||||
the real 40-job/S3 Docker smoke. Both client fixture images also have build-only
|
||||
jobs on every pull request, including the optional native client; these jobs run
|
||||
only fake-HTTP discovery checks and do not start a provider.
|
||||
|
||||
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 manual workflow option; 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,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
|
||||
```
|
||||
|
||||
CI also runs the bounded compiler-configuration guard:
|
||||
|
||||
```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 retained in the normal CI test evidence.
|
||||
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.
|
||||
@@ -1,5 +1,42 @@
|
||||
# Session state — 2026-10-31
|
||||
|
||||
## CURRENT checkpoint — 1.0.0 release preparation (2026-11-03)
|
||||
|
||||
The release branch includes the primary head `a583f91` and the prepared 1.0.0 version. Windows connector 1.0.1
|
||||
and protocol 1.0.0 are published. The QQQ pin is now the published `4.1.0-RC.21` candidate that provides native
|
||||
session login; its BOM was independently retrieved from Maven Central. The existing `-Pui-demo verify` gate
|
||||
passed against that dependency on 2026-11-03: 193 tests reported, zero failures or errors, one existing opt-in
|
||||
workload skip and zero Checkstyle violations. Main and test sources compile under the existing Error Prone,
|
||||
NullAway and warnings-as-errors checks. Printing 1.0.0 publication remains pending its normal CI and release
|
||||
process. Every checklist below is historical.
|
||||
|
||||
## 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
|
||||
|
||||
The isolated Echo candidate adds native printing extension **2.3.0** (after the protected-main 2.2.0 rendering extension) on the
|
||||
|
||||
+30
-3
@@ -1,9 +1,10 @@
|
||||
# Follow-up
|
||||
|
||||
## Script quality gates
|
||||
## CURRENT — 1.0.0 (everything below this section is historical)
|
||||
|
||||
- [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 the published QQQ `4.1.0-RC.21` candidate in `pom.xml` and the active release documentation; the BOM resolves from Maven Central.
|
||||
- [x] Complete the existing normal verification (`./mvnw -Pui-demo verify`) against RC.21: 193 tests reported, zero failures/errors, one existing opt-in workload skip and zero Checkstyle violations.
|
||||
- [ ] Complete the existing normal publication of `1.0.0` after its required CI checks.
|
||||
|
||||
## Echo integration candidate
|
||||
|
||||
@@ -24,6 +25,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
|
||||
|
||||
@@ -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.0.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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ public final class PrintingAgentAuthorization
|
||||
// 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 +124,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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,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 +181,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)
|
||||
{
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ public final class PrintingDeviceProcesses
|
||||
{
|
||||
if(action.process.equals(input.getProcessName()))
|
||||
{
|
||||
return (action);
|
||||
return action;
|
||||
}
|
||||
}
|
||||
throw new QException("Unknown printing control action");
|
||||
@@ -182,7 +182,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 +206,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 +268,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,10 +344,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
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()))));
|
||||
}
|
||||
return (data);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,10 +198,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 +219,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 +234,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 +246,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 +258,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 +270,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 +286,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 +299,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 +309,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 +320,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 +331,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 +341,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 +351,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 +364,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 +383,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 +393,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 +403,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 +413,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 +423,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}")))
|
||||
@@ -311,18 +312,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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -102,7 +103,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 +131,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 +143,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 +154,7 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +162,7 @@ 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}"));
|
||||
if(format != null)
|
||||
@@ -159,8 +171,8 @@ public class PrintingProcesses implements BackendStep
|
||||
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 +229,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 +249,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 +301,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 +312,7 @@ public class PrintingProcesses implements BackendStep
|
||||
{
|
||||
throw new QException("Choose an accessible printer belonging to the selected device");
|
||||
}
|
||||
return (printer);
|
||||
return printer;
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +322,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 +330,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 +347,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 +355,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 +494,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,10 +549,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 +565,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -315,6 +335,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 +360,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 +439,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
authorizeCurrent();
|
||||
if(frame == null)
|
||||
{
|
||||
snapshot(false);
|
||||
snapshot(false, helloPayload());
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -467,7 +488,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
throw new PrintingException(409, "Realtime capability is required");
|
||||
}
|
||||
hello = payload;
|
||||
snapshot(true);
|
||||
snapshot(true, payload);
|
||||
return;
|
||||
}
|
||||
case "STATE_APPLIED" ->
|
||||
@@ -478,7 +499,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;
|
||||
@@ -557,14 +578,30 @@ 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"))
|
||||
@@ -574,15 +611,15 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
|
||||
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 +632,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;
|
||||
@@ -689,6 +726,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -167,8 +168,8 @@ public final class PrintingRecordProcesses
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, "PDF"));
|
||||
return (new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter));
|
||||
return new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +190,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 +203,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 +217,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 +229,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 +247,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 +258,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 +266,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 +278,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 +290,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 +324,7 @@ public final class PrintingRecordProcesses
|
||||
{
|
||||
out.add(new PrintingRecordDocuments.Section(out.isEmpty() ? "" : "Other fields", rest));
|
||||
}
|
||||
return (out);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +386,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 +404,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 +467,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 +518,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 +528,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
|
||||
{
|
||||
@@ -217,6 +217,22 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Avoid static service caches and prevent a scheduler from crossing instance boundaries.
|
||||
***************************************************************************/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,13 @@ import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.uber.nullaway.annotations.EnsuresNonNull;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -50,9 +53,9 @@ public class PrintingService
|
||||
private static final String JOB_COLUMNS = "id, tenant_id, device_id, printer_id, title, format, content_hash, content_length, options_json, idempotency_key, created_at, status, message, spooler_job_id, printer_row_id";
|
||||
private final DataSource dataSource;
|
||||
private final String tenantId;
|
||||
private PrintingDocumentStorage documentStorage;
|
||||
private @Nullable PrintingDocumentStorage documentStorage;
|
||||
private PrintingAdmissionLimits admissionLimits = PrintingAdmissionLimits.defaults();
|
||||
private String storageTableName;
|
||||
private @Nullable String storageTableName;
|
||||
private PrintingRetentionPolicy defaultRetentionPolicy = new PrintingRetentionPolicy(true, 365, 200);
|
||||
private PrintingRealtimeBus realtimeBus = new PrintingRealtimeBus();
|
||||
private RealtimeClaimGuard realtimeClaimGuard = device ->
|
||||
@@ -117,14 +120,14 @@ public class PrintingService
|
||||
public PrintingService withRealtimeBus(PrintingRealtimeBus bus)
|
||||
{
|
||||
realtimeBus = java.util.Objects.requireNonNull(bus);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/** Instance-owned notifications never contain credentials or document bytes. */
|
||||
public PrintingRealtimeBus getRealtimeBus()
|
||||
{
|
||||
return (realtimeBus);
|
||||
return realtimeBus;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +140,7 @@ public class PrintingService
|
||||
JSONObject row = query(connection, "SELECT realtime_policy_json, realtime_revision FROM printing_settings WHERE tenant_id=?", tenantId).getFirst();
|
||||
PrintingRealtimePolicy policy = row.isNull("realtime_policy_json") ? PrintingRealtimePolicy.defaults()
|
||||
: PrintingRealtimePolicy.fromJson(new JSONObject(row.getString("realtime_policy_json")));
|
||||
return (policy.toJson(row.getLong("realtime_revision")));
|
||||
return policy.toJson(row.getLong("realtime_revision"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +172,7 @@ public class PrintingService
|
||||
}
|
||||
}
|
||||
realtimeBus.publish(tenantId, null);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +181,7 @@ public class PrintingService
|
||||
{
|
||||
JSONObject settings = getRealtimeSettings();
|
||||
settings.remove("revision");
|
||||
return (PrintingRealtimePolicy.fromJson(settings));
|
||||
return PrintingRealtimePolicy.fromJson(settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +192,7 @@ public class PrintingService
|
||||
public PrintingService withAdmissionLimits(PrintingAdmissionLimits limits)
|
||||
{
|
||||
admissionLimits = java.util.Objects.requireNonNull(limits);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +200,7 @@ public class PrintingService
|
||||
public JSONObject getAdmissionSettings() throws SQLException
|
||||
{
|
||||
ensureRetentionSettings();
|
||||
return (new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).get());
|
||||
return new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).get();
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +210,7 @@ public class PrintingService
|
||||
ensureRetentionSettings();
|
||||
JSONObject result = new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).save(request);
|
||||
realtimeBus.publish(tenantId, null);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +218,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Bind a host-owned storage table before exposing this service to requests.
|
||||
***************************************************************************/
|
||||
public void configureStorage(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance, String table) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
public void configureStorage(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance, @Nullable String table) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -232,7 +235,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Package-scoped adapter seam supports storage failure tests without S3.
|
||||
***************************************************************************/
|
||||
void configureStorage(PrintingDocumentStorage storage, String table)
|
||||
void configureStorage(PrintingDocumentStorage storage, @Nullable String table)
|
||||
{
|
||||
if(table != null)
|
||||
{
|
||||
@@ -250,7 +253,7 @@ public class PrintingService
|
||||
public PrintingService withRetentionPolicy(PrintingRetentionPolicy policy)
|
||||
{
|
||||
defaultRetentionPolicy = java.util.Objects.requireNonNull(policy);
|
||||
return (this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -382,26 +385,26 @@ public class PrintingService
|
||||
{
|
||||
JSONObject result = new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor);
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** Native authorization supplies the exact reviewed queue identity set, never a predicate executed inside SQL locks. */
|
||||
JSONObject deviceAction(String deviceId, JSONObject request, String actor, Set<String> authorizedQueue) throws SQLException
|
||||
JSONObject deviceAction(String deviceId, JSONObject request, String actor, @Nullable Set<String> authorizedQueue) throws SQLException
|
||||
{
|
||||
JSONObject result = new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor, authorizedQueue);
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** Resolve a retained action receipt without treating new queue contents as part of a replay. */
|
||||
String existingDeviceAction(String deviceId, String requestId) throws SQLException
|
||||
@Nullable String existingDeviceAction(String deviceId, String requestId) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenantId, deviceId, requestId);
|
||||
return (rows.isEmpty() ? null : rows.getFirst().getString("id"));
|
||||
return rows.isEmpty() ? null : rows.getFirst().getString("id");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +424,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(429, "The queue exceeds the bounded native review limit");
|
||||
}
|
||||
return (ids);
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,35 +432,35 @@ public class PrintingService
|
||||
/** Independently poll desired state and pending commands using device authentication. */
|
||||
public JSONObject deviceControlPoll(String deviceId, JSONObject request) throws SQLException
|
||||
{
|
||||
return (new PrintingDeviceControls(dataSource, tenantId).poll(deviceId, request));
|
||||
return new PrintingDeviceControls(dataSource, tenantId).poll(deviceId, request);
|
||||
}
|
||||
|
||||
|
||||
/** Persist one immutable device observation without inferring physical output. */
|
||||
public JSONObject deviceControlReceipt(String deviceId, String commandId, JSONObject receipt) throws SQLException
|
||||
{
|
||||
return (new PrintingDeviceControls(dataSource, tenantId).receipt(deviceId, commandId, receipt));
|
||||
return new PrintingDeviceControls(dataSource, tenantId).receipt(deviceId, commandId, receipt);
|
||||
}
|
||||
|
||||
|
||||
/** Tenant/device scoped history remains visible after credential revocation. */
|
||||
public JSONObject deviceCommandsPage(String deviceId, int limit, String before) throws SQLException
|
||||
public JSONObject deviceCommandsPage(String deviceId, int limit, @Nullable String before) throws SQLException
|
||||
{
|
||||
return (new PrintingDeviceControls(dataSource, tenantId).page(deviceId, limit, before));
|
||||
return new PrintingDeviceControls(dataSource, tenantId).page(deviceId, limit, before);
|
||||
}
|
||||
|
||||
|
||||
/** Read the exact durable receipt for a native or HTTP management action. */
|
||||
public JSONObject getDeviceCommand(String deviceId, String commandId) throws SQLException
|
||||
{
|
||||
return (new PrintingDeviceControls(dataSource, tenantId).get(deviceId, commandId));
|
||||
return new PrintingDeviceControls(dataSource, tenantId).get(deviceId, commandId);
|
||||
}
|
||||
|
||||
|
||||
/** The immutable tenant scope used by host authorization and native actions. */
|
||||
public String getTenantId()
|
||||
{
|
||||
return (tenantId);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
|
||||
@@ -502,7 +505,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONObject createEnrollment(String facilityId, String deviceName) throws SQLException
|
||||
{
|
||||
return (createEnrollment(facilityId, deviceName, secret(), Instant.now().plusSeconds(900).toString()));
|
||||
return createEnrollment(facilityId, deviceName, secret(), Instant.now().plusSeconds(900).toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -559,7 +562,7 @@ public class PrintingService
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new JSONObject().put("enrollmentCode", code).put("expiresAt", expires));
|
||||
return new JSONObject().put("enrollmentCode", code).put("expiresAt", expires);
|
||||
}
|
||||
|
||||
|
||||
@@ -569,7 +572,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONObject enroll(JSONObject request) throws SQLException
|
||||
{
|
||||
return (enrollForUser(request, null));
|
||||
return enrollForUser(request, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -583,13 +586,13 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(401, "Enrollment code is invalid or expired");
|
||||
}
|
||||
return (rows.getFirst().getString("facility_id"));
|
||||
return rows.getFirst().getString("facility_id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Native enrollment binds the core-authenticated principal in the credential transaction. */
|
||||
public JSONObject enrollForUser(JSONObject request, String principalId) throws SQLException
|
||||
public JSONObject enrollForUser(JSONObject request, @Nullable String principalId) throws SQLException
|
||||
{
|
||||
if(principalId != null && (principalId.isBlank() || principalId.length() > 200))
|
||||
{
|
||||
@@ -613,7 +616,7 @@ public class PrintingService
|
||||
update(connection, "INSERT INTO printing_device (id, tenant_id, facility_id, name, agent_version, token_hash, last_seen_at, enabled, computer_name, bound_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)", id, tenantId, code.getString("facility_id"), computerName, required(request.optString("agentVersion"), "agentVersion"), hash(token), Instant.now().toString(), computerName, principalId);
|
||||
update(connection, "UPDATE printing_enrollment SET consumed=1 WHERE code_hash=?", codeHash);
|
||||
connection.commit();
|
||||
return (new JSONObject().put("protocolVersion", 1).put("deviceId", id).put("deviceToken", token).put("pollIntervalSeconds", 5));
|
||||
return new JSONObject().put("protocolVersion", 1).put("deviceId", id).put("deviceToken", token).put("pollIntervalSeconds", 5);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -637,7 +640,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(401, "Invalid device credential");
|
||||
}
|
||||
return (devices.getFirst().getString("id"));
|
||||
return devices.getFirst().getString("id");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,7 +715,7 @@ public class PrintingService
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
return (new JSONObject().put("serverTime", now));
|
||||
return new JSONObject().put("serverTime", now);
|
||||
}
|
||||
|
||||
|
||||
@@ -727,7 +730,7 @@ public class PrintingService
|
||||
|
||||
|
||||
/** Native inventory changes exactly the existing row set authorized before opening this transaction. */
|
||||
void inventory(String deviceId, JSONArray printers, Set<String> authorizedRows) throws SQLException
|
||||
void inventory(String deviceId, JSONArray printers, @Nullable Set<String> authorizedRows) throws SQLException
|
||||
{
|
||||
if(printers.length() > 200)
|
||||
{
|
||||
@@ -824,14 +827,16 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONObject submit(JSONObject request) throws SQLException
|
||||
{
|
||||
return (submitAuthorized(request, null, null));
|
||||
return submitAuthorized(request, null, null);
|
||||
}
|
||||
|
||||
|
||||
/** Native operators retain current authority throughout shared queue admission. */
|
||||
JSONObject submitAuthorized(JSONObject request, String printerRowId, SubmissionAuthorization authorize) throws SQLException
|
||||
/***************************************************************************
|
||||
** Native operators retain current authority throughout shared queue admission.
|
||||
***************************************************************************/
|
||||
JSONObject submitAuthorized(JSONObject request, @Nullable String printerRowId, @Nullable SubmissionAuthorization authorize) throws SQLException
|
||||
{
|
||||
SubmissionGuard guard = authorize == null ? null : new SubmissionGuard(printerRowId, authorize);
|
||||
SubmissionGuard guard = authorize == null ? null : new SubmissionGuard(Objects.requireNonNull(printerRowId, "An authorized submission requires its printer row"), authorize);
|
||||
checkSubmission(guard);
|
||||
String format = required(request.optString("format"), "format");
|
||||
long maximum = realtimePolicy().documentLimit(format);
|
||||
@@ -860,23 +865,33 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "PDF header is missing");
|
||||
}
|
||||
return (submitPrepared(request, hash(content), content.length, connection -> content, null, guard));
|
||||
return submitPrepared(request, hash(content), content.length, connection -> content, null, guard);
|
||||
}
|
||||
|
||||
|
||||
/** A caller-owned current authorization check, never externally asserted proof. */
|
||||
|
||||
/***************************************************************************
|
||||
** A caller-owned current authorization check, never externally asserted proof.
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface SubmissionAuthorization
|
||||
{
|
||||
/** Recheck caller identity, current ceilings and exact destination. */
|
||||
/***********************************************************************
|
||||
** Recheck caller identity, current ceilings and exact destination.
|
||||
***********************************************************************/
|
||||
void check() throws com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
}
|
||||
|
||||
|
||||
/** Native row identity and live authority are independent of retained-source proof. */
|
||||
|
||||
/***************************************************************************
|
||||
** Native row identity and live authority are independent of retained-source proof.
|
||||
***************************************************************************/
|
||||
private record SubmissionGuard(String printerRowId, SubmissionAuthorization authorize)
|
||||
{
|
||||
/** Host callbacks cannot retarget an authorized native row to another queue. */
|
||||
/***********************************************************************
|
||||
** Host callbacks cannot retarget an authorized native row to another queue.
|
||||
***********************************************************************/
|
||||
void requirePrinter(JSONObject row, String field)
|
||||
{
|
||||
if(!printerRowId.equals(row.optString(field)))
|
||||
@@ -887,8 +902,11 @@ public class PrintingService
|
||||
}
|
||||
|
||||
|
||||
/** Preserve checked native authorization through the SQL transaction boundary. */
|
||||
private static void checkSubmission(SubmissionGuard guard) throws SQLException
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve checked native authorization through the SQL transaction boundary.
|
||||
***************************************************************************/
|
||||
private static void checkSubmission(@Nullable SubmissionGuard guard) throws SQLException
|
||||
{
|
||||
if(guard != null)
|
||||
{
|
||||
@@ -924,7 +942,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader) throws SQLException
|
||||
{
|
||||
return (submitPrepared(request, digest, contentLength, loader, null, null));
|
||||
return submitPrepared(request, digest, contentLength, loader, null, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -938,7 +956,7 @@ public class PrintingService
|
||||
}
|
||||
JSONObject source = binding.getJSONObject("source");
|
||||
BoundSubmission bound = new BoundSubmission(binding.toString(), actor);
|
||||
return (submitPrepared(request, source.getString("sha256"), source.getInt("sizeBytes"), connection ->
|
||||
return submitPrepared(request, source.getString("sha256"), source.getInt("sizeBytes"), connection ->
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -953,7 +971,7 @@ public class PrintingService
|
||||
{
|
||||
throw new SQLException("Retained content authorization failed", exception);
|
||||
}
|
||||
}, bound, new SubmissionGuard(binding.getString("printerRecordId"), authorize::run)));
|
||||
}, bound, new SubmissionGuard(binding.getString("printerRecordId"), authorize::run));
|
||||
}
|
||||
|
||||
|
||||
@@ -972,8 +990,10 @@ public class PrintingService
|
||||
}
|
||||
|
||||
|
||||
/** Reuse the existing storage reservation, queue admission, and commit path. */
|
||||
private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader, BoundSubmission bound, SubmissionGuard guard) throws SQLException
|
||||
/***************************************************************************
|
||||
** Reuse the existing storage reservation, queue admission, and commit path.
|
||||
***************************************************************************/
|
||||
private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader, @Nullable BoundSubmission bound, @Nullable SubmissionGuard guard) throws SQLException
|
||||
{
|
||||
checkSubmission(guard);
|
||||
String deviceId = required(request.optString("deviceId"), "deviceId");
|
||||
@@ -988,8 +1008,8 @@ public class PrintingService
|
||||
JSONObject options = printOptions(request);
|
||||
long contentLimit = realtimePolicy().documentLimit(format);
|
||||
String id = UUID.randomUUID().toString();
|
||||
String reference = storageTableName == null ? null : documentStorage.prefix() + id + "." + format.toLowerCase(java.util.Locale.ROOT);
|
||||
String binding = storageTableName == null ? null : documentStorage.binding(storageTableName);
|
||||
String reference = storageTableName == null ? "" : configuredStorage().prefix() + id + "." + format.toLowerCase(java.util.Locale.ROOT);
|
||||
String binding = storageTableName == null ? "" : configuredStorage().binding(storageTableName);
|
||||
String uploadedId = null;
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
@@ -1030,7 +1050,7 @@ public class PrintingService
|
||||
JSONObject replay = bound == null ? job(old) : accepted(connection, old);
|
||||
checkSubmission(guard);
|
||||
connection.commit();
|
||||
return (replay);
|
||||
return replay;
|
||||
}
|
||||
if(query(connection, "SELECT id FROM printing_document_orphan WHERE tenant_id=? AND idempotency_key=? LIMIT 1", tenantId, key).isEmpty())
|
||||
{
|
||||
@@ -1099,10 +1119,10 @@ public class PrintingService
|
||||
checkSubmission(guard);
|
||||
if(storageTableName != null)
|
||||
{
|
||||
documentStorage.put(storageTableName, reference, format, content);
|
||||
configuredStorage().put(storageTableName, reference, format, content);
|
||||
}
|
||||
checkSubmission(guard);
|
||||
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"), storageTableName, reference, binding);
|
||||
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"), storageTableName, storageTableName == null ? null : reference, storageTableName == null ? null : binding);
|
||||
if(bound != null)
|
||||
{
|
||||
String eventId = UUID.randomUUID().toString();
|
||||
@@ -1117,7 +1137,7 @@ public class PrintingService
|
||||
checkSubmission(guard);
|
||||
connection.commit();
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1130,10 +1150,7 @@ public class PrintingService
|
||||
}
|
||||
catch(Exception cleanupFailure)
|
||||
{
|
||||
if(cleanupFailure != exception)
|
||||
{
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
suppressIfDistinct(exception, cleanupFailure);
|
||||
}
|
||||
}
|
||||
throw exception;
|
||||
@@ -1148,7 +1165,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private JSONObject printOptions(JSONObject request) throws SQLException
|
||||
{
|
||||
return (printOptions(request, Math.toIntExact(realtimePolicy().get("maxCopies"))));
|
||||
return printOptions(request, Math.toIntExact(realtimePolicy().get("maxCopies")));
|
||||
}
|
||||
|
||||
|
||||
@@ -1184,7 +1201,11 @@ public class PrintingService
|
||||
}
|
||||
if(options.has("paperWidthMm") || options.has("paperHeightMm"))
|
||||
{
|
||||
throw new PrintingException(400, "Custom paper dimensions are not supported yet; configure the Windows queue");
|
||||
// Explicit job paper is a complete, positive size and is never combined with a queue media profile.
|
||||
if(!validPaperMillimetres(options.opt("paperWidthMm")) || !validPaperMillimetres(options.opt("paperHeightMm")) || options.has("mediaProfile"))
|
||||
{
|
||||
throw new PrintingException(400, "Paper dimensions require both a width and height in millimetres (up to 2000) and cannot be combined with a media profile");
|
||||
}
|
||||
}
|
||||
PrintingMediaProfile.selection(options);
|
||||
options.put("copies", count).put("scale", scale);
|
||||
@@ -1192,7 +1213,17 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "Print options are too large");
|
||||
}
|
||||
return (options);
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A finite number within the protocol's physical paper bounds.
|
||||
***************************************************************************/
|
||||
private static boolean validPaperMillimetres(@Nullable Object value)
|
||||
{
|
||||
return (value instanceof Number number && Double.isFinite(number.doubleValue()) && number.doubleValue() > 0 && number.doubleValue() <= 2000);
|
||||
}
|
||||
|
||||
|
||||
@@ -1205,9 +1236,15 @@ public class PrintingService
|
||||
String format = required(request.optString("format"), "format");
|
||||
TestPrintProfile profile = TestPrintProfile.resolve(format, request.has("testProfile") ? request.optString("testProfile") : null);
|
||||
byte[] bytes = "PDF".equals(format) ? PrintingTestDocuments.pdf(profile) : PrintingTestDocuments.zpl(profile);
|
||||
return (submit(new JSONObject().put("deviceId", deviceId).put("printerId", required(request.optString("printerId"), "printerId"))
|
||||
JSONObject job = new JSONObject().put("deviceId", deviceId).put("printerId", required(request.optString("printerId"), "printerId"))
|
||||
.put("title", "QQQ print test").put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes))
|
||||
.put("idempotencyKey", "device-test:" + deviceId + ":" + required(request.optString("requestId"), "requestId"))));
|
||||
.put("idempotencyKey", "device-test:" + deviceId + ":" + required(request.optString("requestId"), "requestId"));
|
||||
JSONObject options = profile.jobOptions(format);
|
||||
if(options != null)
|
||||
{
|
||||
job.put("options", options);
|
||||
}
|
||||
return submit(job);
|
||||
}
|
||||
|
||||
|
||||
@@ -1225,7 +1262,7 @@ public class PrintingService
|
||||
{
|
||||
result.put(job(row));
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,7 +1286,7 @@ public class PrintingService
|
||||
List<JSONObject> previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND device_id=? AND idempotency_key=?", tenantId, deviceId, retryKey);
|
||||
if(!previous.isEmpty())
|
||||
{
|
||||
return (job(previous.getFirst()));
|
||||
return job(previous.getFirst());
|
||||
}
|
||||
if(row.getInt("content_available") == 0)
|
||||
{
|
||||
@@ -1271,10 +1308,10 @@ public class PrintingService
|
||||
{
|
||||
verifyStoredReference(row);
|
||||
}
|
||||
return (submitPrepared(new JSONObject().put("deviceId", deviceId).put("printerId", row.getString("printer_id"))
|
||||
return submitPrepared(new JSONObject().put("deviceId", deviceId).put("printerId", row.getString("printer_id"))
|
||||
.put("title", row.getString("title")).put("format", row.getString("format"))
|
||||
.put("options", new JSONObject(row.getString("options_json")))
|
||||
.put("idempotencyKey", retryKey), row.getString("content_hash"), row.getInt("content_length"), connection -> documentBytes(deviceJob(connection, deviceId, jobId, true))));
|
||||
.put("idempotencyKey", retryKey), row.getString("content_hash"), row.getInt("content_length"), connection -> documentBytes(deviceJob(connection, deviceId, jobId, true)));
|
||||
}
|
||||
|
||||
|
||||
@@ -1296,7 +1333,7 @@ public class PrintingService
|
||||
if("FAILED".equals(row.getString("status")) && CANCELED_BEFORE_DISPATCH.equals(row.optString("message")))
|
||||
{
|
||||
connection.commit();
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
if(!"QUEUED".equals(row.getString("status")))
|
||||
{
|
||||
@@ -1305,7 +1342,7 @@ public class PrintingService
|
||||
update(connection, "UPDATE printing_job SET status='FAILED', message=? WHERE id=?", CANCELED_BEFORE_DISPATCH, jobId);
|
||||
connection.commit();
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1322,12 +1359,12 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONObject claim(String deviceId) throws SQLException
|
||||
{
|
||||
return (claimForJob(deviceId, null));
|
||||
return claimForJob(deviceId, null);
|
||||
}
|
||||
|
||||
|
||||
/** Native adapters authorize one exact job and printer before this locked dispatch transition. */
|
||||
public JSONObject claimForJob(String deviceId, String authorizedJobId) throws SQLException
|
||||
public JSONObject claimForJob(String deviceId, @Nullable String authorizedJobId) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
@@ -1341,13 +1378,13 @@ public class PrintingService
|
||||
if(control.getInt("dispatch_paused") == 1 || !"CONNECTED".equals(control.getString("connection_state")))
|
||||
{
|
||||
connection.commit();
|
||||
return (new JSONObject().put("job", JSONObject.NULL));
|
||||
return new JSONObject().put("job", JSONObject.NULL);
|
||||
}
|
||||
List<JSONObject> candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') AND ((j.format='PDF' AND p.formats LIKE '%\"PDF\"%') OR (j.format='ZPL' AND p.formats LIKE '%\"ZPL\"%'))" + (authorizedJobId == null ? "" : " AND j.id=?") + " ORDER BY j.created_at, j.id LIMIT 1", authorizedJobId == null ? new Object[] { tenantId, deviceId } : new Object[] { tenantId, deviceId, authorizedJobId });
|
||||
if(candidates.isEmpty())
|
||||
{
|
||||
connection.commit();
|
||||
return (new JSONObject().put("job", JSONObject.NULL));
|
||||
return new JSONObject().put("job", JSONObject.NULL);
|
||||
}
|
||||
JSONObject selected = candidates.getFirst();
|
||||
if(new JSONObject(selected.getString("options_json")).has("mediaProfile")
|
||||
@@ -1361,7 +1398,7 @@ public class PrintingService
|
||||
selected.put("status", "CLAIMED");
|
||||
}
|
||||
connection.commit();
|
||||
return (new JSONObject().put("job", job(selected)));
|
||||
return new JSONObject().put("job", job(selected));
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1390,7 +1427,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(410, "Print document expired under the host retention policy");
|
||||
}
|
||||
return (documentBytes(row));
|
||||
return documentBytes(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1426,7 +1463,7 @@ public class PrintingService
|
||||
verifyStoredReference(row);
|
||||
input = documentStorage.open(row.getString("content_storage_table"), row.getString("content_reference"));
|
||||
}
|
||||
return (new PrintingDocumentStream(input, row.getLong("content_length"), row.getString("content_hash")));
|
||||
return new PrintingDocumentStream(input, row.getLong("content_length"), row.getString("content_hash"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1509,7 +1546,7 @@ public class PrintingService
|
||||
.put("profile", new JSONObject().put("name", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location")))
|
||||
.put("agentVersion", row.getString("agent_version")).put("lastSeenAt", row.getString("last_seen_at")).put("enabled", row.getInt("enabled") == 1).put("control", PrintingDeviceControls.state(row)));
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1518,7 +1555,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Bounded inventory history for management clients.
|
||||
***************************************************************************/
|
||||
public JSONObject printersPage(String deviceId, int limit, String before) throws SQLException
|
||||
public JSONObject printersPage(String deviceId, int limit, @Nullable String before) throws SQLException
|
||||
{
|
||||
if(limit < 1 || limit > 200)
|
||||
{
|
||||
@@ -1562,7 +1599,7 @@ public class PrintingService
|
||||
String cursor = new JSONObject().put("name", last.getString("name")).put("id", last.getString("id")).toString();
|
||||
response.put("nextCursor", Base64.getUrlEncoder().withoutPadding().encodeToString(cursor.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
return (response);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1573,7 +1610,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONArray printers(String deviceId) throws SQLException
|
||||
{
|
||||
return (printersPage(deviceId, 200, null).getJSONArray("printers"));
|
||||
return printersPage(deviceId, 200, null).getJSONArray("printers");
|
||||
}
|
||||
|
||||
|
||||
@@ -1583,7 +1620,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONArray jobs() throws SQLException
|
||||
{
|
||||
return (jobsPage(200, null).getJSONArray("jobs"));
|
||||
return jobsPage(200, null).getJSONArray("jobs");
|
||||
}
|
||||
|
||||
|
||||
@@ -1591,7 +1628,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Keyset pagination stays bounded as history grows, without document reads.
|
||||
***************************************************************************/
|
||||
public JSONObject jobsPage(int limit, String before) throws SQLException
|
||||
public JSONObject jobsPage(int limit, @Nullable String before) throws SQLException
|
||||
{
|
||||
if(limit < 1 || limit > 200)
|
||||
{
|
||||
@@ -1635,7 +1672,7 @@ public class PrintingService
|
||||
JSONObject cursor = new JSONObject().put("createdAt", last.getString("created_at")).put("jobId", last.getString("id"));
|
||||
response.put("nextCursor", Base64.getUrlEncoder().withoutPadding().encodeToString(cursor.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
return (response);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1653,13 +1690,32 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(404, "Job not found");
|
||||
}
|
||||
return (job(rows.getFirst()));
|
||||
return job(rows.getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Cleanup never replaces the acceptance/authorization failure that caused rollback. */
|
||||
/***************************************************************************
|
||||
** Record a secondary cleanup failure on the primary one. Throwable.addSuppressed
|
||||
** rejects the very same instance, so this is an identity check by design; value
|
||||
** equality would wrongly drop distinct failures that merely compare equal.
|
||||
***************************************************************************/
|
||||
// Identity is the Throwable self-suppression contract; equals() cannot express it.
|
||||
@SuppressWarnings("ReferenceEquality")
|
||||
private static void suppressIfDistinct(Throwable primary, Throwable secondary)
|
||||
{
|
||||
if(secondary != primary)
|
||||
{
|
||||
primary.addSuppressed(secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Cleanup never replaces the acceptance/authorization failure that caused rollback.
|
||||
***************************************************************************/
|
||||
static void rollbackAfterFailure(Connection connection, Exception original)
|
||||
{
|
||||
try
|
||||
@@ -1668,16 +1724,13 @@ public class PrintingService
|
||||
}
|
||||
catch(Exception cleanupFailure)
|
||||
{
|
||||
if(cleanupFailure != original)
|
||||
{
|
||||
original.addSuppressed(cleanupFailure);
|
||||
}
|
||||
suppressIfDistinct(original, cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Exact accepted-source lookup; unresolved object reservations never mean absence. */
|
||||
JSONObject findRetained(String key, JSONObject expected) throws SQLException
|
||||
@Nullable JSONObject findRetained(String key, @Nullable JSONObject expected) throws SQLException
|
||||
{
|
||||
if(expected != null)
|
||||
{
|
||||
@@ -1691,7 +1744,7 @@ public class PrintingService
|
||||
PrintingAdmission.lock(connection, tenantId);
|
||||
JSONObject result = findRetained(connection, key, expected);
|
||||
connection.commit();
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1703,7 +1756,7 @@ public class PrintingService
|
||||
|
||||
|
||||
/** The existing admission lock makes absence consistent with in-flight submissions. */
|
||||
private JSONObject findRetained(Connection connection, String key, JSONObject expected) throws SQLException
|
||||
private @Nullable JSONObject findRetained(Connection connection, String key, @Nullable JSONObject expected) throws SQLException
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT " + JOB_COLUMNS + ", retained_binding_json, retained_actor_id, accepted_event_id FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
|
||||
if(rows.isEmpty())
|
||||
@@ -1719,7 +1772,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(409, "Accepted source binding differs");
|
||||
}
|
||||
return (accepted(connection, row));
|
||||
return accepted(connection, row);
|
||||
}
|
||||
|
||||
|
||||
@@ -1747,8 +1800,8 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(409, "Committed server acceptance event is absent or inconsistent");
|
||||
}
|
||||
return (new JSONObject().put("jobId", row.getString("id")).put("acceptedEventId", row.getString("accepted_event_id"))
|
||||
.put("acceptedAt", events.getFirst().getString("created_at")).put("binding", binding));
|
||||
return new JSONObject().put("jobId", row.getString("id")).put("acceptedEventId", row.getString("accepted_event_id"))
|
||||
.put("acceptedAt", events.getFirst().getString("created_at")).put("binding", binding);
|
||||
}
|
||||
|
||||
|
||||
@@ -1773,8 +1826,8 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(409, "Device observation is inconsistent");
|
||||
}
|
||||
return (new JSONObject().put("acceptance", acceptance).put("recordedAt", row.getString("created_at"))
|
||||
.put("state", row.getString("status")).put("spoolerJobId", event.opt("spoolerJobId") == null ? JSONObject.NULL : event.opt("spoolerJobId")));
|
||||
return new JSONObject().put("acceptance", acceptance).put("recordedAt", row.getString("created_at"))
|
||||
.put("state", row.getString("status")).put("spoolerJobId", event.opt("spoolerJobId") == null ? JSONObject.NULL : event.opt("spoolerJobId"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1782,12 +1835,12 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Find an accepted request during durable-effect reconciliation.
|
||||
***************************************************************************/
|
||||
public JSONObject findByIdempotencyKey(String key) throws SQLException
|
||||
public @Nullable JSONObject findByIdempotencyKey(String key) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
|
||||
return (rows.isEmpty() ? null : job(rows.getFirst()));
|
||||
return rows.isEmpty() ? null : job(rows.getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1799,7 +1852,7 @@ public class PrintingService
|
||||
|
||||
|
||||
/** Read the exact committed row binding; a replacement queue cannot inherit history. */
|
||||
OperatorAcceptance findOperatorAcceptance(String key) throws SQLException
|
||||
@Nullable OperatorAcceptance findOperatorAcceptance(String key) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
@@ -1828,7 +1881,7 @@ public class PrintingService
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
return (purgeCompletedContent(connection, policy, new int[1], null));
|
||||
return purgeCompletedContent(connection, policy, new int[1], null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1837,12 +1890,12 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Commit each confirmed removal so a later failure cannot undo its receipt.
|
||||
***************************************************************************/
|
||||
private int purgeCompletedContent(Connection connection, PrintingRetentionPolicy policy, int[] progress, String leaseToken) throws SQLException
|
||||
private int purgeCompletedContent(Connection connection, PrintingRetentionPolicy policy, int[] progress, @Nullable String leaseToken) throws SQLException
|
||||
{
|
||||
java.util.Objects.requireNonNull(policy);
|
||||
if(!policy.enabled())
|
||||
{
|
||||
return (0);
|
||||
return 0;
|
||||
}
|
||||
String cutoff = Instant.now().minusSeconds(policy.days() * 86400L).toString();
|
||||
{
|
||||
@@ -1877,7 +1930,7 @@ public class PrintingService
|
||||
progress[0] += changed;
|
||||
}
|
||||
connection.commit();
|
||||
return (purged);
|
||||
return purged;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -1918,14 +1971,14 @@ public class PrintingService
|
||||
.put("lastRunError", row.optString("last_run_error")).put("eligibleBacklog", backlog);
|
||||
if(storageTableName != null)
|
||||
{
|
||||
result.put("storageTableName", storageTableName).put("storageBackendName", documentStorage.backendName(storageTableName))
|
||||
.put("bucketName", documentStorage.bucketName(storageTableName)).put("storagePrefix", documentStorage.prefix());
|
||||
result.put("storageTableName", storageTableName).put("storageBackendName", configuredStorage().backendName(storageTableName))
|
||||
.put("bucketName", configuredStorage().bucketName(storageTableName)).put("storagePrefix", configuredStorage().prefix());
|
||||
}
|
||||
else
|
||||
{
|
||||
result.put("storageMode", "LEGACY_DATABASE");
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1993,7 +2046,7 @@ public class PrintingService
|
||||
{
|
||||
if(update(connection, "UPDATE printing_settings SET lease_token=?, lease_expires_at=? WHERE tenant_id=? AND (lease_expires_at IS NULL OR lease_expires_at<?)", token, Instant.now().plusSeconds(3600).toString(), tenantId, Instant.now().toString()) == 0)
|
||||
{
|
||||
return (0);
|
||||
return 0;
|
||||
}
|
||||
connection.setAutoCommit(false);
|
||||
try
|
||||
@@ -2012,7 +2065,7 @@ public class PrintingService
|
||||
}
|
||||
update(connection, "UPDATE printing_settings SET last_run_at=?, last_run_count=?, last_run_error='', lease_token=NULL, lease_expires_at=NULL WHERE tenant_id=? AND lease_token=?", Instant.now().toString(), progress[0], tenantId, token);
|
||||
connection.commit();
|
||||
return (progress[0]);
|
||||
return progress[0];
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -2029,7 +2082,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Stop safely if another worker acquired an expired maintenance lease.
|
||||
***************************************************************************/
|
||||
private void renewRetentionLease(Connection connection, String token) throws SQLException
|
||||
private void renewRetentionLease(Connection connection, @Nullable String token) throws SQLException
|
||||
{
|
||||
if(token != null)
|
||||
{
|
||||
@@ -2066,7 +2119,20 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(502, "Stored print document integrity check failed");
|
||||
}
|
||||
return (bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** configureStorage installs the adapter before the optional table name,
|
||||
** before this service is exposed. Call only inside a non-null table branch;
|
||||
** inline storage does not require an adapter. No new runtime guard is added.
|
||||
***************************************************************************/
|
||||
@SuppressWarnings("NullAway")
|
||||
private PrintingDocumentStorage configuredStorage()
|
||||
{
|
||||
return documentStorage;
|
||||
}
|
||||
|
||||
|
||||
@@ -2074,6 +2140,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Historical table/bucket/namespace binding cannot be retargeted silently.
|
||||
***************************************************************************/
|
||||
@EnsuresNonNull("documentStorage")
|
||||
private void verifyStoredReference(JSONObject row)
|
||||
{
|
||||
if(documentStorage == null)
|
||||
@@ -2115,7 +2182,7 @@ public class PrintingService
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
connection.commit();
|
||||
return (0);
|
||||
return 0;
|
||||
}
|
||||
JSONObject orphan = rows.getFirst();
|
||||
if(query(connection, "SELECT id FROM printing_job WHERE id=? AND tenant_id=?", id, tenantId).isEmpty())
|
||||
@@ -2136,7 +2203,7 @@ public class PrintingService
|
||||
}
|
||||
update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId);
|
||||
connection.commit();
|
||||
return (1);
|
||||
return 1;
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
@@ -2153,7 +2220,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private JSONObject deviceJob(Connection connection, String deviceId, String jobId) throws SQLException
|
||||
{
|
||||
return (deviceJob(connection, deviceId, jobId, false));
|
||||
return deviceJob(connection, deviceId, jobId, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -2168,7 +2235,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(404, "Job not found");
|
||||
}
|
||||
return (rows.getFirst());
|
||||
return rows.getFirst();
|
||||
}
|
||||
|
||||
|
||||
@@ -2178,7 +2245,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private static JSONObject job(JSONObject row)
|
||||
{
|
||||
return (new JSONObject().put("jobId", row.getString("id")).put("deviceId", row.getString("device_id")).put("printerId", row.getString("printer_id")).put("title", row.getString("title")).put("format", row.getString("format")).put("contentSha256", row.getString("content_hash")).put("contentLength", row.getInt("content_length")).put("options", new JSONObject(row.getString("options_json"))).put("createdAt", row.getString("created_at")).put("status", row.getString("status")).put("message", row.optString("message")).put("spoolerJobId", row.optString("spooler_job_id")));
|
||||
return new JSONObject().put("jobId", row.getString("id")).put("deviceId", row.getString("device_id")).put("printerId", row.getString("printer_id")).put("title", row.getString("title")).put("format", row.getString("format")).put("contentSha256", row.getString("content_hash")).put("contentLength", row.getInt("content_length")).put("options", new JSONObject(row.getString("options_json"))).put("createdAt", row.getString("created_at")).put("status", row.getString("status")).put("message", row.optString("message")).put("spoolerJobId", row.optString("spooler_job_id"));
|
||||
}
|
||||
|
||||
|
||||
@@ -2186,7 +2253,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Run a bounded, parameterized query with lower-case column names.
|
||||
***************************************************************************/
|
||||
static List<JSONObject> query(Connection connection, String sql, Object... values) throws SQLException
|
||||
static List<JSONObject> query(Connection connection, String sql, @Nullable Object... values) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
@@ -2203,7 +2270,7 @@ public class PrintingService
|
||||
}
|
||||
result.add(row);
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2213,12 +2280,12 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Execute a parameterized mutation.
|
||||
***************************************************************************/
|
||||
static int update(Connection connection, String sql, Object... values) throws SQLException
|
||||
static int update(Connection connection, String sql, @Nullable Object... values) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
bind(statement, values);
|
||||
return (statement.executeUpdate());
|
||||
return statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2227,7 +2294,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Bind values without SQL interpolation.
|
||||
***************************************************************************/
|
||||
private static void bind(PreparedStatement statement, Object[] values) throws SQLException
|
||||
private static void bind(PreparedStatement statement, @Nullable Object[] values) throws SQLException
|
||||
{
|
||||
for(int index = 0; index < values.length; index++)
|
||||
{
|
||||
@@ -2240,7 +2307,7 @@ public class PrintingService
|
||||
/***************************************************************************
|
||||
** Reject missing caller data before persistence.
|
||||
***************************************************************************/
|
||||
private static String required(String value, String name)
|
||||
private static String required(@Nullable String value, String name)
|
||||
{
|
||||
int maximum = switch(name)
|
||||
{
|
||||
@@ -2254,7 +2321,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + name);
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -2268,7 +2335,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + name);
|
||||
}
|
||||
return (optionalText(value, name, maximum));
|
||||
return optionalText(value, name, maximum);
|
||||
}
|
||||
|
||||
|
||||
@@ -2295,7 +2362,7 @@ public class PrintingService
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + name + "; maximum " + maximum + " characters");
|
||||
}
|
||||
return (value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -2307,7 +2374,7 @@ public class PrintingService
|
||||
{
|
||||
byte[] bytes = new byte[32];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
|
||||
@@ -2317,7 +2384,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private static String hash(String value)
|
||||
{
|
||||
return (hash(value.getBytes(StandardCharsets.UTF_8)));
|
||||
return hash(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
@@ -2329,7 +2396,7 @@ public class PrintingService
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)));
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException exception)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -52,7 +52,7 @@ public final class PrintingUiApi
|
||||
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections; 2.3.0 adds authorized operator submission, reported queue-default media and the retained-source facade. */
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.3.0")
|
||||
return new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.3.0")
|
||||
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer; least-privilege operator catalog and printer-media-v1.")
|
||||
.withSpec(Session.class)
|
||||
.withSpec(Bind.class)
|
||||
@@ -73,14 +73,19 @@ public final class PrintingUiApi
|
||||
.withSpec(Command.class)
|
||||
.withSpec(OperatorCatalog.class)
|
||||
.withSpec(OperatorSubmit.class)
|
||||
.withSpec(OperatorRequest.class));
|
||||
.withSpec(OperatorRequest.class);
|
||||
}
|
||||
|
||||
|
||||
/** Current-actor, approved/exposed printer catalog; no administrative permission. */
|
||||
|
||||
/***************************************************************************
|
||||
** Current-actor, approved/exposed printer catalog; no administrative permission.
|
||||
***************************************************************************/
|
||||
public static class OperatorCatalog extends PrintingUiApiSpec
|
||||
{
|
||||
/** Public registry constructor. */
|
||||
/***********************************************************************
|
||||
** Public registry constructor.
|
||||
***********************************************************************/
|
||||
public OperatorCatalog()
|
||||
{
|
||||
super("OperatorCatalog", HttpMethod.GET, "/operator/printers", null, "OperatorCatalog");
|
||||
@@ -88,10 +93,15 @@ public final class PrintingUiApi
|
||||
}
|
||||
|
||||
|
||||
/** Operator document submission uses the existing durable queue. */
|
||||
|
||||
/***************************************************************************
|
||||
** Operator document submission uses the existing durable queue.
|
||||
***************************************************************************/
|
||||
public static class OperatorSubmit extends PrintingUiApiSpec
|
||||
{
|
||||
/** Public registry constructor. */
|
||||
/***********************************************************************
|
||||
** Public registry constructor.
|
||||
***********************************************************************/
|
||||
public OperatorSubmit()
|
||||
{
|
||||
super("OperatorSubmit", HttpMethod.POST, "/operator/jobs", "OperatorSubmit", "Job");
|
||||
@@ -99,10 +109,15 @@ public final class PrintingUiApi
|
||||
}
|
||||
|
||||
|
||||
/** Exact actor/request lookup replaces general job history access. */
|
||||
|
||||
/***************************************************************************
|
||||
** Exact actor/request lookup replaces general job history access.
|
||||
***************************************************************************/
|
||||
public static class OperatorRequest extends PrintingUiApiSpec
|
||||
{
|
||||
/** Public registry constructor. */
|
||||
/***********************************************************************
|
||||
** Public registry constructor.
|
||||
***********************************************************************/
|
||||
public OperatorRequest()
|
||||
{
|
||||
super("OperatorRequest", HttpMethod.GET, "/operator/requests/{requestId}", null, "Job");
|
||||
@@ -210,7 +225,7 @@ public final class PrintingUiApi
|
||||
Map<Integer, Response> responses = new LinkedHashMap<>(super.defineResponses());
|
||||
responses.put(200, new Response().withDescription("Verified retained document bytes")
|
||||
.withContent(Map.of("application/octet-stream", new com.kingsrook.qqq.openapi.model.Content().withSchema(new Schema().withType(Type.STRING).withFormat("binary")))));
|
||||
return (responses);
|
||||
return responses;
|
||||
}
|
||||
|
||||
/** Do not JSON-serialize document contents or include them in logs. */
|
||||
|
||||
@@ -43,6 +43,7 @@ import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/** Native secured QQQ specs: session setup and credential scope execute before request construction. */
|
||||
@@ -51,12 +52,12 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
private final String operation;
|
||||
private final String path;
|
||||
private final HttpMethod method;
|
||||
private final String requestSchema;
|
||||
private final @Nullable String requestSchema;
|
||||
private final String responseSchema;
|
||||
|
||||
|
||||
/** Immutable operation metadata; request credentials never live on shared spec instances. */
|
||||
protected PrintingUiApiSpec(String operation, HttpMethod method, String path, String requestSchema, String responseSchema)
|
||||
protected PrintingUiApiSpec(String operation, HttpMethod method, String path, @Nullable String requestSchema, String responseSchema)
|
||||
{
|
||||
this.operation = operation;
|
||||
this.method = method;
|
||||
@@ -70,18 +71,18 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(method).withPath(PrintingUiApi.PREFIX + path)
|
||||
return new BasicOperation().withHttpMethod(method).withPath(PrintingUiApi.PREFIX + path)
|
||||
.withOperationId("printing" + operation).withShortSummary("Printing " + operation)
|
||||
.withLongDescription(operation.startsWith("Operator") ? "Requires the current QQQ user session, printing_operator or printing_admin, current tenant and native destination record access. Submissions additionally require printingSubmit process and credential/channel write authority."
|
||||
: "Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication."));
|
||||
: "Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication.");
|
||||
}
|
||||
|
||||
|
||||
/** Typed request bodies use the same wire vocabulary as the native client. */
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
protected @Nullable Schema defineRequestBodySchema()
|
||||
{
|
||||
return (requestSchema == null ? null : PrintingApiSchemas.ref(requestSchema));
|
||||
return requestSchema == null ? null : PrintingApiSchemas.ref(requestSchema);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +90,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return (PrintingApiSchemas.name(responseSchema));
|
||||
return PrintingApiSchemas.name(responseSchema);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +98,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (PrintingApiSchemas.components());
|
||||
return PrintingApiSchemas.components();
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +130,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
parameters.add(new Parameter().withName("limit").withIn(In.QUERY).withSchema(new Schema().withType(Type.INTEGER)).withDescription("1 through 200; default 50."));
|
||||
parameters.add(new Parameter().withName("after").withIn(In.QUERY).withSchema(PrintingApiSchemas.text(200)));
|
||||
}
|
||||
return (parameters);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +169,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
input.getPathParams().put("_verifiedDevice", PrintingAgentAuthorization.device(id, token, "Bind".equals(operation)));
|
||||
}
|
||||
return (input);
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +182,11 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
JSONObject result = switch(operation)
|
||||
{
|
||||
case "OperatorCatalog" -> PrintingOperatorAuthorization.catalog(input.getQueryParamInteger("limit") == null ? 50 : input.getQueryParamInteger("limit"), input.getQueryParam("after"));
|
||||
case "OperatorCatalog" ->
|
||||
{
|
||||
Integer requestedLimit = input.getQueryParamInteger("limit");
|
||||
yield PrintingOperatorAuthorization.catalog(requestedLimit == null ? 50 : requestedLimit, input.getQueryParam("after"));
|
||||
}
|
||||
case "OperatorSubmit" -> PrintingOperatorAuthorization.submit(new JSONObject(input.getBody()));
|
||||
case "OperatorRequest" -> PrintingOperatorAuthorization.observe(input.getPathParam("requestId"));
|
||||
default -> throw new PrintingException(400, "Unknown operator operation");
|
||||
@@ -239,7 +244,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
case "Content" ->
|
||||
{
|
||||
PrintingControlAuthorization.job(device, job);
|
||||
return (service.openContent(device, job));
|
||||
return service.openContent(device, job);
|
||||
}
|
||||
case "Event" ->
|
||||
{
|
||||
@@ -305,7 +310,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
}
|
||||
default -> throw new PrintingException(400, "Unknown printing operation");
|
||||
}
|
||||
return (result instanceof JSONObject json ? json.toMap() : result);
|
||||
return result instanceof JSONObject json ? json.toMap() : result;
|
||||
}
|
||||
|
||||
|
||||
@@ -327,7 +332,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
/** Mutation success does not assert a physical print outcome. */
|
||||
private static JSONObject accepted()
|
||||
{
|
||||
return (new JSONObject().put("accepted", true));
|
||||
return new JSONObject().put("accepted", true);
|
||||
}
|
||||
|
||||
|
||||
@@ -355,12 +360,12 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request", BasicErrorResponseV1.class.getSimpleName()),
|
||||
return List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Native QQQ sign-in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission, device proof or user binding denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Record not found", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.CONFLICT, "State or request conflict", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.GONE, "Request or content expired", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Bounded capacity reached", BasicErrorResponseV1.class.getSimpleName())));
|
||||
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Bounded capacity reached", BasicErrorResponseV1.class.getSimpleName()));
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ public class PrintingUiDemo extends AbstractQQQApplication
|
||||
instance.addBackend(new RDBMSBackendMetaData().withName("printing-db").withVendor("h2")
|
||||
.withHostName("mem").withDatabaseName("printing_ui_demo").withUsername("sa").withPassword("").withJdbcUrl(JDBC));
|
||||
new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName("printing-db").withService(service)).produce(instance, "printing");
|
||||
return (instance);
|
||||
return instance;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -56,8 +57,8 @@ public final class RetainedPrintFacade
|
||||
/** Fixed fields, not an arbitrary caller JSON document. */
|
||||
JSONObject json()
|
||||
{
|
||||
return (new JSONObject().put("owner", owner).put("opaqueReference", opaqueReference).put("sha256", sha256)
|
||||
.put("sizeBytes", sizeBytes).put("mediaType", mediaType));
|
||||
return new JSONObject().put("owner", owner).put("opaqueReference", opaqueReference).put("sha256", sha256)
|
||||
.put("sizeBytes", sizeBytes).put("mediaType", mediaType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ public final class RetainedPrintFacade
|
||||
}
|
||||
|
||||
/** Exact immutable device observation, separate from the server acceptance event. */
|
||||
public record Observation(Acceptance acceptance, String eventId, Instant recordedAt, String state, String spoolerJobId)
|
||||
public record Observation(Acceptance acceptance, String eventId, Instant recordedAt, String state, @Nullable String spoolerJobId)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ public final class RetainedPrintFacade
|
||||
Acceptance accepted = acceptance(result);
|
||||
checkDestination(accepted);
|
||||
prepared.check();
|
||||
return (accepted);
|
||||
return accepted;
|
||||
}
|
||||
catch(SQLException exception)
|
||||
{
|
||||
@@ -120,7 +121,7 @@ public final class RetainedPrintFacade
|
||||
}
|
||||
|
||||
/** Absence is returned only when neither an accepted job nor an unresolved upload exists. */
|
||||
public Acceptance find(Request request) throws QException
|
||||
public @Nullable Acceptance find(Request request) throws QException
|
||||
{
|
||||
Prepared prepared = prepare(request, false);
|
||||
try
|
||||
@@ -134,7 +135,7 @@ public final class RetainedPrintFacade
|
||||
Acceptance accepted = acceptance(row);
|
||||
checkDestination(accepted);
|
||||
prepared.check();
|
||||
return (accepted);
|
||||
return accepted;
|
||||
}
|
||||
catch(SQLException exception)
|
||||
{
|
||||
@@ -168,16 +169,16 @@ public final class RetainedPrintFacade
|
||||
throw new PrintingException(409, "Accepted print binding changed");
|
||||
}
|
||||
authority.authorizeObservation(accepted.source(), requestId);
|
||||
if(instance != QContext.getQInstance() || session != QContext.getQSession()
|
||||
|| !reporter.equals(PrintingOperatorAuthorization.principal()) || service != PrintingProcesses.currentService()
|
||||
|| authority != authority(service))
|
||||
if(!same(instance, QContext.getQInstance()) || !same(session, QContext.getQSession())
|
||||
|| !reporter.equals(PrintingOperatorAuthorization.principal()) || !same(service, PrintingProcesses.currentService())
|
||||
|| !same(authority, authority(service)))
|
||||
{
|
||||
throw new QException("Printing reporter authority changed");
|
||||
}
|
||||
requireStorage(false);
|
||||
checkDestination(accepted);
|
||||
return (new Observation(accepted, eventId, Instant.parse(proof.getString("recordedAt")), proof.getString("state"),
|
||||
proof.isNull("spoolerJobId") ? null : proof.getString("spoolerJobId")));
|
||||
return new Observation(accepted, eventId, Instant.parse(proof.getString("recordedAt")), proof.getString("state"),
|
||||
proof.isNull("spoolerJobId") ? null : proof.getString("spoolerJobId"));
|
||||
}
|
||||
catch(SQLException exception)
|
||||
{
|
||||
@@ -222,8 +223,8 @@ public final class RetainedPrintFacade
|
||||
{
|
||||
PrintingOperatorAuthorization.requireSubmission();
|
||||
}
|
||||
if(instance != QContext.getQInstance() || session != QContext.getQSession() || QContext.getQBackendTransaction() != null
|
||||
|| service != PrintingProcesses.currentService() || authority != authority(service) || !actor.equals(PrintingOperatorAuthorization.principal()))
|
||||
if(!same(instance, QContext.getQInstance()) || !same(session, QContext.getQSession()) || QContext.getQBackendTransaction() != null
|
||||
|| !same(service, PrintingProcesses.currentService()) || !same(authority, authority(service)) || !actor.equals(PrintingOperatorAuthorization.principal()))
|
||||
{
|
||||
throw new QException("Retained print authority changed");
|
||||
}
|
||||
@@ -246,7 +247,7 @@ public final class RetainedPrintFacade
|
||||
}
|
||||
});
|
||||
prepared.check();
|
||||
return (prepared);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
|
||||
@@ -283,14 +284,30 @@ public final class RetainedPrintFacade
|
||||
{
|
||||
for(var metadata : QContext.getQInstance().getQBits().values())
|
||||
{
|
||||
if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() == service && config.getRetainedSourceAuthority() != null)
|
||||
if(metadata.getConfig() instanceof PrintingQBitConfig config && same(config.getService(), service))
|
||||
{
|
||||
return (config.getRetainedSourceAuthority());
|
||||
RetainedPrintSourceAuthority configured = config.getRetainedSourceAuthority();
|
||||
if(configured != null)
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new QException("Retained printing is not configured");
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Exact instance identity for the in-flight authority captures. A replaced
|
||||
** instance, session, service or owner authority 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;
|
||||
}
|
||||
|
||||
/** Package-private checked transaction callbacks never originate in request JSON. */
|
||||
@FunctionalInterface
|
||||
interface Checked
|
||||
@@ -331,11 +348,11 @@ public final class RetainedPrintFacade
|
||||
{
|
||||
JSONObject binding = row.getJSONObject("binding");
|
||||
JSONObject source = binding.getJSONObject("source");
|
||||
return (new Acceptance(binding.getString("requestId"), row.getString("jobId"), row.getString("acceptedEventId"),
|
||||
return new Acceptance(binding.getString("requestId"), row.getString("jobId"), row.getString("acceptedEventId"),
|
||||
Instant.parse(row.getString("acceptedAt")), binding.getString("tenantId"), binding.getString("actorId"),
|
||||
new SourceReference(source.getString("owner"), source.getString("opaqueReference"), source.getString("sha256"),
|
||||
source.getLong("sizeBytes"), source.getString("mediaType")), binding.getString("deviceId"), binding.getString("printerId"), binding.getString("printerRecordId"),
|
||||
binding.getString("title"), binding.getJSONObject("options").toString()));
|
||||
binding.getString("title"), binding.getJSONObject("options").toString());
|
||||
}
|
||||
|
||||
/** Reject malformed Unicode, controls and oversized identifiers without logging values. */
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit stock and resolution choices for built-in diagnostic documents.
|
||||
*******************************************************************************/
|
||||
@@ -30,10 +34,20 @@ public enum TestPrintProfile
|
||||
A4("A4 - 210 x 297 mm", 0),
|
||||
LABEL_4X6_203("4 x 6 in label - 203 dpi", 203),
|
||||
LABEL_4X6_300("4 x 6 in label - 300 dpi", 300),
|
||||
LABEL_4X6_600("4 x 6 in label - 600 dpi", 600);
|
||||
LABEL_4X6_600("4 x 6 in label - 600 dpi", 600),
|
||||
LABEL_4X2_203("4 x 2 in label - 203 dpi", 203, 2),
|
||||
LABEL_4X2_300("4 x 2 in label - 300 dpi", 300, 2),
|
||||
LABEL_4X2_600("4 x 2 in label - 600 dpi", 600, 2),
|
||||
LABEL_4X1_203("4 x 1 in label - 203 dpi", 203, 1),
|
||||
LABEL_4X1_300("4 x 1 in label - 300 dpi", 300, 1),
|
||||
LABEL_4X1_600("4 x 1 in label - 600 dpi", 600, 1);
|
||||
|
||||
private static final int LEGACY_LABEL_HEIGHT_INCHES = 6;
|
||||
private static final double MM_PER_INCH = 25.4;
|
||||
|
||||
private final String label;
|
||||
private final int dpi;
|
||||
private final int heightInches;
|
||||
|
||||
|
||||
|
||||
@@ -41,9 +55,20 @@ public enum TestPrintProfile
|
||||
** Store a human label and printer density without guessing from a name.
|
||||
***************************************************************************/
|
||||
TestPrintProfile(String label, int dpi)
|
||||
{
|
||||
this(label, dpi, dpi > 0 ? LEGACY_LABEL_HEIGHT_INCHES : 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Labels are always four inches wide; the height is explicit stock data.
|
||||
***************************************************************************/
|
||||
TestPrintProfile(String label, int dpi, int heightInches)
|
||||
{
|
||||
this.label = label;
|
||||
this.dpi = dpi;
|
||||
this.heightInches = heightInches;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +76,7 @@ public enum TestPrintProfile
|
||||
/***************************************************************************
|
||||
** Validate before enqueueing. Omitted profiles retain legacy defaults.
|
||||
***************************************************************************/
|
||||
public static TestPrintProfile resolve(String format, String value) throws PrintingException
|
||||
public static TestPrintProfile resolve(String format, @Nullable String value) throws PrintingException
|
||||
{
|
||||
if(!"PDF".equals(format) && !"ZPL".equals(format))
|
||||
{
|
||||
@@ -68,9 +93,9 @@ public enum TestPrintProfile
|
||||
}
|
||||
if("ZPL".equals(format) && profile.dpi == 0)
|
||||
{
|
||||
throw new PrintingException(400, "ZPL test prints require a 4 x 6 label profile with the matching printer dpi");
|
||||
throw new PrintingException(400, "ZPL test prints require a label profile with the matching printer dpi");
|
||||
}
|
||||
return (profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +105,7 @@ public enum TestPrintProfile
|
||||
***************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
return label;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +115,63 @@ public enum TestPrintProfile
|
||||
***************************************************************************/
|
||||
public int getDpi()
|
||||
{
|
||||
return (dpi);
|
||||
return dpi;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Label height in inches; zero for page profiles.
|
||||
***************************************************************************/
|
||||
public int getHeightInches()
|
||||
{
|
||||
return heightInches;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Exact PDF media width in points (72 per inch); zero for page profiles.
|
||||
***************************************************************************/
|
||||
public int getWidthPoints()
|
||||
{
|
||||
return (dpi > 0 ? 4 * 72 : 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Exact PDF media height in points (72 per inch); zero for page profiles.
|
||||
***************************************************************************/
|
||||
public int getHeightPoints()
|
||||
{
|
||||
return (heightInches * 72);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only labels shorter than the legacy 4 x 6 stock need job-specific paper;
|
||||
** 4 x 6 and page profiles keep using the Windows queue default untouched.
|
||||
***************************************************************************/
|
||||
public boolean needsJobPaper(String format)
|
||||
{
|
||||
return ("PDF".equals(format) && dpi > 0 && heightInches != LEGACY_LABEL_HEIGHT_INCHES);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Actual-size paper for this job (never a media profile), or null when the
|
||||
** configured queue default applies. Millimetres are exact: 101.6 x 25.4/50.8.
|
||||
***************************************************************************/
|
||||
public @Nullable JSONObject jobOptions(String format)
|
||||
{
|
||||
if(!needsJobPaper(format))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new JSONObject().put("paperWidthMm", 4 * MM_PER_INCH).put("paperHeightMm", heightInches * MM_PER_INCH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ class PrintEnqueueEffectTargetTest
|
||||
assertEquals(EffectResult.Outcome.SENT, sent.outcome());
|
||||
assertNotNull(sent.providerRef());
|
||||
assertEquals(sent.providerRef(), target.deliver(request).providerRef());
|
||||
assertEquals(sent.providerRef(), service.findByIdempotencyKey(request.idempotencyKey()).getString("jobId"));
|
||||
assertEquals(sent.providerRef(), java.util.Objects.requireNonNull(service.findByIdempotencyKey(request.idempotencyKey()), "Expected durable idempotency receipt").getString("jobId"));
|
||||
ReconcileResult reconciled = target.reconcile(request);
|
||||
assertEquals(ReconcileResult.Outcome.FOUND, reconciled.outcome());
|
||||
assertEquals(sent.providerRef(), reconciled.providerRef());
|
||||
@@ -144,7 +144,7 @@ class PrintEnqueueEffectTargetTest
|
||||
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withNamespace("printing").withVersion("0.1.0-rc.1")
|
||||
.withConfig(new PrintingQBitConfig().withService(service)));
|
||||
instance.withSupplementalMetaData(new DurableJobsMetaData().withEffectTarget(TARGET, new QCodeReference(PrintEnqueueEffectTarget.class)));
|
||||
return (instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ class PrintEnqueueEffectTargetTest
|
||||
String device = service.enroll(code.put("deviceName", "Packing station").put("agentVersion", "test")).getString("deviceId");
|
||||
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "zebra").put("name", "Packing Zebra").put("formats", new JSONArray(List.of("ZPL")))));
|
||||
service.enablePrinter(device, "zebra", true);
|
||||
return (device);
|
||||
return device;
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,6 @@ class PrintEnqueueEffectTargetTest
|
||||
{
|
||||
Map<String, Serializable> payload = Map.of("deviceId", device, "printerId", "zebra", "title", "Durable test", "format", "ZPL",
|
||||
"contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(StandardCharsets.UTF_8)), "idempotencyKey", "ignored-payload-key");
|
||||
return (new EffectRequest(key, "print", JobEffectType.IDEMPOTENT_EXTERNAL, 42, 0, "shipment-1", payload, null, 1));
|
||||
return new EffectRequest(key, "print", JobEffectType.IDEMPOTENT_EXTERNAL, 42, 0, "shipment-1", payload, null, 1);
|
||||
}
|
||||
}
|
||||
@@ -326,7 +326,7 @@ class PrintingAdmissionTest
|
||||
{
|
||||
PrintingService service = new PrintingService(fixture.source, "tenant-a").withAdmissionLimits(limits);
|
||||
service.configureStorage(fixture.documents, "printing_documents");
|
||||
return (service);
|
||||
return service;
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ class PrintingAdmissionTest
|
||||
service.initialize();
|
||||
CountingDocuments documents = new CountingDocuments();
|
||||
service.configureStorage(documents, "printing_documents");
|
||||
return (new Fixture(source, service, documents, enroll(service, "first"), enroll(service, "second")));
|
||||
return new Fixture(source, service, documents, enroll(service, "first"), enroll(service, "second"));
|
||||
}
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ class PrintingAdmissionTest
|
||||
String device = service.enroll(service.createEnrollment("warehouse", name).put("deviceName", name).put("agentVersion", "test")).getString("deviceId");
|
||||
service.inventory(device, new JSONArray().put(printer("queue")));
|
||||
service.enablePrinter(device, "queue", true);
|
||||
return (device);
|
||||
return device;
|
||||
}
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ class PrintingAdmissionTest
|
||||
***************************************************************************/
|
||||
private static JSONObject printer(String id)
|
||||
{
|
||||
return (new JSONObject().put("printerId", id).put("name", "Synthetic " + id).put("status", "READY").put("formats", new JSONArray(List.of("ZPL"))));
|
||||
return new JSONObject().put("printerId", id).put("name", "Synthetic " + id).put("status", "READY").put("formats", new JSONArray(List.of("ZPL")));
|
||||
}
|
||||
|
||||
|
||||
@@ -374,8 +374,8 @@ class PrintingAdmissionTest
|
||||
***************************************************************************/
|
||||
private static JSONObject request(String device, String key)
|
||||
{
|
||||
return (new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Admission regression").put("format", "ZPL")
|
||||
.put("contentBase64", Base64.getEncoder().encodeToString(DOCUMENT)).put("idempotencyKey", key));
|
||||
return new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Admission regression").put("format", "ZPL")
|
||||
.put("contentBase64", Base64.getEncoder().encodeToString(DOCUMENT)).put("idempotencyKey", key);
|
||||
}
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ class PrintingAdmissionTest
|
||||
***************************************************************************/
|
||||
private static JSONObject retry(String key)
|
||||
{
|
||||
return (new JSONObject().put("requestId", key).put("confirmDuplicateRisk", true));
|
||||
return new JSONObject().put("requestId", key).put("confirmDuplicateRisk", true);
|
||||
}
|
||||
|
||||
|
||||
@@ -408,7 +408,7 @@ class PrintingAdmissionTest
|
||||
{
|
||||
PrintingException exception = assertThrows(PrintingException.class, operation::call);
|
||||
assertEquals(429, exception.getStatus());
|
||||
assertFalse(exception.getMessage().isBlank());
|
||||
assertFalse(java.util.Objects.requireNonNull(exception.getMessage()).isBlank());
|
||||
}
|
||||
|
||||
|
||||
@@ -439,12 +439,12 @@ class PrintingAdmissionTest
|
||||
try
|
||||
{
|
||||
operation.call();
|
||||
return ("accepted");
|
||||
return "accepted";
|
||||
}
|
||||
catch(PrintingException exception)
|
||||
{
|
||||
assertEquals(429, exception.getStatus());
|
||||
return ("quota");
|
||||
return "quota";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +456,7 @@ class PrintingAdmissionTest
|
||||
private static PrintingAdmissionLimits limits(int deviceJobs, int tenantJobs, long deviceBytes, long tenantBytes, long deviceRetained,
|
||||
long tenantRetained, int deviceRate, int tenantRate, int devicePrinters, int tenantPrinters)
|
||||
{
|
||||
return (new PrintingAdmissionLimits(deviceJobs, tenantJobs, deviceBytes, tenantBytes, deviceRetained, tenantRetained, deviceRate, tenantRate, devicePrinters, tenantPrinters));
|
||||
return new PrintingAdmissionLimits(deviceJobs, tenantJobs, deviceBytes, tenantBytes, deviceRetained, tenantRetained, deviceRate, tenantRate, devicePrinters, tenantPrinters);
|
||||
}
|
||||
|
||||
|
||||
@@ -466,7 +466,7 @@ class PrintingAdmissionTest
|
||||
***************************************************************************/
|
||||
private static int count(DriverDataSource source, String table) throws Exception
|
||||
{
|
||||
return (Integer.parseInt(scalar(source, "SELECT COUNT(*) FROM " + table)));
|
||||
return Integer.parseInt(scalar(source, "SELECT COUNT(*) FROM " + table));
|
||||
}
|
||||
|
||||
|
||||
@@ -485,7 +485,7 @@ class PrintingAdmissionTest
|
||||
try(var rows = statement.executeQuery())
|
||||
{
|
||||
assertTrue(rows.next());
|
||||
return (rows.getString(1));
|
||||
return rows.getString(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,7 +547,7 @@ class PrintingAdmissionTest
|
||||
@Override
|
||||
public String binding(String table)
|
||||
{
|
||||
return ("quota-fixture");
|
||||
return "quota-fixture";
|
||||
}
|
||||
|
||||
|
||||
@@ -580,7 +580,7 @@ class PrintingAdmissionTest
|
||||
{
|
||||
throw new SQLException("Synthetic object was removed");
|
||||
}
|
||||
return (content.clone());
|
||||
return content.clone();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ class PrintingCommandStatusTest
|
||||
private static DriverDataSource source(PrintingProcessesTest.Fixture fixture)
|
||||
{
|
||||
RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(fixture.instance().getTable(PrintingTables.COMMAND).getBackendName());
|
||||
return (new DriverDataSource(backend.getJdbcUrl(), "sa", ""));
|
||||
return new DriverDataSource(backend.getJdbcUrl(), "sa", "");
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ class PrintingCommandStatusTest
|
||||
offline.setString(2, device);
|
||||
offline.executeUpdate();
|
||||
}
|
||||
return (id);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,6 @@ class PrintingCommandStatusTest
|
||||
input.setTableName(PrintingTables.COMMAND);
|
||||
input.setPrimaryKey(id);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new GetAction().execute(input).getRecord());
|
||||
return new GetAction().execute(input).getRecord();
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
import io.javalin.Javalin;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -49,7 +50,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
class PrintingContractTest
|
||||
{
|
||||
private static final String ADMIN = "contract-test-administrator-token";
|
||||
private static final Path FIXTURES = Path.of(System.getProperty("printing.protocol.fixtures", "../qqq-print-protocol/fixtures"));
|
||||
private static final Path FIXTURES = Path.of(System.getProperty("printing.protocol.fixtures", "src/test/resources/protocol-fixtures"));
|
||||
|
||||
|
||||
/** Default hosts cannot consume enrollment codes through the legacy bearer transport. */
|
||||
@@ -212,7 +213,7 @@ class PrintingContractTest
|
||||
String token = service.enroll(fixture("enroll.request.json").put("enrollmentCode", code)).getString("deviceToken");
|
||||
PrintingRoutes routes = new PrintingRoutes(service, context -> false);
|
||||
Javalin app = Javalin.create(routes::acceptJavalinConfig).start("127.0.0.1", 0);
|
||||
try(Socket socket = new Socket("127.0.0.1", app.port()))
|
||||
try(Socket socket = new Socket(java.net.InetAddress.getByAddress(new byte[] {127, 0, 0, 1}), app.port()))
|
||||
{
|
||||
socket.setSoTimeout(250);
|
||||
String headers = "POST " + PrintingRoutes.BASE + "/device/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:" + app.port()
|
||||
@@ -240,7 +241,7 @@ class PrintingContractTest
|
||||
***************************************************************************/
|
||||
private static JSONObject fixture(String name) throws Exception
|
||||
{
|
||||
return (new JSONObject(Files.readString(FIXTURES.resolve(name))));
|
||||
return new JSONObject(Files.readString(FIXTURES.resolve(name)));
|
||||
}
|
||||
|
||||
|
||||
@@ -269,7 +270,7 @@ class PrintingContractTest
|
||||
/***************************************************************************
|
||||
** Keep binary content intact while constructing authenticated HTTP messages.
|
||||
***************************************************************************/
|
||||
private static HttpResponse<byte[]> send(HttpClient client, String base, String method, String path, String token, JSONObject body) throws Exception
|
||||
private static HttpResponse<byte[]> send(HttpClient client, String base, String method, String path, @Nullable String token, @Nullable JSONObject body) throws Exception
|
||||
{
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(base + path));
|
||||
if(token != null)
|
||||
@@ -281,7 +282,7 @@ class PrintingContractTest
|
||||
request.header("Content-Type", "application/json");
|
||||
}
|
||||
request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body.toString()));
|
||||
return (client.send(request.build(), HttpResponse.BodyHandlers.ofByteArray()));
|
||||
return client.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +293,7 @@ class PrintingContractTest
|
||||
private static JSONObject json(HttpResponse<byte[]> response, int expected)
|
||||
{
|
||||
assertEquals(expected, response.statusCode());
|
||||
return (new JSONObject(new String(response.body(), java.nio.charset.StandardCharsets.UTF_8)));
|
||||
return new JSONObject(new String(response.body(), java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -277,22 +277,22 @@ class PrintingDeviceControlsTest
|
||||
/** Explicit fresh action identity, retained unchanged by callers when retrying. */
|
||||
private static JSONObject action(String action)
|
||||
{
|
||||
return (new JSONObject().put("requestId", UUID.randomUUID().toString()).put("issuedAt", Instant.now().toString()).put("action", action));
|
||||
return new JSONObject().put("requestId", UUID.randomUUID().toString()).put("issuedAt", Instant.now().toString()).put("action", action);
|
||||
}
|
||||
|
||||
|
||||
/** Advertise only the two implemented remote operations. */
|
||||
private static JSONObject poll()
|
||||
{
|
||||
return (new JSONObject().put("controlVersion", 1).put("agentVersion", "test").put("lastAppliedRevision", 0)
|
||||
.put("capabilities", new JSONArray(List.of("REFRESH_INVENTORY", "CANCEL_SPOOL"))));
|
||||
return new JSONObject().put("controlVersion", 1).put("agentVersion", "test").put("lastAppliedRevision", 0)
|
||||
.put("capabilities", new JSONArray(List.of("REFRESH_INVENTORY", "CANCEL_SPOOL")));
|
||||
}
|
||||
|
||||
|
||||
/** A synthetic command observation carries no arbitrary executable data. */
|
||||
private static JSONObject receipt(String outcome)
|
||||
{
|
||||
return (new JSONObject().put("receiptId", UUID.randomUUID().toString()).put("outcome", outcome).put("completedAt", Instant.now().toString()));
|
||||
return new JSONObject().put("receiptId", UUID.randomUUID().toString()).put("outcome", outcome).put("completedAt", Instant.now().toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -318,20 +318,20 @@ class PrintingDeviceControlsTest
|
||||
/** Pair without sharing another device's credential. */
|
||||
private JSONObject enroll() throws Exception
|
||||
{
|
||||
return (service.enroll(service.createEnrollment("warehouse", "station").put("deviceName", "station").put("agentVersion", "test")));
|
||||
return service.enroll(service.createEnrollment("warehouse", "station").put("deviceName", "station").put("agentVersion", "test"));
|
||||
}
|
||||
|
||||
/** Use normal admission and retained document accounting. */
|
||||
private String submit(String key) throws Exception
|
||||
{
|
||||
return (service.submit(new JSONObject().put("deviceId", device).put("printerId", "zebra").put("title", "Synthetic")
|
||||
.put("format", "ZPL").put("contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(StandardCharsets.UTF_8))).put("idempotencyKey", key)).getString("jobId"));
|
||||
return service.submit(new JSONObject().put("deviceId", device).put("printerId", "zebra").put("title", "Synthetic")
|
||||
.put("format", "ZPL").put("contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(StandardCharsets.UTF_8))).put("idempotencyKey", key)).getString("jobId");
|
||||
}
|
||||
|
||||
/** Apply through the same facade used by authenticated native and HTTP adapters. */
|
||||
private JSONObject act(String action) throws Exception
|
||||
{
|
||||
return (service.deviceAction(device, action(action), "admin"));
|
||||
return service.deviceAction(device, action(action), "admin");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,10 +214,10 @@ class PrintingDeviceProcessesTest
|
||||
fixture.service().cancel(fixture.device(), canceled);
|
||||
submit(fixture.service(), fixture.otherDevice());
|
||||
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(fixture.instance().getWidget("printing_device_queue"));
|
||||
input.setQueryParams(Map.of("id", fixture.device(), "joinName", input.getWidgetMetaData().getDefaultValues().get("joinName").toString()));
|
||||
input.setQueryParams(Map.of("id", fixture.device(), "joinName", java.util.Objects.requireNonNull(input.getWidgetMetaData().getDefaultValues().get("joinName")).toString()));
|
||||
var data = (ChildRecordListData) new ChildRecordListRenderer().render(input).getWidgetData();
|
||||
assertEquals(List.of(queued), data.getQueryOutput().getRecords().stream().map(row -> row.getValueString("id")).toList());
|
||||
input.setQueryParams(Map.of("id", fixture.foreignDevice(), "joinName", input.getWidgetMetaData().getDefaultValues().get("joinName").toString()));
|
||||
input.setQueryParams(Map.of("id", fixture.foreignDevice(), "joinName", java.util.Objects.requireNonNull(input.getWidgetMetaData().getDefaultValues().get("joinName")).toString()));
|
||||
assertThrows(QException.class, () -> new ChildRecordListRenderer().render(input));
|
||||
}
|
||||
|
||||
@@ -278,9 +278,9 @@ class PrintingDeviceProcessesTest
|
||||
***************************************************************************/
|
||||
private static String submit(PrintingService service, String device) throws Exception
|
||||
{
|
||||
return (service.submit(new JSONObject().put("deviceId", device).put("printerId", "laser").put("title", "Control fixture")
|
||||
return service.submit(new JSONObject().put("deviceId", device).put("printerId", "laser").put("title", "Control fixture")
|
||||
.put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-1.4 fixture".getBytes(StandardCharsets.UTF_8)))
|
||||
.put("idempotencyKey", UUID.randomUUID().toString())).getString("jobId"));
|
||||
.put("idempotencyKey", UUID.randomUUID().toString())).getString("jobId");
|
||||
}
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ class PrintingDeviceProcessesTest
|
||||
new PrintingDeviceProcesses.Prepare().run(input, output);
|
||||
input.addValue(PrintingDeviceProcesses.REQUEST_ID, output.getValueString(PrintingDeviceProcesses.REQUEST_ID));
|
||||
input.addValue(PrintingDeviceProcesses.ISSUED_AT, output.getValueString(PrintingDeviceProcesses.ISSUED_AT));
|
||||
return (input);
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
@@ -308,6 +308,6 @@ class PrintingDeviceProcessesTest
|
||||
GetInput input = new GetInput();
|
||||
input.setTableName(table);
|
||||
input.setPrimaryKey(id);
|
||||
return (new GetAction().execute(input).getRecord());
|
||||
return new GetAction().execute(input).getRecord();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMe
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
@@ -126,7 +127,7 @@ class PrintingDocumentStorageTest
|
||||
fixture.storage.failUpload = true;
|
||||
assertThrows(SQLException.class, () -> fixture.service.submit(request(fixture.device, "compensated")));
|
||||
assertEquals(0, row(fixture.source, "SELECT COUNT(*) AS total FROM printing_document_orphan").getInt("TOTAL"));
|
||||
assertThrows(SQLException.class, () -> fixture.storage.get("printing_documents", fixture.storage.lastReference));
|
||||
assertThrows(SQLException.class, () -> fixture.storage.get("printing_documents", java.util.Objects.requireNonNull(fixture.storage.lastReference)));
|
||||
fixture.storage.failDelete = true;
|
||||
assertThrows(SQLException.class, () -> fixture.service.submit(request(fixture.device, "broken")));
|
||||
assertEquals(0, fixture.service.jobs().length());
|
||||
@@ -251,7 +252,7 @@ class PrintingDocumentStorageTest
|
||||
String device = service.enroll(service.createEnrollment("warehouse", "station").put("deviceName", "station").put("agentVersion", "test")).getString("deviceId");
|
||||
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic").put("formats", new JSONArray(List.of("ZPL")))));
|
||||
service.enablePrinter(device, "queue", true);
|
||||
return (new Fixture(source, service, storage, instance, device));
|
||||
return new Fixture(source, service, storage, instance, device);
|
||||
}
|
||||
|
||||
|
||||
@@ -281,7 +282,7 @@ class PrintingDocumentStorageTest
|
||||
}
|
||||
assertEquals(document.length, count);
|
||||
assertEquals(submitted.getString("contentSha256"), java.util.HexFormat.of().formatHex(digest.digest()));
|
||||
fixture.storage.put("printing_documents", fixture.storage.lastReference, "PDF", new byte[] {1, 2, 3});
|
||||
fixture.storage.put("printing_documents", java.util.Objects.requireNonNull(fixture.storage.lastReference), "PDF", new byte[] {1, 2, 3});
|
||||
try(var stream = fixture.service.openContent(fixture.device, submitted.getString("jobId")))
|
||||
{
|
||||
assertThrows(java.io.IOException.class, stream::readAllBytes);
|
||||
@@ -295,8 +296,8 @@ class PrintingDocumentStorageTest
|
||||
***************************************************************************/
|
||||
private static JSONObject request(String device, String key)
|
||||
{
|
||||
return (new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Synthetic").put("format", "ZPL")
|
||||
.put("contentBase64", Base64.getEncoder().encodeToString(DOCUMENT)).put("idempotencyKey", key));
|
||||
return new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Synthetic").put("format", "ZPL")
|
||||
.put("contentBase64", Base64.getEncoder().encodeToString(DOCUMENT)).put("idempotencyKey", key);
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +342,7 @@ class PrintingDocumentStorageTest
|
||||
{
|
||||
result.put(rows.getMetaData().getColumnLabel(index), rows.getObject(index) == null ? JSONObject.NULL : rows.getObject(index));
|
||||
}
|
||||
return (result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +377,7 @@ class PrintingDocumentStorageTest
|
||||
private boolean failDelete;
|
||||
private int failDeleteNumber = -1;
|
||||
private int deletes;
|
||||
private String lastReference;
|
||||
private @Nullable String lastReference;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -124,7 +125,7 @@ class PrintingExtractTest
|
||||
|
||||
|
||||
/** Real native input fields drive standard selected-ID or filter extraction. */
|
||||
private static RunBackendStepInput input(String ids)
|
||||
private static RunBackendStepInput input(@Nullable String ids)
|
||||
{
|
||||
RunBackendStepInput input = new RunBackendStepInput().withProcessName(PrintingDeviceProcesses.Action.PAUSE.process)
|
||||
.addValue(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, PrintingTables.DEVICE);
|
||||
@@ -132,7 +133,7 @@ class PrintingExtractTest
|
||||
{
|
||||
input.addValue("recordIds", ids);
|
||||
}
|
||||
return (input);
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +143,7 @@ class PrintingExtractTest
|
||||
PrintingExtract extract = new PrintingExtract();
|
||||
extract.preRun(input, new RunBackendStepOutput());
|
||||
extract.setRecordPipe(new RecordPipe(20));
|
||||
return (extract);
|
||||
return extract;
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +151,7 @@ class PrintingExtractTest
|
||||
public static class DenyRead extends PrintingReadPermission
|
||||
{
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
super.checkPermissionsThrowing(input, metadata);
|
||||
if(input instanceof QueryInput || input instanceof CountInput)
|
||||
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user