chore: integrate printing standards and strict Java compiler checks

Preserve current rendering, operator and retained-source behavior while integrating script quality, meaningful headers and strict Error Prone/NullAway contracts. Production and test compilation and the 189-case verification gate pass with zero failures and one intentional workload skip.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandClaude Sonnet 5.5 committed 2026-11-01 16:51:18 -06:00
commit e4639e3cca
132 files changed
+4618 -1716

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
.git
**/bin
**/obj
target
.local
.venv
+15
View File
@@ -0,0 +1,15 @@
{
"typeCheckingMode": "strict",
"pythonVersion": "3.13",
"include": [
"scripts/*.py"
],
"reportMissingTypeStubs": "error",
"reportUnnecessaryTypeIgnoreComment": "error",
"reportImplicitOverride": "error",
"exclude": [
"**/__pycache__"
],
"venvPath": "..",
"venv": ".venv"
}
+95 -43
View File
@@ -2,55 +2,107 @@
# SPDX-License-Identifier: Apache-2.0
"""Reject missing/malformed CodeQL evidence before enforcing the severity gate."""
import json
import math
import os
from pathlib import Path
from typing import TypedDict
RuleProperties = TypedDict("RuleProperties", {"security-severity": str | float | None}, total=False)
def check(directory, language, threshold):
if not math.isfinite(threshold) or not 0 <= threshold <= 10:
raise ValueError("Invalid security severity threshold")
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
files = sorted(Path(directory).glob("*.sarif"))
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
raise ValueError("Missing expected CodeQL SARIF report for " + language)
blocking = []
for path in files:
report = json.loads(path.read_text(encoding="utf-8"))
runs = report.get("runs")
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
raise ValueError("Invalid or empty SARIF runs: " + path.name)
for run in runs:
driver = run.get("tool", {}).get("driver", {})
results = run.get("results")
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
rules = {}
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
for rule in component.get("rules", []):
rules[rule["id"]] = rule
if not rules:
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
for invocation in run.get("invocations", []):
if invocation.get("executionSuccessful") is False:
raise ValueError("CodeQL invocation failed: " + path.name)
for result in results:
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
if rule_id not in rules:
raise ValueError("Result references missing rule metadata: " + path.name)
if result.get("suppressions"):
continue
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
if not math.isfinite(severity) or not 0 <= severity <= 10:
raise ValueError("Invalid rule severity: " + rule_id)
if severity >= threshold:
blocking.append((severity, rule_id))
for severity, rule_id in sorted(blocking, reverse=True):
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
return bool(blocking)
class Rule(TypedDict):
"""Keep the rule identity and optional severity metadata used by the gate."""
id: str
properties: RuleProperties
class Component(TypedDict, total=False):
"""Describe CodeQL driver or extension rule metadata."""
name: str
rules: list[Rule]
class Tool(TypedDict, total=False):
"""Group the driver and optional extension rule catalogs."""
driver: Component
extensions: list[Component]
class Result(TypedDict, total=False):
"""Describe only the result fields consumed by the severity gate."""
ruleId: str
rule: dict[str, str]
suppressions: list[object]
class Run(TypedDict, total=False):
"""Retain invocation success and findings for one analyzed language."""
tool: Tool
results: list[Result]
invocations: list[dict[str, bool]]
class Report(TypedDict, total=False):
"""Represent SARIF fields while retaining the explicit runtime validation."""
version: str
runs: list[Run]
MAX_SEVERITY = 10
def check(directory: str | Path, language: str, threshold: float) -> bool: # noqa: PLR0912 -- Keep the ordered evidence checks together; none may be skipped.
"""Validate complete CodeQL evidence and report findings at the threshold."""
if not math.isfinite(threshold) or not 0 <= threshold <= MAX_SEVERITY:
raise ValueError("Invalid security severity threshold")
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
files = sorted(Path(directory).glob("*.sarif"))
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
raise ValueError("Missing expected CodeQL SARIF report for " + language)
blocking: list[tuple[float, str]] = []
for path in files:
report: Report = json.loads(path.read_text(encoding="utf-8"))
runs = report.get("runs")
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
raise ValueError("Invalid or empty SARIF runs: " + path.name)
for run in runs:
driver = run.get("tool", {}).get("driver", {})
results = run.get("results")
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
rules: dict[str, Rule] = {}
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
for rule in component.get("rules", []):
rules[rule["id"]] = rule
if not rules:
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
for invocation in run.get("invocations", []):
if invocation.get("executionSuccessful") is False:
raise ValueError("CodeQL invocation failed: " + path.name)
for result in results:
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
if rule_id not in rules:
raise ValueError("Result references missing rule metadata: " + path.name)
if result.get("suppressions"):
continue
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
if not math.isfinite(severity) or not 0 <= severity <= MAX_SEVERITY:
raise ValueError("Invalid rule severity: " + rule_id)
if severity >= threshold:
blocking.append((severity, rule_id))
for severity, rule_id in sorted(blocking, reverse=True):
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
return bool(blocking)
if __name__ == "__main__":
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
+120
View File
@@ -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()
+146 -101
View File
@@ -2,127 +2,172 @@
# SPDX-License-Identifier: Apache-2.0
"""Fail closed unless scanners cover Maven's selected UI-host runtime graph."""
import argparse
from collections import Counter
import json
from pathlib import Path
import re
from collections import Counter
from pathlib import Path
from typing import TypedDict, cast
from urllib.parse import unquote, urlsplit
class Bom(TypedDict, total=False):
"""Describe the selected CycloneDX coordinate fields."""
bomFormat: str
components: list[dict[str, str]]
metadata: dict[str, dict[str, str]]
class TrivyReport(TypedDict, total=False):
"""Describe package coordinates, leaving runtime shape checks intact."""
Results: list[dict[str, list[dict[str, str]]]]
class OsvReport(TypedDict, total=False):
"""Keep result bodies untrusted until their explicit validation."""
results: list[object]
class OsvResult(TypedDict, total=False):
"""Describe source identity and packages after checking the object shape."""
source: dict[str, str]
packages: list[object]
class OsvEntry(TypedDict, total=False):
"""Describe the coordinate and optional vulnerability array being checked."""
package: dict[str, str]
vulnerabilities: object
REQUIRED = {
"com.kingsrook.qqq:qqq-frontend-next",
"com.kingsrook.qqq:qqq-backend-core",
"com.kingsrook.qqq:qqq-backend-module-filesystem",
"com.google.guava:guava",
"com.h2database:h2",
"com.kingsrook.qqq:qqq-frontend-next",
"com.kingsrook.qqq:qqq-backend-core",
"com.kingsrook.qqq:qqq-backend-module-filesystem",
"com.google.guava:guava",
"com.h2database:h2",
}
REVIEWED_H2_VERSION = "2.4.240"
OMITTED = object()
def resolved(text):
packages = set()
for line in text.splitlines():
line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip().split(" -- ", 1)[0].strip()
if not line or line == "The following files have been resolved:":
continue
parts = line.split(":")
if len(parts) not in (5, 6) or parts[-1] not in ("compile", "runtime"):
raise ValueError(f"Unexpected Maven runtime coordinate: {line}")
if not all(parts) or parts[2] != "jar":
raise ValueError(f"Invalid runtime JAR coordinate: {line}")
name = parts[0] + ":" + parts[1]
if parts[0].startswith(("org.junit", "org.opentest4j", "org.apiguardian")):
raise ValueError("Test library in runtime inventory")
packages.add((name, parts[-2]))
if not REQUIRED.issubset({name for name, _ in packages}):
raise ValueError("Maven inventory is empty or lacks required UI-host dependencies")
# The pinned dependency-review license exception matcher ignores versions.
# Keep any H2 metadata exception limited to the reviewed, unmodified runtime.
if {version for name, version in packages if name == "com.h2database:h2"} != {REVIEWED_H2_VERSION}:
raise ValueError("H2 runtime must match reviewed version " + REVIEWED_H2_VERSION)
return packages
def resolved(text: str) -> set[tuple[str, str]]:
"""Read Maven's selected runtime JARs and enforce the reviewed H2 pin."""
packages: set[tuple[str, str]] = set()
for raw_line in text.splitlines():
line = re.sub(r"\x1b\[[0-9;]*m", "", raw_line).strip().split(" -- ", 1)[0].strip()
if not line or line == "The following files have been resolved:":
continue
parts = line.split(":")
if len(parts) not in (5, 6) or parts[-1] not in ("compile", "runtime"):
raise ValueError(f"Unexpected Maven runtime coordinate: {line}")
if not all(parts) or parts[2] != "jar":
raise ValueError(f"Invalid runtime JAR coordinate: {line}")
name = parts[0] + ":" + parts[1]
if parts[0].startswith(("org.junit", "org.opentest4j", "org.apiguardian")):
raise ValueError("Test library in runtime inventory")
packages.add((name, parts[-2]))
if not REQUIRED.issubset({name for name, _ in packages}):
raise ValueError("Maven inventory is empty or lacks required UI-host dependencies")
# The pinned dependency-review license exception matcher ignores versions.
# Keep any H2 metadata exception limited to the reviewed, unmodified runtime.
if {version for name, version in packages if name == "com.h2database:h2"} != {REVIEWED_H2_VERSION}:
raise ValueError("H2 runtime must match reviewed version " + REVIEWED_H2_VERSION)
return packages
def components(report):
if not isinstance(report, dict) or report.get("bomFormat") != "CycloneDX":
raise ValueError("Expected CycloneDX inventory")
packages = set()
for component in report.get("components", []):
purl = urlsplit(component.get("purl", ""))
if purl.scheme != "pkg" or not purl.path.startswith("maven/"):
raise ValueError("Missing Maven package URL")
coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1)
group, artifact = coordinate.split("/", 1)
packages.add((unquote(group) + ":" + unquote(artifact), unquote(version)))
if not packages:
raise ValueError("Empty CycloneDX inventory")
return packages
def components(report: object) -> set[tuple[str, str]]:
"""Extract exact Maven coordinates from the scanner's CycloneDX inventory."""
typed_report = cast(Bom, report)
if not isinstance(report, dict) or typed_report.get("bomFormat") != "CycloneDX":
raise ValueError("Expected CycloneDX inventory")
packages: set[tuple[str, str]] = set()
for component in typed_report.get("components", []):
purl = urlsplit(component.get("purl", ""))
if purl.scheme != "pkg" or not purl.path.startswith("maven/"):
raise ValueError("Missing Maven package URL")
coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1)
group, artifact = coordinate.split("/", 1)
packages.add((unquote(group) + ":" + unquote(artifact), unquote(version)))
if not packages:
raise ValueError("Empty CycloneDX inventory")
return packages
def check(expected, bom, trivy=OMITTED):
actual = components(bom)
if actual != expected:
raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}")
if trivy is not OMITTED:
if not isinstance(trivy, dict) or not isinstance(trivy.get("Results"), list):
raise ValueError("Expected Trivy report object with Results array")
scanned = {(p["Name"], p["Version"]) for r in trivy.get("Results", []) for p in r.get("Packages", [])}
# Trivy also reports the root project represented by SBOM metadata.
root = bom.get("metadata", {}).get("component", {})
allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))}
if not expected.issubset(scanned) or not scanned.issubset(allowed):
raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}")
return len(expected)
def check(expected: set[tuple[str, str]], bom: object, trivy: object = OMITTED) -> int:
"""Require exact SBOM coverage and, when supplied, selected Trivy coverage."""
actual = components(bom)
if actual != expected:
raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}")
if trivy is not OMITTED:
typed_trivy = cast(TrivyReport, trivy)
if not isinstance(trivy, dict) or not isinstance(typed_trivy.get("Results"), list):
raise ValueError("Expected Trivy report object with Results array")
scanned = {(p["Name"], p["Version"]) for r in typed_trivy.get("Results", []) for p in r.get("Packages", [])}
# Trivy also reports the root project represented by SBOM metadata.
root = cast(Bom, bom).get("metadata", {}).get("component", {})
allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))}
if not expected.issubset(scanned) or not scanned.issubset(allowed):
raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}")
return len(expected)
def check_osv(expected, report):
if not isinstance(report, dict) or not isinstance(report.get("results"), list):
raise ValueError("Expected OSV report object with results array")
# OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names:
# https://github.com/google/osv-scanner/issues/3099
# Fail on ambiguous display coordinates instead of claiming full identity proof.
projected = Counter((name.split(":", 1)[1], version) for name, version in expected)
if any(count != 1 for count in projected.values()):
raise ValueError("Ambiguous Maven coordinates in pinned OSV report format")
scanned = Counter()
for result in report["results"]:
if not isinstance(result, dict) or not isinstance(result.get("source"), dict):
raise ValueError("Missing OSV source")
if result["source"].get("type") != "sbom" or not isinstance(result["source"].get("path"), str):
raise ValueError("Expected OSV SBOM source")
if not isinstance(result.get("packages"), list):
raise ValueError("Missing OSV package inventory; use --all-packages")
for entry in result["packages"]:
if not isinstance(entry, dict) or not isinstance(entry.get("package"), dict):
raise ValueError("Malformed OSV package")
package = entry["package"]
if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")):
raise ValueError("Invalid OSV Maven package coordinate")
if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list):
raise ValueError("Malformed OSV vulnerabilities")
scanned[(package["name"], package["version"])] += 1
if scanned != projected:
raise ValueError("OSV --all-packages inventory differs from selected Maven graph")
return len(expected)
def check_osv(expected: set[tuple[str, str]], report: object) -> int:
"""Reject missing, ambiguous, malformed or partial OSV package evidence."""
typed_report = cast(OsvReport, report)
if not isinstance(report, dict) or not isinstance(typed_report.get("results"), list):
raise ValueError("Expected OSV report object with results array")
# OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names:
# https://github.com/google/osv-scanner/issues/3099
# Fail on ambiguous display coordinates instead of claiming full identity proof.
projected = Counter((name.split(":", 1)[1], version) for name, version in expected)
if any(count != 1 for count in projected.values()):
raise ValueError("Ambiguous Maven coordinates in pinned OSV report format")
scanned: Counter[tuple[str, str]] = Counter()
for raw_result in cast(list[object], typed_report.get("results")):
result = cast(OsvResult, raw_result)
if not isinstance(raw_result, dict) or not isinstance(result.get("source"), dict):
raise ValueError("Missing OSV source")
source = cast(dict[str, str], result.get("source"))
if source.get("type") != "sbom" or not isinstance(source.get("path"), str):
raise ValueError("Expected OSV SBOM source")
if not isinstance(result.get("packages"), list):
raise ValueError("Missing OSV package inventory; use --all-packages")
for raw_entry in cast(list[object], result.get("packages")):
entry = cast(OsvEntry, raw_entry)
if not isinstance(raw_entry, dict) or not isinstance(entry.get("package"), dict):
raise ValueError("Malformed OSV package")
package = cast(dict[str, str], entry.get("package"))
if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")):
raise ValueError("Invalid OSV Maven package coordinate")
if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list):
raise ValueError("Malformed OSV vulnerabilities")
scanned[(package["name"], package["version"])] += 1
if scanned != projected:
raise ValueError("OSV --all-packages inventory differs from selected Maven graph")
return len(expected)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("resolved", type=Path)
parser.add_argument("bom", type=Path)
parser.add_argument("trivy", type=Path, nargs="?")
parser.add_argument("--osv", type=Path)
args = parser.parse_args()
expected = resolved(args.resolved.read_text(encoding="utf-8"))
count = check(expected,
json.loads(args.bom.read_text(encoding="utf-8")),
json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED)
if args.osv:
check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8")))
print(f"Verified {count} Maven runtime dependencies, including the UI frontend.")
def main() -> None:
"""Validate the exact evidence files supplied by the required CI job."""
parser = argparse.ArgumentParser()
parser.add_argument("resolved", type=Path)
parser.add_argument("bom", type=Path)
parser.add_argument("trivy", type=Path, nargs="?")
parser.add_argument("--osv", type=Path)
args = parser.parse_args()
expected = resolved(args.resolved.read_text(encoding="utf-8"))
count = check(expected, json.loads(args.bom.read_text(encoding="utf-8")), json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED)
if args.osv:
check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8")))
print(f"Verified {count} Maven runtime dependencies, including the UI frontend.")
if __name__ == "__main__":
main()
main()
+112
View File
@@ -0,0 +1,112 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools."""
import json
import shutil
import subprocess
import sys
from pathlib import Path, PurePosixPath
from typing import Any
PYTHON_SUFFIXES = (".py", ".pyi", ".pyw")
SCRIPT_PATTERN = ".github/scripts/*.py"
PYRIGHT_CONFIG = ".github"
JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx")
# Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`.
PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js"
def tracked_files(root: Path) -> list[str]:
"""List tracked and not-yet-ignored new paths so no source file can silently bypass the gates."""
# Fixed Git inventory operation; the root is an argument, never shell input.
output = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], check=True, capture_output=True).stdout.decode("utf-8") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
return [name for name in output.split("\u0000") if name]
def python_scope(tracked: list[str]) -> list[str]:
"""Return the tracked Python sources, rejecting any outside the one checked script root."""
candidates = sorted(name for name in tracked if name.endswith(PYTHON_SUFFIXES))
outside = [name for name in candidates if not PurePosixPath(name).full_match(SCRIPT_PATTERN)]
if outside:
raise ValueError("Tracked Python outside the checked script root: " + ", ".join(outside))
if not candidates:
raise ValueError("No owned Python scripts selected")
return candidates
def javascript_scope(root: Path, tracked: list[str]) -> list[str]:
"""Return tracked JavaScript/TypeScript sources, rejecting any outside the tsconfig include list."""
candidates = sorted(name for name in tracked if name.endswith(JAVASCRIPT_SUFFIXES))
tsconfig = root / "tsconfig.json"
if not tsconfig.is_file():
if candidates:
raise ValueError("Tracked JavaScript/TypeScript without a tsconfig.json: " + ", ".join(candidates))
return []
includes: list[str] = json.loads(tsconfig.read_text(encoding="utf-8"))["include"]
outside = [name for name in candidates if not any(PurePosixPath(name).full_match(pattern) for pattern in includes)]
if outside:
raise ValueError("Tracked JavaScript/TypeScript outside the tsconfig include list: " + ", ".join(outside))
if not candidates:
raise ValueError("No owned JavaScript sources selected")
return candidates
def run_pyright(root: Path, config: str) -> dict[str, Any]:
"""Run the locked npm Pyright through Node with JSON output and return its full report."""
node = shutil.which("node")
if node is None:
raise ValueError("Node.js is required to run the locked Pyright")
if not PYRIGHT_ENTRY.is_file():
raise ValueError(f"Locked Pyright is missing; run npm ci --ignore-scripts ({PYRIGHT_ENTRY})")
# Resolved Node and locked entry script; the config is an argument, never shell input.
completed = subprocess.run([node, str(PYRIGHT_ENTRY), "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Resolved Node and locked entry script; no shell.
try:
report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :])
int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing.
report["exitCode"] = completed.returncode
except (ValueError, KeyError) as error:
raise ValueError(f"Pyright produced no JSON summary (exit {completed.returncode}): {completed.stdout}{completed.stderr}") from error
return report
def check_pyright_report(report: dict[str, Any], expected: int) -> None:
"""Require the analyzed-file count to match the selected sources, with no errors or warnings."""
summary = report["summary"]
analyzed = summary["filesAnalyzed"]
if analyzed == 0 or analyzed != expected:
raise ValueError(f"Pyright analyzed {analyzed} files, expected {expected}")
if summary["errorCount"] or summary["warningCount"]:
details = [f"{item['file']}: {item['message']}" for item in report["generalDiagnostics"]]
raise ValueError("Pyright reported findings:\n" + "\n".join(details))
if report["exitCode"] != 0:
raise ValueError(f"Pyright exited {report['exitCode']} despite a clean summary")
def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None:
"""Run Ruff on the explicit source list so no selected file can be skipped."""
# Fixed interpreter module invocation; paths are arguments, never shell input.
if subprocess.run([sys.executable, "-m", "ruff", *arguments, "--no-cache", *sources], cwd=root, check=False).returncode != 0: # noqa: S603 -- Fixed interpreter and module; no shell.
raise ValueError("Ruff " + " ".join(arguments) + " failed")
def main() -> None:
"""Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count."""
root = Path(__file__).resolve().parents[2]
tracked = tracked_files(root)
sources = python_scope(tracked)
javascript = javascript_scope(root, tracked)
run_ruff(root, ["format", "--check"], sources)
run_ruff(root, ["check"], sources)
report = run_pyright(root, PYRIGHT_CONFIG)
check_pyright_report(report, len(sources))
print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope")
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.CalledProcessError) as error:
print(str(error), file=sys.stderr)
sys.exit(1)
+19 -17
View File
@@ -19,14 +19,14 @@ def selected(name: str, policy: dict[str, Any]) -> bool:
return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
def leading_comments(text: str, style: str) -> str:
def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch.
"""Read comments only, preserving required interpreter/XML directive ordering."""
text = text.removeprefix("\ufeff")
if text.startswith("<?xml"):
declaration = re.match(r"<\?xml[^?]*\?>", text)
if style != "xml" or declaration is None:
raise ValueError("Invalid XML declaration")
text = text[declaration.end():]
text = text[declaration.end() :]
elif text.startswith("#!"):
if style not in ("hash", "powershell", "slash"):
raise ValueError("Unexpected interpreter directive")
@@ -35,7 +35,7 @@ def leading_comments(text: str, style: str) -> str:
if style != "powershell":
raise ValueError("Unexpected PowerShell directive")
text = text.partition("\n")[2]
comments = []
comments: list[str] = []
while text.strip():
text = text.lstrip()
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
@@ -46,11 +46,9 @@ def leading_comments(text: str, style: str) -> str:
elif style == "slash" and text.startswith("//"):
line, _, text = text.partition("\n")
comments.append(line[2:])
elif ((style == "slash" and text.startswith("/*"))
or (style == "xml" and text.startswith("<!--"))
or (style == "powershell" and text.startswith("<#"))):
elif (style == "slash" and text.startswith("/*")) or (style == "xml" and text.startswith("<!--")) or (style == "powershell" and text.startswith("<#")):
opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style]
body, separator, rest = text[len(opening):].partition(closing)
body, separator, rest = text[len(opening) :].partition(closing)
if not separator:
raise ValueError("Unterminated leading comment")
comments.append(body)
@@ -72,19 +70,22 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])]
for notice in approved:
holder = re.escape(notice["holder"])
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
if re.search(copyright_pattern, comments) is None:
continue
spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments)
if spdx and any(value != notice["license"] for value in spdx):
continue
apache = all(phrase in comments for phrase in (
"Licensed under the Apache License, Version 2.0",
"you may not use this file except in compliance with the License.",
"https://www.apache.org/licenses/LICENSE-2.0",
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
"limitations under the License.",
))
apache = all(
phrase in comments
for phrase in (
"Licensed under the Apache License, Version 2.0",
"you may not use this file except in compliance with the License.",
"https://www.apache.org/licenses/LICENSE-2.0",
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
"limitations under the License.",
)
)
if suffix == ".java" and not apache:
continue
if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
@@ -94,8 +95,9 @@ def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
def check_repository(root: Path, policy: dict[str, Any]) -> int:
"""Check tracked sources without scanning build outputs or executing source."""
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000")
failures = []
# Fixed Git inventory operation; paths are arguments, never shell input.
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
failures: list[str] = []
count = 0
for name in tracked:
if not name or not selected(name, policy):
+87 -77
View File
@@ -2,107 +2,117 @@
# SPDX-License-Identifier: Apache-2.0
"""Synthetic regressions for missing, incorrect and empty scanner inventories."""
import copy
import importlib.util
from pathlib import Path
import sys
import subprocess
import tempfile
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any, cast, override
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("inventory", Path(__file__).with_name("check-maven-inventory.py"))
assert spec is not None and spec.loader is not None
inventory = importlib.util.module_from_spec(spec)
spec.loader.exec_module(inventory)
class InventoryTests(unittest.TestCase):
def setUp(self):
self.expected = {(name, "2.4.240" if name == "com.h2database:h2" else "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")}
self.text = "The following files have been resolved:\n" + "\n".join(
f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected)
self.bom = {"bomFormat": "CycloneDX", "components": [
{"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"}
for name, version in self.expected]}
self.trivy = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]}
"""Exercise selected dependency coverage using isolated synthetic reports."""
def test_complete_graph_without_findings_passes(self):
self.assertEqual(self.expected, inventory.resolved(self.text))
self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy))
@override
def setUp(self) -> None:
"""Create isolated synthetic evidence with cleanup after each test."""
self.expected = {(name, "2.4.240" if name == "com.h2database:h2" else "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")}
self.text = "The following files have been resolved:\n" + "\n".join(f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected)
self.bom: dict[str, Any] = {"bomFormat": "CycloneDX", "components": [{"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"} for name, version in self.expected]}
self.trivy: dict[str, Any] = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]}
def test_missing_or_unreviewed_h2_version_fails(self):
without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line)
for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"),
self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"):
with self.subTest(text=text), self.assertRaises(ValueError):
inventory.resolved(text)
def test_complete_graph_without_findings_passes(self) -> None:
"""Verify complete graph without findings passes."""
self.assertEqual(self.expected, inventory.resolved(self.text))
self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy))
def test_missing_frontend_and_empty_maven_fail(self):
for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)):
with self.assertRaises(ValueError):
inventory.resolved(text)
def test_missing_or_unreviewed_h2_version_fails(self) -> None:
"""Verify missing or unreviewed h2 version fails."""
without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line)
for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"), self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"):
with self.subTest(text=text), self.assertRaises(ValueError):
inventory.resolved(text)
def test_test_scope_or_test_jar_fails(self):
for extra in ("example:test:jar:1:test", "org.junit.jupiter:junit-jupiter:jar:5:runtime"):
with self.assertRaises(ValueError):
inventory.resolved(self.text + "\n" + extra)
def test_missing_frontend_and_empty_maven_fail(self) -> None:
"""Verify missing frontend and empty maven fail."""
for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)):
with self.assertRaises(ValueError):
inventory.resolved(text)
def test_empty_malformed_or_missing_component_fails(self):
for bom in ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]},
{"bomFormat": "CycloneDX", "components": self.bom["components"][1:]}):
with self.assertRaises(ValueError):
inventory.check(self.expected, bom)
def test_test_scope_or_test_jar_fails(self) -> None:
"""Verify test scope or test jar fails."""
for extra in ("example:test:jar:1:test", "org.junit.jupiter:junit-jupiter:jar:5:runtime"):
with self.assertRaises(ValueError):
inventory.resolved(self.text + "\n" + extra)
def test_wrong_selected_version_fails(self):
bad = copy.deepcopy(self.trivy)
for package in bad["Results"][0]["Packages"]:
if package["Name"] == "org.jspecify:jspecify":
package["Version"] = "1.0.1"
with self.assertRaises(ValueError):
inventory.check(self.expected, self.bom, bad)
def test_empty_malformed_or_missing_component_fails(self) -> None:
"""Verify empty malformed or missing component fails."""
for bom in cast(tuple[object, ...], ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]}, {"bomFormat": "CycloneDX", "components": self.bom["components"][1:]})):
with self.assertRaises(ValueError):
inventory.check(self.expected, bom)
def test_empty_or_dropped_trivy_package_fails(self):
for report in (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]}):
with self.assertRaises(ValueError):
inventory.check(self.expected, self.bom, report)
def test_wrong_selected_version_fails(self) -> None:
"""Verify wrong selected version fails."""
bad = copy.deepcopy(self.trivy)
for package in bad["Results"][0]["Packages"]:
if package["Name"] == "org.jspecify:jspecify":
package["Version"] = "1.0.1"
with self.assertRaises(ValueError):
inventory.check(self.expected, self.bom, bad)
def test_cli_supplied_null_trivy_fails(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
(directory / "resolved.txt").write_text(self.text, encoding="utf-8")
(directory / "bom.json").write_text(json.dumps(self.bom), encoding="utf-8")
(directory / "trivy.json").write_text("null", encoding="utf-8")
result = subprocess.run([sys.executable, str(Path(inventory.__file__)),
str(directory / "resolved.txt"), str(directory / "bom.json"),
str(directory / "trivy.json")], capture_output=True, text=True, check=False)
self.assertNotEqual(0, result.returncode)
self.assertIn("Expected Trivy report object", result.stderr)
def test_empty_or_dropped_trivy_package_fails(self) -> None:
"""Verify empty or dropped trivy package fails."""
for report in cast(tuple[object, ...], (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]})):
with self.assertRaises(ValueError):
inventory.check(self.expected, self.bom, report)
def osv(self):
return {"results": [{"source": {"path": "/src/printing.cdx.json", "type": "sbom"}, "packages": [
{"package": {"name": name.split(":", 1)[1], "version": version, "ecosystem": "Maven"}}
for name, version in self.expected]}]}
def test_cli_supplied_null_trivy_fails(self) -> None:
"""Verify cli supplied null trivy fails."""
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
(directory / "resolved.txt").write_text(self.text, encoding="utf-8")
(directory / "bom.json").write_text(json.dumps(self.bom), encoding="utf-8")
(directory / "trivy.json").write_text("null", encoding="utf-8")
assert inventory.__file__ is not None
result = subprocess.run([sys.executable, str(Path(inventory.__file__)), str(directory / "resolved.txt"), str(directory / "bom.json"), str(directory / "trivy.json")], capture_output=True, text=True, check=False) # noqa: S603 -- Execute this interpreter and the fixed sibling guard using synthetic paths, without a shell.
self.assertNotEqual(0, result.returncode)
self.assertIn("Expected Trivy report object", result.stderr)
def test_osv_all_clean_packages_pass_without_vulnerabilities(self):
self.assertEqual(6, inventory.check_osv(self.expected, self.osv()))
def osv(self) -> dict[str, Any]:
"""Build a clean OSV fixture whose identity matches the selected graph."""
return {"results": [{"source": {"path": "/src/printing.cdx.json", "type": "sbom"}, "packages": [{"package": {"name": name.split(":", 1)[1], "version": version, "ecosystem": "Maven"}} for name, version in self.expected]}]}
def test_osv_missing_malformed_or_partial_evidence_fails(self):
partial = self.osv()
partial["results"][0]["packages"].pop()
wrong = self.osv()
wrong["results"][0]["packages"][0]["package"]["version"] = "wrong"
duplicate = self.osv()
duplicate["results"][0]["packages"].append(duplicate["results"][0]["packages"][0])
for report in (None, {}, [], {"results": None}, {"results": []}, {"results": [{}]}, partial, wrong, duplicate):
with self.subTest(report=report), self.assertRaises(ValueError):
inventory.check_osv(self.expected, report)
def test_osv_all_clean_packages_pass_without_vulnerabilities(self) -> None:
"""Verify osv all clean packages pass without vulnerabilities."""
self.assertEqual(6, inventory.check_osv(self.expected, self.osv()))
def test_osv_ambiguous_group_projection_fails(self):
with self.assertRaises(ValueError):
inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv())
def test_osv_missing_malformed_or_partial_evidence_fails(self) -> None:
"""Verify osv missing malformed or partial evidence fails."""
partial = self.osv()
partial["results"][0]["packages"].pop()
wrong = self.osv()
wrong["results"][0]["packages"][0]["package"]["version"] = "wrong"
duplicate = self.osv()
duplicate["results"][0]["packages"].append(duplicate["results"][0]["packages"][0])
for report in cast(tuple[object, ...], (None, {}, [], {"results": None}, {"results": []}, {"results": [{}]}, partial, wrong, duplicate)):
with self.subTest(report=report), self.assertRaises(ValueError):
inventory.check_osv(self.expected, report)
def test_osv_ambiguous_group_projection_fails(self) -> None:
"""Verify osv ambiguous group projection fails."""
with self.assertRaises(ValueError):
inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv())
if __name__ == "__main__":
unittest.main()
unittest.main()
+76 -61
View File
@@ -2,91 +2,106 @@
# SPDX-License-Identifier: Apache-2.0
"""Exercise the exact CI guards using synthetic evidence only."""
import contextlib
import copy
import io
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType
from typing import Any, override
sys.dont_write_bytecode = True
def load(name):
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load(name: str) -> ModuleType:
"""Load the real sibling guard for synthetic evidence tests."""
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
codeql = load("check-codeql-sarif")
class SarifTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.directory = Path(self.temporary.name)
self.report = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]}
"""Reject incomplete SARIF evidence and preserve severity enforcement."""
def save(self, report):
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
@override
def setUp(self) -> None:
"""Create isolated synthetic evidence with cleanup after each test."""
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.directory = Path(self.temporary.name)
self.report: dict[str, Any] = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]}
def test_clean_report_with_zero_findings_passes(self):
self.save(self.report)
self.assertFalse(codeql.check(self.directory, "java-kotlin", 7))
def save(self, report: object) -> None:
"""Write the test body as SARIF without validating it first."""
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
def test_missing_report_or_wrong_language_fails(self):
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "csharp", 7)
def test_clean_report_with_zero_findings_passes(self) -> None:
"""Verify clean report with zero findings passes."""
self.save(self.report)
self.assertFalse(codeql.check(self.directory, "java-kotlin", 7))
def test_empty_or_malformed_report_fails(self):
for text in ["", "{", "null", "[]"]:
with self.subTest(text=text):
(self.directory / "java.sarif").write_text(text)
with self.assertRaises((ValueError, AttributeError)):
codeql.check(self.directory, "java-kotlin", 7)
def test_missing_report_or_wrong_language_fails(self) -> None:
"""Verify missing report or wrong language fails."""
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "csharp", 7)
def test_missing_runs_tool_rules_or_results_fails(self):
variants = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}]
for field in ["tool", "results"]:
bad = copy.deepcopy(self.report)
del bad["runs"][0][field]
variants.append(bad)
bad = copy.deepcopy(self.report)
bad["runs"][0]["tool"]["driver"]["rules"] = []
variants.append(bad)
for bad in variants:
with self.subTest(report=bad):
self.save(bad)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
def test_empty_or_malformed_report_fails(self) -> None:
"""Verify empty or malformed report fails."""
for text in ["", "{", "null", "[]"]:
with self.subTest(text=text):
(self.directory / "java.sarif").write_text(text)
with self.assertRaises((ValueError, AttributeError)):
codeql.check(self.directory, "java-kotlin", 7)
def test_high_finding_blocks_and_unknown_rule_fails(self):
self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}]
self.save(self.report)
with contextlib.redirect_stdout(io.StringIO()):
self.assertTrue(codeql.check(self.directory, "java-kotlin", 7))
self.report["runs"][0]["results"] = [{"ruleId": "missing"}]
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
def test_missing_runs_tool_rules_or_results_fails(self) -> None:
"""Verify missing runs tool rules or results fails."""
variants: list[dict[str, Any]] = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}]
for field in ["tool", "results"]:
bad = copy.deepcopy(self.report)
del bad["runs"][0][field]
variants.append(bad)
bad = copy.deepcopy(self.report)
bad["runs"][0]["tool"]["driver"]["rules"] = []
variants.append(bad)
for bad in variants:
with self.subTest(report=bad):
self.save(bad)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
def test_failed_invocation_and_invalid_threshold_fail(self):
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", float("nan"))
def test_high_finding_blocks_and_unknown_rule_fails(self) -> None:
"""Verify high finding blocks and unknown rule fails."""
self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}]
self.save(self.report)
with contextlib.redirect_stdout(io.StringIO()):
self.assertTrue(codeql.check(self.directory, "java-kotlin", 7))
self.report["runs"][0]["results"] = [{"ruleId": "missing"}]
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
def test_failed_invocation_and_invalid_threshold_fail(self) -> None:
"""Verify failed invocation and invalid threshold fail."""
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", float("nan"))
if __name__ == "__main__":
unittest.main()
unittest.main()
+238
View File
@@ -0,0 +1,238 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Prove the quality gates reject real violations, using private temporary fixtures and the locked tools."""
import importlib.util
import json
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType
from typing import Any, override
from unittest import mock
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[2]
GOOD_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
UNUSED_IMPORT_PYTHON = '"""Probe module."""\n\nimport os\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
TWO_SPACE_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
INCOMPATIBLE_PYTHON = '"""Probe module."""\n\nVALUE: int = "text"\n'
UNANNOTATED_PYTHON = '"""Probe module."""\n\n\ndef echo(value):\n """Return the value."""\n return value\n'
GOOD_JAVASCRIPT = "export function addNumbers(left, right)\n{\n return left + right;\n}\n"
BAD_LAYOUT_JAVASCRIPT = "export function addNumbers(left, right) {\n return left + right\n}\n"
GOOD_TYPED_JAVASCRIPT = "/** @type {number} */\nexport const value = 1;\n"
INCOMPATIBLE_JAVASCRIPT = '/** @type {number} */\nexport const value = "text";\n'
def load(name: str) -> ModuleType:
"""Load the real sibling guard for tests."""
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
guard = load("check-script-quality")
def run(command: list[str], cwd: Path, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
"""Run one real tool invocation without a shell and capture its diagnostics."""
return subprocess.run(command, cwd=cwd, input=stdin, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed tool paths and private temporary arguments; no shell.
def ruff(arguments: list[str], source: Path) -> subprocess.CompletedProcess[str]:
"""Run the locked Ruff with the repository configuration on one private fixture."""
return run([sys.executable, "-m", "ruff", *arguments, "--no-cache", "--config", str(ROOT / "ruff.toml"), str(source)], source.parent)
class ScopeTests(unittest.TestCase):
"""Reject tracked sources that fall outside the explicit tool roots."""
def test_python_outside_script_root_is_rejected(self) -> None:
"""Reject a tracked Python file outside the script root instead of letting it disappear."""
for stray in ("tools/helper.py", ".github/scripts/nested/helper.py", "helper.pyi"):
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the checked script root"):
guard.python_scope([".github/scripts/check-a.py", stray])
def test_python_scope_returns_sorted_script_root_and_rejects_empty(self) -> None:
"""Select only the direct script root, sorted, and reject selecting nothing."""
self.assertEqual([".github/scripts/a.py", ".github/scripts/b.py"], guard.python_scope([".github/scripts/b.py", "README.md", ".github/scripts/a.py"]))
with self.assertRaisesRegex(ValueError, "No owned Python"):
guard.python_scope(["README.md"])
def test_javascript_requires_tsconfig_include_scope(self) -> None:
"""Reject tracked JavaScript outside tsconfig include patterns, including .js/.cjs/.ts."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
(root / "tsconfig.json").write_text(json.dumps({"include": ["tools/**/*.mjs", "eslint.config.mjs"]}), encoding="utf-8")
self.assertEqual(["eslint.config.mjs", "tools/a/b.mjs", "tools/c.mjs"], guard.javascript_scope(root, ["tools/c.mjs", "tools/a/b.mjs", "eslint.config.mjs", "README.md"]))
for stray in ("src/z.js", "tools/w.cjs", "tools/v.ts", "tools/u.js"):
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the tsconfig include list"):
guard.javascript_scope(root, ["tools/c.mjs", stray])
with self.assertRaisesRegex(ValueError, "No owned JavaScript"):
guard.javascript_scope(root, ["README.md"])
def test_javascript_without_tsconfig_is_rejected(self) -> None:
"""Reject JavaScript in a repository that has no tsconfig."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
self.assertEqual([], guard.javascript_scope(root, ["README.md"]))
with self.assertRaisesRegex(ValueError, "without a tsconfig.json"):
guard.javascript_scope(root, ["tools/a.mjs"])
class PyrightCountTests(unittest.TestCase):
"""Reject Pyright reports that analyzed too few files or found anything."""
@staticmethod
def report(analyzed: int, errors: int = 0, warnings: int = 0) -> dict[str, Any]:
"""Build the exact Pyright JSON shape consumed by the guard."""
return {"summary": {"filesAnalyzed": analyzed, "errorCount": errors, "warningCount": warnings}, "generalDiagnostics": [{"file": "x.py", "message": "finding"}] * (errors + warnings), "exitCode": 1 if errors or warnings else 0}
def test_nonzero_exit_fails_even_with_clean_summary(self) -> None:
"""Fail a nonzero Pyright exit even when every count is clean."""
clean = self.report(4)
clean["exitCode"] = 1
with self.assertRaisesRegex(ValueError, "exited 1"):
guard.check_pyright_report(clean, 4)
def test_count_must_match_selected_sources(self) -> None:
"""Fail zero or mismatched analyzed counts even with no diagnostics."""
guard.check_pyright_report(self.report(4), 4)
for analyzed in (0, 3, 5):
with self.subTest(analyzed=analyzed), self.assertRaisesRegex(ValueError, "analyzed"):
guard.check_pyright_report(self.report(analyzed), 4)
def test_errors_and_warnings_fail(self) -> None:
"""Fail on errors and on warnings at the exact count."""
for report in (self.report(4, errors=1), self.report(4, warnings=1)):
with self.subTest(report=report), self.assertRaisesRegex(ValueError, "reported findings"):
guard.check_pyright_report(report, 4)
class RuffProbeTests(unittest.TestCase):
"""Run the real locked Ruff on bounded private fixtures."""
def test_compliant_fixture_passes_and_violations_fail(self) -> None:
"""Pass a compliant control and reject unused imports and wrong indentation."""
with tempfile.TemporaryDirectory() as name:
probe = Path(name) / "probe.py"
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
self.assertEqual(0, ruff(["check"], probe).returncode)
self.assertEqual(0, ruff(["format", "--check"], probe).returncode)
probe.write_text(UNUSED_IMPORT_PYTHON, encoding="utf-8", newline="\n")
unused = ruff(["check", "--output-format", "concise"], probe)
self.assertNotEqual(0, unused.returncode)
self.assertIn("F401", unused.stdout)
probe.write_text(TWO_SPACE_PYTHON, encoding="utf-8", newline="\n")
indented = ruff(["format", "--check"], probe)
self.assertNotEqual(0, indented.returncode)
self.assertIn("would be reformatted", (indented.stdout + indented.stderr).lower())
class PyrightProbeTests(unittest.TestCase):
"""Run the real locked Pyright with the repository strict settings on private projects."""
@staticmethod
def project(root: Path, include: list[str] | None = None) -> None:
"""Write the repository Pyright config, without its venv location, into a private project."""
config: dict[str, Any] = json.loads((ROOT / ".github/pyrightconfig.json").read_text(encoding="utf-8"))
del config["venvPath"], config["venv"]
if include is not None:
config["include"] = include
(root / "pyrightconfig.json").write_text(json.dumps(config), encoding="utf-8")
def test_compliant_control_passes_and_strict_violations_fail(self) -> None:
"""Analyze the control once with no findings and reject incompatible and unannotated code."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
self.project(root)
scripts = root / "scripts"
scripts.mkdir()
probe = scripts / "probe.py"
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
clean = guard.run_pyright(root, ".")
self.assertEqual(0, clean["exitCode"])
guard.check_pyright_report(clean, 1)
clean["exitCode"] = 1
with self.assertRaisesRegex(ValueError, "exited 1"):
guard.check_pyright_report(clean, 1)
probe.write_text(INCOMPATIBLE_PYTHON, encoding="utf-8", newline="\n")
report = guard.run_pyright(root, ".")
self.assertIn("reportAssignmentType", {item.get("rule") for item in report["generalDiagnostics"]})
with self.assertRaisesRegex(ValueError, "reported findings"):
guard.check_pyright_report(report, 1)
probe.write_text(UNANNOTATED_PYTHON, encoding="utf-8", newline="\n")
strict = guard.run_pyright(root, ".")
self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]})
def test_missing_locked_pyright_or_node_is_rejected(self) -> None:
"""Reject a missing locked Pyright entry or Node instead of silently skipping the type gate."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
with mock.patch.object(guard, "PYRIGHT_ENTRY", root / "missing.js"), self.assertRaisesRegex(ValueError, "Locked Pyright is missing"):
guard.run_pyright(root, ".")
with mock.patch.object(guard.shutil, "which", return_value=None), self.assertRaisesRegex(ValueError, "Node.js is required"):
guard.run_pyright(root, ".")
def test_zero_analyzed_files_cannot_pass(self) -> None:
"""Reject an empty include and a hidden-directory root config in the wrapper."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
scripts = root / ".github" / "scripts"
scripts.mkdir(parents=True)
(scripts / "probe.py").write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
for include in (["nothing/*.py"], [".github/scripts/*.py"]):
self.project(root, include)
with self.subTest(include=include), self.assertRaises(ValueError):
guard.check_pyright_report(guard.run_pyright(root, "."), 1)
class JavaScriptProbeTests(unittest.TestCase):
"""Run the real locked ESLint and TypeScript on bounded private input."""
@override
def setUp(self) -> None:
"""Skip only where tsconfig is absent, because the scope guard forbids JavaScript there."""
if not (ROOT / "tsconfig.json").is_file():
self.skipTest("No JavaScript tooling in this repository")
node = shutil.which("node")
self.assertIsNotNone(node)
self.node = str(node)
def eslint(self, text: str) -> subprocess.CompletedProcess[str]:
"""Lint text as an in-scope tool file using the repository configuration."""
return run([self.node, str(ROOT / "node_modules/eslint/bin/eslint.js"), "--stdin", "--stdin-filename", "tools/probe.mjs", "--max-warnings", "0", "--format", "json"], ROOT, text)
def test_eslint_rejects_wrong_layout(self) -> None:
"""Pass a compliant control and reject K&R braces, two-space indent and a missing semicolon."""
good = self.eslint(GOOD_JAVASCRIPT)
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
bad = self.eslint(BAD_LAYOUT_JAVASCRIPT)
self.assertNotEqual(0, bad.returncode)
rules = {message["ruleId"] for message in json.loads(bad.stdout)[0]["messages"]}
self.assertLessEqual({"@stylistic/brace-style", "@stylistic/semi", "@stylistic/indent"}, rules)
def test_checked_javascript_rejects_incompatible_assignment(self) -> None:
"""Reject a real incompatible assignment under the repository strict checkJs settings."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
(root / "tsconfig.json").write_text(json.dumps({"extends": (ROOT / "tsconfig.json").as_posix(), "compilerOptions": {"typeRoots": [(ROOT / "node_modules/@types").as_posix()]}, "include": ["probe.mjs"]}), encoding="utf-8")
tsc = [self.node, str(ROOT / "node_modules/typescript/bin/tsc"), "-p", str(root / "tsconfig.json")]
(root / "probe.mjs").write_text(GOOD_TYPED_JAVASCRIPT, encoding="utf-8", newline="\n")
good = run(tsc, root)
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
(root / "probe.mjs").write_text(INCOMPATIBLE_JAVASCRIPT, encoding="utf-8", newline="\n")
bad = run(tsc, root)
self.assertNotEqual(0, bad.returncode)
self.assertIn("TS2322", bad.stdout)
if __name__ == "__main__":
unittest.main()
+21 -17
View File
@@ -11,7 +11,6 @@ import unittest
from pathlib import Path
from typing import Any
SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py"))
assert SPEC is not None and SPEC.loader is not None
HEADERS = importlib.util.module_from_spec(SPEC)
@@ -32,8 +31,7 @@ limitations under the License."""
def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]:
"""Use the production styles with a small explicit owned fixture scope."""
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"],
"notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
class HeaderTests(unittest.TestCase):
@@ -51,12 +49,23 @@ class HeaderTests(unittest.TestCase):
with self.subTest(name=name):
HEADERS.check_notice(name, text, fixture_policy())
def test_year_range_separator_is_hyphen_or_en_dash_only(self) -> None:
"""Accept a hyphen or en dash between years and reject any other separator."""
for separator, accepted in (("-", True), (chr(0x2013), True), ("/", False), (chr(0x2014), False)):
text = f"# Copyright 2025{separator}2026 James Maes\n# SPDX-License-Identifier: MIT"
with self.subTest(separator=separator):
if accepted:
HEADERS.check_notice("src/a.py", text, fixture_policy())
else:
with self.assertRaises(ValueError):
HEADERS.check_notice("src/a.py", text, fixture_policy())
def test_required_first_lines_and_bom(self) -> None:
"""Accept shebangs, PowerShell requirements and XML declarations first."""
for name, prefix, body in (
("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")),
("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")),
("src/a.xml", "<?xml version=\"1.0\"?>\n", "<!--" + MIT + "--><root/>"),
("src/a.xml", '<?xml version="1.0"?>\n', "<!--" + MIT + "--><root/>"),
("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
):
with self.subTest(name=name):
@@ -67,21 +76,20 @@ class HeaderTests(unittest.TestCase):
for name, text in (
("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"),
("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"),
("src/a.xml", "<!--" + MIT + "--><?xml version=\"1.0\"?><root/>"),
("src/a.xml", "<!--" + MIT + '--><?xml version="1.0"?><root/>'),
):
with self.subTest(name=name), self.assertRaises(ValueError):
HEADERS.check_notice(name, text, fixture_policy())
def test_code_strings_and_late_comments_do_not_count(self) -> None:
"""Reject docstrings and comments after executable declarations."""
for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"",
"\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")):
for text in ('notice = "' + MIT.replace("\n", "\\n") + '"', '"""' + MIT + '"""', "import os\n# " + MIT.replace("\n", "\n# ")):
with self.subTest(text=text), self.assertRaises(ValueError):
HEADERS.check_notice("src/a.py", text, fixture_policy())
def test_wrong_syntax_and_unterminated_comments_fail(self) -> None:
"""Notice text alone is insufficient without a complete language comment."""
for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"):
for text in ("# " + MIT, "/*" + MIT, 'string text = "' + MIT + '";'):
with self.subTest(text=text), self.assertRaises(ValueError):
HEADERS.check_notice("src/a.cs", text, fixture_policy())
with self.assertRaises(ValueError):
@@ -89,8 +97,7 @@ class HeaderTests(unittest.TestCase):
def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None:
"""Do not conflate repository licenses or accept conflicting identifiers."""
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"),
MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
with self.subTest(text=text), self.assertRaises(ValueError):
HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
@@ -103,8 +110,7 @@ class HeaderTests(unittest.TestCase):
def test_java_requires_full_apache_block(self) -> None:
"""An abbreviated SPDX-only Java notice cannot replace scoped convention."""
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0",
APACHE.replace("limitations under the License.", "")):
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", APACHE.replace("limitations under the License.", "")):
with self.subTest(notice=notice), self.assertRaises(ValueError):
HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
@@ -121,9 +127,7 @@ class HeaderTests(unittest.TestCase):
"""New scripts enter the guard; data, generated and upstream files do not."""
self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY))
self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY))
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json",
"src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml",
"src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
with self.subTest(name=name):
self.assertFalse(HEADERS.selected(name, POLICY))
self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
@@ -140,11 +144,11 @@ class HeaderTests(unittest.TestCase):
"""A tracked new source fails while untracked generated output is ignored."""
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
subprocess.run(["git", "init", "-q", str(root)], check=True)
subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory.
root.joinpath("src").mkdir()
source = root / "src/a.py"
source.write_text("print(1)", encoding="utf-8")
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True)
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command.
with self.assertRaisesRegex(ValueError, "src/a.py"):
HEADERS.check_repository(root, fixture_policy())
source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8")
+20
View File
@@ -36,12 +36,31 @@ jobs:
with:
distribution: temurin
java-version: '21'
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24'
package-manager-cache: false
- name: Restore locked Pyright
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Set up uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: '0.13.6'
enable-cache: false
- name: Restore locked Python tools
run: uv sync --locked
- name: Verify script quality gates and rejection paths
run: |
uv run --locked --no-sync python .github/scripts/test-script-quality.py
uv run --locked --no-sync python .github/scripts/check-script-quality.py
- name: Verify owned source notices and rejection paths
run: |
python3 .github/scripts/test-source-headers.py
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: |
@@ -67,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:
+42
View File
@@ -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
+10
View File
@@ -12,3 +12,13 @@ dist/
*.iml
.DS_Store
Thumbs.db
# Local script-quality environments and tool caches.
.venv/
node_modules/
.ruff_cache/
__pycache__/
# Isolated .NET developer fixture output.
dev/**/bin/
dev/**/obj/
+1
View File
@@ -0,0 +1 @@
3.13
+11
View File
@@ -9,3 +9,14 @@ Changes belong in a pull request with a concise behavior description and actual
Keep the QBit compatible with QQQ 4.1+ and resolve runtime services through the current QQQ instance. Preserve tenant/device isolation and the distinction between local exposure (`available`) and server approval (`enabled`). Application callers must authorize destinations before submitting or emitting an effect.
Maintainers create reviewed version tags. CI produces JAR/POM/checksum assets and publishes GitHub prereleases for hyphenated versions. Maven Central publication, deployment and physical printer tests are separate operations; this workflow does not perform them.
## Script quality
Owned `.github/scripts/*.py` follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (config adapted from its `python` profile): three-space indentation, Ruff 0.17.3 format/lint (locked in `uv.lock`) and strict Pyright 1.1.414 (Microsoft's npm package, a development-only dependency locked in `package-lock.json` and run through Node 24). Restore with `uv sync --locked` and `npm ci --ignore-scripts`, then run:
```text
uv run --locked --no-sync python .github/scripts/test-script-quality.py
uv run --locked --no-sync python .github/scripts/check-script-quality.py
```
`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or any JavaScript/TypeScript (this repository has no tsconfig), and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print` only in `check-*.py`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass.
+206
View File
@@ -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
+5
View File
@@ -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
+30
View File
@@ -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>
+10
View File
@@ -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>
+4 -2
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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.
+41 -8
View File
@@ -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
View File
@@ -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();}
}
}
+10 -13
View File
@@ -1,15 +1,12 @@
<!--
Copyright 2026 Kingsrook, LLC
SPDX-License-Identifier: Apache-2.0
-->
<!-- 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>
+5 -1
View File
@@ -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);
+13
View File
@@ -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=="
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.403",
"rollForward": "latestPatch"
}
}
+41 -12
View File
@@ -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);
+410 -189
View File
@@ -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
View File
@@ -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");}}
}
+3 -1
View File
@@ -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
+3 -2
View File
@@ -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
+27 -6
View File
@@ -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();
}
+83 -13
View File
@@ -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();
}
+11 -16
View File
@@ -1,18 +1,13 @@
<!--
Copyright 2026 Kingsrook, LLC
SPDX-License-Identifier: Apache-2.0
-->
<!-- 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) });
+204 -85
View File
@@ -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));
}
+87 -47
View File
@@ -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.");
+130 -26
View File
@@ -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();
}
+13
View File
@@ -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=="
}
}
}
}
+61 -16
View File
@@ -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);
+56
View File
@@ -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.
+14
View File
@@ -19,6 +19,20 @@
- [ ] 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 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.
## 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.
- [ ] Qualify this integrated runtime again (compile gate, full test suite with
adversarial regressions, eight compiler probes, development fixture proofs); the
earlier 133-test/204-jar results describe the old baseline only.
- [ ] Observe the `development-clients` and `development-servers` jobs pass on hosted CI.
## Current local development qualification
- [x] Reuse production discovery and the shared major-2 gate in the optional
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@qrun-io/qbit-printing-script-quality",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qrun-io/qbit-printing-script-quality",
"version": "0.0.0",
"license": "Apache-2.0",
"devDependencies": {
"pyright": "1.1.414"
},
"engines": {
"node": ">=24"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pyright": {
"version": "1.1.414",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.414.tgz",
"integrity": "sha512-FPZZb51jepDX4eP7TEYDeNFtmE3WgwkkEcJpvH3/QmUSsj0EAy3LXu+xB4T/FWDejtsXlCfFr/rbWFjwuwuXww==",
"dev": true,
"license": "MIT",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@qrun-io/qbit-printing-script-quality",
"version": "0.0.0",
"private": true,
"description": "Development-only script quality tooling for QBit Printing; no runtime dependency.",
"license": "Apache-2.0",
"engines": {
"node": ">=24"
},
"devDependencies": {
"pyright": "1.1.414"
},
"packageManager": "npm@11.19.1"
}
+52 -2
View File
@@ -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>
+13
View File
@@ -0,0 +1,13 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
[project]
name = "qbit-printing-script-quality"
version = "0.0.0"
requires-python = ">=3.13"
[dependency-groups]
dev = ["ruff==0.17.3"]
[tool.uv]
package = false
+56
View File
@@ -0,0 +1,56 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152.
# KofTwentyTwo Python lint + format configuration (K22-CODE-PY). Copy to the repo root
# (or merge into [tool.ruff] in pyproject.toml). `ruff format --check` and `ruff check`
# run in CI and pre-commit with zero findings allowed.
target-version = "py313"
include = [".github/scripts/*.py"]
extend-exclude = [".venv", "node_modules", "dist", "target", "bin", "obj"]
# Kingsrook: no practical line-length limit (ruff's maximum is 320).
line-length = 320
# Kingsrook: 3-space indentation. Ruff's formatter supports it fully; pycodestyle's
# "multiple of four" rules (E111/E114) are therefore not selected.
indent-width = 3
[format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"
docstring-code-format = true
[lint]
select = [
"F", # pyflakes
"E4", "E7", "E9", "W", # pycodestyle errors that are layout-independent
"I", # isort: no wildcard imports, ordered
"N", # pep8-naming
"UP", # pyupgrade
"B", # bugbear
"S", # bandit security
"BLE", # blind except
"T20", # print statements: use logging (Kingsrook logging rule)
"ERA", # commented-out code (Kingsrook: no zombie code)
"FIX", # FIXME/TODO must be resolved or tracked
"TD", # TODO format: author and issue link
"PTH", # pathlib over os.path
"RET", "SIM", "C4", "PIE", "PL", "RUF",
"ANN", # type annotations on every function
"D", # docstrings on modules, classes, functions
"LOG", "G", # logging hygiene
]
ignore = [
"D203", # conflicts with D211
"D213", # conflicts with D212
"TD003", # issue link is enforced by review, not regex
]
[lint.per-file-ignores]
".github/scripts/check-*.py" = ["T201"] # CLI diagnostics are the public stdout contract, not application logging.
".github/scripts/test-*.py" = ["S101", "PLR2004"] # Synthetic assertions and explicit boundary values.
[lint.pydocstyle]
convention = "pep257"
[lint.flake8-tidy-imports]
ban-relative-imports = "all"
@@ -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;
/*******************************************************************************
@@ -119,9 +120,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 +132,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 +143,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 +151,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 +160,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 +218,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 +238,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 +290,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 +301,7 @@ public class PrintingProcesses implements BackendStep
{
throw new QException("Choose an accessible printer belonging to the selected device");
}
return (printer);
return printer;
}
@@ -310,7 +311,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 +319,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 +336,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,7 +344,7 @@ 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
{
@@ -351,7 +352,7 @@ public class PrintingProcesses implements BackendStep
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"));
return job.getString("jobId");
}
catch(java.sql.SQLException | PrintingException exception)
{
@@ -476,10 +477,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 +532,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 +548,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")));
}
@@ -1192,7 +1209,7 @@ public class PrintingService
{
throw new PrintingException(400, "Print options are too large");
}
return (options);
return options;
}
@@ -1205,9 +1222,9 @@ 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"))
return submit(new JSONObject().put("deviceId", deviceId).put("printerId", required(request.optString("printerId"), "printerId"))
.put("title", "QQQ print test").put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes))
.put("idempotencyKey", "device-test:" + deviceId + ":" + required(request.optString("requestId"), "requestId"))));
.put("idempotencyKey", "device-test:" + deviceId + ":" + required(request.optString("requestId"), "requestId")));
}
@@ -1225,7 +1242,7 @@ public class PrintingService
{
result.put(job(row));
}
return (result);
return result;
}
}
@@ -1249,7 +1266,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 +1288,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 +1313,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 +1322,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 +1339,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 +1358,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 +1378,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 +1407,7 @@ public class PrintingService
{
throw new PrintingException(410, "Print document expired under the host retention policy");
}
return (documentBytes(row));
return documentBytes(row);
}
}
@@ -1426,7 +1443,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 +1526,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 +1535,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 +1579,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 +1590,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 +1600,7 @@ public class PrintingService
***************************************************************************/
public JSONArray jobs() throws SQLException
{
return (jobsPage(200, null).getJSONArray("jobs"));
return jobsPage(200, null).getJSONArray("jobs");
}
@@ -1591,7 +1608,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 +1652,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 +1670,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 +1704,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 +1724,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 +1736,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 +1752,7 @@ public class PrintingService
{
throw new PrintingException(409, "Accepted source binding differs");
}
return (accepted(connection, row));
return accepted(connection, row);
}
@@ -1747,8 +1780,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 +1806,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 +1815,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 +1832,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 +1861,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 +1870,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 +1910,7 @@ public class PrintingService
progress[0] += changed;
}
connection.commit();
return (purged);
return purged;
}
catch(Exception exception)
{
@@ -1918,14 +1951,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 +2026,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 +2045,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 +2062,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 +2099,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 +2120,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 +2162,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 +2183,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 +2200,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 +2215,7 @@ public class PrintingService
{
throw new PrintingException(404, "Job not found");
}
return (rows.getFirst());
return rows.getFirst();
}
@@ -2178,7 +2225,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 +2233,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 +2250,7 @@ public class PrintingService
}
result.add(row);
}
return (result);
return result;
}
}
}
@@ -2213,12 +2260,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 +2274,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 +2287,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 +2301,7 @@ public class PrintingService
{
throw new PrintingException(400, "Invalid " + name);
}
return (value);
return value;
}
@@ -2268,7 +2315,7 @@ public class PrintingService
{
throw new PrintingException(400, "Invalid " + name);
}
return (optionalText(value, name, maximum));
return optionalText(value, name, maximum);
}
@@ -2295,7 +2342,7 @@ public class PrintingService
{
throw new PrintingException(400, "Invalid " + name + "; maximum " + maximum + " characters");
}
return (value);
return value;
}
@@ -2307,7 +2354,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 +2364,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 +2376,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;
}
}
@@ -63,7 +63,7 @@ public final class PrintingTestDocuments
***************************************************************************/
public static byte[] pdf() throws QException
{
return (pdf(TestPrintProfile.LETTER));
return pdf(TestPrintProfile.LETTER);
}
@@ -119,7 +119,7 @@ public final class PrintingTestDocuments
}
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
document.save(bytes);
return (bytes.toByteArray());
return bytes.toByteArray();
}
catch(IOException exception)
{
@@ -134,7 +134,7 @@ public final class PrintingTestDocuments
***************************************************************************/
public static byte[] zpl() throws QException
{
return (zpl(203));
return zpl(203);
}
@@ -144,7 +144,7 @@ public final class PrintingTestDocuments
***************************************************************************/
public static byte[] zpl(TestPrintProfile profile) throws QException
{
return (zpl(profile.getDpi()));
return zpl(profile.getDpi());
}
@@ -173,7 +173,7 @@ public final class PrintingTestDocuments
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));
return zpl.toString().getBytes(StandardCharsets.US_ASCII);
}
@@ -241,7 +241,7 @@ public final class PrintingTestDocuments
{
graphics.dispose();
}
return (image);
return image;
}
catch(IOException | FontFormatException exception)
{
@@ -261,7 +261,7 @@ public final class PrintingTestDocuments
{
throw new IOException("Bundled test-document font is missing");
}
return (stream);
return stream;
}
@@ -292,7 +292,7 @@ public final class PrintingTestDocuments
{
graphics.dispose();
}
return (image);
return image;
}
}
@@ -303,7 +303,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);
}
@@ -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()));
}
}
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more