Compare commits

..
Author SHA1 Message Date
james.maes 9ccdece03c Merge remote-tracking branch 'origin/main' into chore/server-source-standards-20261101 2026-11-01 12:12:29 -06:00
james.maesandCodex GPT-6 175c88b999 chore: enforce owned source copyright notices
Check real leading comments against explicit repository provenance and retain existing native style gates.

Co-Authored-By: Codex GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 12:11:01 -06:00
james.maesandCodex bb86b2d507 chore(standards): preserve owned-source copyright notices
Add accurate repository copyright and license comments to 50 owned source/build/configuration files. Preserve existing holders, upstream wrappers, legal payloads and schema/data exemptions. Exact inverse-byte reconstruction and applicable parser/token/AST checks prove comment-only changes; existing relevant regressions pass.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 11:35:29 -06:00
57 changed files with 421 additions and 5642 deletions

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
{
"typeCheckingMode": "strict",
"pythonVersion": "3.13",
"include": [
"scripts/*.py"
],
"reportMissingTypeStubs": "error",
"reportUnnecessaryTypeIgnoreComment": "error",
"reportImplicitOverride": "error",
"exclude": [
"**/__pycache__"
],
"venvPath": "..",
"venv": ".venv"
}
+43 -95
View File
@@ -2,107 +2,55 @@
# 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)
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)
def check(directory, language, threshold):
if not math.isfinite(threshold) or not 0 <= threshold <= 10:
raise ValueError("Invalid security severity threshold")
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
files = sorted(Path(directory).glob("*.sarif"))
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
raise ValueError("Missing expected CodeQL SARIF report for " + language)
blocking = []
for path in files:
report = json.loads(path.read_text(encoding="utf-8"))
runs = report.get("runs")
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
raise ValueError("Invalid or empty SARIF runs: " + path.name)
for run in runs:
driver = run.get("tool", {}).get("driver", {})
results = run.get("results")
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
rules = {}
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
for rule in component.get("rules", []):
rules[rule["id"]] = rule
if not rules:
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
for invocation in run.get("invocations", []):
if invocation.get("executionSuccessful") is False:
raise ValueError("CodeQL invocation failed: " + path.name)
for result in results:
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
if rule_id not in rules:
raise ValueError("Result references missing rule metadata: " + path.name)
if result.get("suppressions"):
continue
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
if not math.isfinite(severity) or not 0 <= severity <= 10:
raise ValueError("Invalid rule severity: " + rule_id)
if severity >= threshold:
blocking.append((severity, rule_id))
for severity, rule_id in sorted(blocking, reverse=True):
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
return bool(blocking)
if __name__ == "__main__":
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
+101 -146
View File
@@ -2,172 +2,127 @@
# SPDX-License-Identifier: Apache-2.0
"""Fail closed unless scanners cover Maven's selected UI-host runtime graph."""
import argparse
import json
import re
from collections import Counter
import json
from pathlib import Path
from typing import TypedDict, cast
import re
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: 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 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 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 components(report):
if not isinstance(report, dict) or report.get("bomFormat") != "CycloneDX":
raise ValueError("Expected CycloneDX inventory")
packages = set()
for component in report.get("components", []):
purl = urlsplit(component.get("purl", ""))
if purl.scheme != "pkg" or not purl.path.startswith("maven/"):
raise ValueError("Missing Maven package URL")
coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1)
group, artifact = coordinate.split("/", 1)
packages.add((unquote(group) + ":" + unquote(artifact), unquote(version)))
if not packages:
raise ValueError("Empty CycloneDX inventory")
return packages
def check(expected: 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(expected, bom, trivy=OMITTED):
actual = components(bom)
if actual != expected:
raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}")
if trivy is not OMITTED:
if not isinstance(trivy, dict) or not isinstance(trivy.get("Results"), list):
raise ValueError("Expected Trivy report object with Results array")
scanned = {(p["Name"], p["Version"]) for r in trivy.get("Results", []) for p in r.get("Packages", [])}
# Trivy also reports the root project represented by SBOM metadata.
root = bom.get("metadata", {}).get("component", {})
allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))}
if not expected.issubset(scanned) or not scanned.issubset(allowed):
raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}")
return len(expected)
def check_osv(expected: 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 check_osv(expected, report):
if not isinstance(report, dict) or not isinstance(report.get("results"), list):
raise ValueError("Expected OSV report object with results array")
# OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names:
# https://github.com/google/osv-scanner/issues/3099
# Fail on ambiguous display coordinates instead of claiming full identity proof.
projected = Counter((name.split(":", 1)[1], version) for name, version in expected)
if any(count != 1 for count in projected.values()):
raise ValueError("Ambiguous Maven coordinates in pinned OSV report format")
scanned = Counter()
for result in report["results"]:
if not isinstance(result, dict) or not isinstance(result.get("source"), dict):
raise ValueError("Missing OSV source")
if result["source"].get("type") != "sbom" or not isinstance(result["source"].get("path"), str):
raise ValueError("Expected OSV SBOM source")
if not isinstance(result.get("packages"), list):
raise ValueError("Missing OSV package inventory; use --all-packages")
for entry in result["packages"]:
if not isinstance(entry, dict) or not isinstance(entry.get("package"), dict):
raise ValueError("Malformed OSV package")
package = entry["package"]
if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")):
raise ValueError("Invalid OSV Maven package coordinate")
if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list):
raise ValueError("Malformed OSV vulnerabilities")
scanned[(package["name"], package["version"])] += 1
if scanned != projected:
raise ValueError("OSV --all-packages inventory differs from selected Maven graph")
return len(expected)
def main() -> 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.")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("resolved", type=Path)
parser.add_argument("bom", type=Path)
parser.add_argument("trivy", type=Path, nargs="?")
parser.add_argument("--osv", type=Path)
args = parser.parse_args()
expected = resolved(args.resolved.read_text(encoding="utf-8"))
count = check(expected,
json.loads(args.bom.read_text(encoding="utf-8")),
json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED)
if args.osv:
check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8")))
print(f"Verified {count} Maven runtime dependencies, including the UI frontend.")
if __name__ == "__main__":
main()
main()
-112
View File
@@ -1,112 +0,0 @@
# 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)
+17 -19
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: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch.
def leading_comments(text: str, style: str) -> str:
"""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: # noqa: PLR0912 -- Each rec
if style != "powershell":
raise ValueError("Unexpected PowerShell directive")
text = text.partition("\n")[2]
comments: list[str] = []
comments = []
while text.strip():
text = text.lstrip()
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
@@ -46,9 +46,11 @@ def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each rec
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)
@@ -70,22 +72,19 @@ 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}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
if re.search(copyright_pattern, comments) is None:
continue
spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments)
if spdx and any(value != notice["license"] for value in spdx):
continue
apache = all(
phrase in comments
for phrase in (
"Licensed under the Apache License, Version 2.0",
"you may not use this file except in compliance with the License.",
"https://www.apache.org/licenses/LICENSE-2.0",
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
"limitations under the License.",
)
)
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):
@@ -95,9 +94,8 @@ 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."""
# 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] = []
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000")
failures = []
count = 0
for name in tracked:
if not name or not selected(name, policy):
+78 -88
View File
@@ -2,117 +2,107 @@
# SPDX-License-Identifier: Apache-2.0
"""Synthetic regressions for missing, incorrect and empty scanner inventories."""
import copy
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any, cast, override
import sys
import subprocess
import tempfile
import json
import unittest
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("inventory", Path(__file__).with_name("check-maven-inventory.py"))
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):
"""Exercise selected dependency coverage using isolated synthetic reports."""
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": []}]}
@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_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))
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_or_unreviewed_h2_version_fails(self):
without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line)
for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"),
self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"):
with self.subTest(text=text), self.assertRaises(ValueError):
inventory.resolved(text)
def test_missing_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_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_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_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_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_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_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_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_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_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_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 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_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 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 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_all_clean_packages_pass_without_vulnerabilities(self):
self.assertEqual(6, inventory.check_osv(self.expected, self.osv()))
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_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_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())
def test_osv_ambiguous_group_projection_fails(self):
with self.assertRaises(ValueError):
inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv())
if __name__ == "__main__":
unittest.main()
unittest.main()
+61 -76
View File
@@ -2,106 +2,91 @@
# SPDX-License-Identifier: Apache-2.0
"""Exercise the exact CI guards using synthetic evidence only."""
import contextlib
import copy
import importlib.util
import io
import importlib.util
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: 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
def load(name):
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
codeql = load("check-codeql-sarif")
class SarifTests(unittest.TestCase):
"""Reject incomplete SARIF evidence and preserve severity enforcement."""
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": []}]}
@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 save(self, report):
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
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_clean_report_with_zero_findings_passes(self):
self.save(self.report)
self.assertFalse(codeql.check(self.directory, "java-kotlin", 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_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_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_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_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_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_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_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_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"))
def test_failed_invocation_and_invalid_threshold_fail(self):
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", float("nan"))
if __name__ == "__main__":
unittest.main()
unittest.main()
-238
View File
@@ -1,238 +0,0 @@
# 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()
+17 -21
View File
@@ -11,6 +11,7 @@ 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)
@@ -31,7 +32,8 @@ 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):
@@ -49,23 +51,12 @@ 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):
@@ -76,20 +67,21 @@ 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):
@@ -97,7 +89,8 @@ 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())
@@ -110,7 +103,8 @@ 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)
@@ -127,7 +121,9 @@ 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"]))
@@ -144,11 +140,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) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory.
subprocess.run(["git", "init", "-q", str(root)], check=True)
root.joinpath("src").mkdir()
source = root / "src/a.py"
source.write_text("print(1)", encoding="utf-8")
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command.
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True)
with self.assertRaisesRegex(ValueError, "src/a.py"):
HEADERS.check_repository(root, fixture_policy())
source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8")
+1 -18
View File
@@ -29,30 +29,13 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: QRun-IO/qqq-print-protocol
ref: a26c18c90c7d6102bbe26eb50cbb90fcd1bd46e3
ref: f3cbc02e9b0fb4fdfefaa59d976c04df747cc589
path: qqq-print-protocol
persist-credentials: false
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
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
+1 -1
View File
@@ -90,7 +90,7 @@ jobs:
global-json-file: global.json
- name: Initialize CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ contains(fromJSON('["actions","javascript-typescript","python","ruby"]'), matrix.language) && 'none' || inputs.build-mode }}
-6
View File
@@ -12,9 +12,3 @@ dist/
*.iml
.DS_Store
Thumbs.db
# Local script-quality environments and tool caches.
.venv/
node_modules/
.ruff_cache/
__pycache__/
-1
View File
@@ -1 +0,0 @@
3.13
+1 -38
View File
@@ -2,45 +2,8 @@
## Unreleased (0.4.0-SNAPSHOT)
Printing extension **2.3.0** (additive; wire payload v1 and the agent subprotocol are
unchanged). 2.1.0 added the record, table-list and text print processes; 2.2.0 added
`printingRenderToPdf`, the header and footer values on every document and content
sections; 2.3.0 adds the least-privilege operator catalog/submission/request lookup,
reported Windows queue-default media (`printer-media-v1`) and the opt-in
retained-source facade. Clients should offer a feature when its process is in the
metadata, not by comparing versions. An agent that does not advertise
`printer-media-v1` stays a three-capability peer and cannot claim a job carrying a
media selection, whatever extension version it discovers.
Printing extension **2.1.0** (additive; the agent contract is unchanged).
- Realtime shutdown now waits, with a bound, for channel workers that are already
running before the host's durable state is released. Previously the executor was
only told to stop, so a late drain could query a stopped file database and
recreate it empty (found as a temporary-directory cleanup error in a full test
run).
Observed locally on the merged candidate: full `-Pui-demo verify` 189 reported,
188 executed, one intentional opt-in workload skip, zero failures/errors, zero
Checkstyle violations; five consecutive focused realtime runs passed. Hosted CI on
the final signed head, publication and consumer qualification have not run; no
physical output is claimed.
- The app's header and footer on every page of every document (record sheets,
table lists, text and content pages): app name, title or breadcrumb, server,
user and printed-at at the top; the client's footer (e.g. `qqq ui 1.0.1 ·
server`), a list's filter and sort, and "Page n of m" at the bottom. Clients
send `headerApp`, `headerTitle`, `headerServer`, `headerUser`, `headerPrintedAt`,
`footerLeft` and `footerNote`; missing ones default to the instance branding,
the session user, the current instant and "QQQ printing".
- Content documents: `printingPrintText` and `printingRenderToPdf` (`TEXT`) take
`sections` (JSON `[{title, lines}]`), a screen's panels without navigation or
borders, rendered as headings with their lines wrapped to the page width.
- Save as PDF: `printingRenderToPdf` renders the document a print would carry -
a record sheet (`kind` `RECORD`, `tableName`, `recordId`), a table list (`LIST`,
the `printingPrintTableList` values) or a text page (`TEXT`) - and returns it to
the caller as the standard process download (`downloadFileName`,
`serverFilePath`) instead of queueing a job. No printer is needed; reads enforce
the caller's permissions, and a list over the limit says so in
`printingMessage`. Clients offer it as "Save as PDF…" in their printer pickers.
- Print anything: `PrintingRecordProcesses.registerAll(instance)` adds **Print** to
every table that can be read one record at a time (call it after the host's
tables are in the instance). Hidden tables, tables without a primary key or
-11
View File
@@ -9,14 +9,3 @@ 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.
+1 -1
View File
@@ -141,7 +141,7 @@ JSONObject job = printing.submit(new JSONObject()
String printJobId = job.getString("jobId");
```
For terminal and web clients the QBit also offers ready-made print processes: `PrintingRecordProcesses.registerAll(instance)` (after all host tables are added) puts **Print** on every readable table, rendering a record sheet from the table's sections and child lists; `printingPrintTableList` prints a grid of any table (columns, filter, sort, page or all rows, paper and orientation); `printingPrintText` prints text such as a screen capture. Opt a table out with `PrintingTableSettings().withPrintable(false)`. Each returns the queued job id in `printingJobIds`. `printingRenderToPdf` (extension 2.2.0) renders the same documents to a PDF download for the caller instead ("Save as PDF"), and every document carries the app header and footer the client sends (`headerApp`, `headerTitle`, `headerServer`, `headerUser`, `headerPrintedAt`, `footerLeft`, `footerNote`).
For terminal and web clients the QBit also offers ready-made print processes: `PrintingRecordProcesses.registerAll(instance)` (after all host tables are added) puts **Print** on every readable table, rendering a record sheet from the table's sections and child lists; `printingPrintTableList` prints a grid of any table (columns, filter, sort, page or all rows, paper and orientation); `printingPrintText` prints text such as a screen capture. Opt a table out with `PrintingTableSettings().withPrintable(false)`. Each returns the queued job id in `printingJobIds`.
The returned ID identifies durable queue acceptance. Check job status asynchronously. The [protocol v1 specification](https://github.com/QRun-IO/qqq-print-protocol/blob/f977bd01b62ceb7539190fd2861809537fcf504c/README.md) defines HTTP endpoints, JSON fields, integrity checks and lifecycle semantics.
-67
View File
@@ -1,67 +0,0 @@
# Operator catalog and queue media
This source adds native printing extension 2.3.0 (2.2.0 is the rendering extension and carries no media negotiation). Publication and Windows media
agent qualification are pending. Existing administrator and device routes keep
their permissions; this is an additive operator API, not a device bearer API.
Hosts grant `printing_operator` and the `printing_tenant` security key. The native
`printingSubmit` process remains the domain write permission. Current device and
printer table/record security, credential scopes and client-channel ceilings apply
on every operation. Generic job/command history, table writes, enrollment and
device management remain administrator-only. Hosts may add destination locks.
Native callbacks cannot replace the initiating actor, session, instance, service
or row-security context. Submission rechecks current process, credential, channel
and destination authority after loading/storage callbacks and before commit.
The backend process is hidden and has no base64-entry form. Echo obtains its user
selections from the catalog; backend metadata also references native printer and
format possible-value sources.
- `GET /qqq/v1/qbit/printing/operator/printers?limit=50&after=...` returns at most
200 authorized approved/exposed printers, in stable printer-row-ID order. The
opaque next cursor advances over at most `limit` candidate rows even if parent
device security hides every candidate. Continue until `nextCursor` is null.
- `POST /qqq/v1/qbit/printing/operator/jobs` accepts `requestId`, `printerRowId`,
`title`, `format` (`PDF` or `ZPL`), `contentBase64` and optional `options`. The
native printer row determines the device and opaque Windows queue identity.
`requestId` is 1–128 characters from letters, digits, `.`, `_`, `:`, `-`.
The authenticated actor and tenant scope the stable key; callers cannot supply
actor identity or an internal idempotency key. The response is the existing job
receipt after durable commit. Changed requests conflict instead of reprinting.
- `GET /qqq/v1/qbit/printing/operator/requests/{requestId}` returns only that
actor's exact accepted request, or 404. It does not enqueue, read document bytes,
or initialize settings. Current destination read authority remains required;
later loss of exposure alone does not erase the acceptance receipt.
Lookup checks the original accepted native printer-row ID. Deleting that row
and creating another with the same device/queue pair does not transfer history.
Each catalog entry has `printerRowId`, `deviceId`, `printerId`, effective `name`,
`deviceName`, `status`, `formats`, `mediaProfiles`, and optional `description` and
`location`. These names are presentation; the stable row identity selects a queue.
Media profiles report actual Windows queue defaults. Their typed facts are `id`,
`revision` (lowercase SHA-256), `name`, `widthMicrons`, `heightMicrons`, `dpiX`,
`dpiY`, `source: WINDOWS_QUEUE_DEFAULT`, `applyMode: DRIVER_DEFAULT`. Unknown or
unsupported driver facts produce an empty list. The initial client reports one
configured default; the wire bound is 32. Dimensions are 1–2,000,000 microns and
DPI is 1–9600. Names are at most 512 characters. Unknown additive reported facts
are ignored when canonicalizing; known required facts remain validated. Arbitrary
page-size values are never interpreted as a Windows stock change.
Profile and selection IDs share the protocol's 128-character URL-safe ID bound.
Select exact reported media with `options.mediaProfile: {id, revision}`. The
selection is immutable job intent and accepts no extra fields. New submissions
reject stale/missing profiles before admission/storage writes; exact accepted
replays remain accepted. The capable Windows agent rechecks its actual current
default before spooling. Selected-media dispatch requires the current agent
capability `printer-media-v1`, including after reconnect/downgrade. Legacy
inventory without profiles clears stale media facts. Omitted selection preserves
ordinary existing queue behavior. Paper-width/height overrides remain unsupported,
including when a media selection is supplied.
Generated/retained source authorization belongs to the configured source owner.
The retained facade shares this operator permission and destination boundary; it
preserves its atomic accepted source/actor/request binding. Barcode fit and
encoding remain the renderer's job.
`QUEUED` is durable acceptance; `SPOOLED` means spooler acceptance. Neither is
physical output confirmation. These tests use synthetic bytes and no real printer.
-114
View File
@@ -1,114 +0,0 @@
# Retained print acceptance (additive candidate)
This optional Java integration preserves the existing native generation print
process and the existing printing queue. It adds no device wire fields or REST
routes. It is disabled until the host supplies
`PrintingQBitConfig.withRetainedSourceAuthority`. Current `printing_operator`
(or administrator) authority and native tenant/device/printer record checks
remain mandatory. Submission also requires the shared `printingSubmit` process
permission. This grants no device management or generic job-history access.
## Public contract
`RetainedPrintFacade.Request(requestId, sourceLocator, printerRecordId, title,
optionsJson)` contains no actor or asserted source binding. Request IDs use
`[A-Za-z0-9._:-]{1,128}`, including owner namespaces such as `echo:<scanId>`.
`submit` derives the signed-in native actor and returns an immutable
`Acceptance`; it includes the exact source, job/event identities, tenant,
initiating actor, destination, title and normalized options JSON, including any
`mediaProfile` ID/revision. `find` authorizes the same complete request and
returns that acceptance, null for observed absence, or a conflict for an
unresolved upload reservation. Reuse the same request identity after a lost
response. Never convert an unavailable lookup into absence or create another
intent automatically.
The host-configured `RetainedPrintSourceAuthority.resolve(locator, requestId)`
must authorize the current native actor, source association and currently
eligible immutable version. It returns `SourceReference(owner, opaqueReference,
sha256, sizeBytes, mediaType)`. `opaqueReference` is at most 4000 Java
characters, stable and canonical under that owner's rules; printing does not
parse owner-specific fields. The optional Record Files adapter must encode its
complete version/source/generation-request/initiating-actor/tenant tuple and
compare generation's committed receipt. Accepted MIME types are
`application/pdf` and `application/vnd.zebra-zpl`; the current configured
PDF/ZPL limits apply before the byte read, within the existing integer-length
storage ceiling. `read(reference, requestId)` must enforce the same current
permissions and return only those exact bytes. Hash/length verification remains
in printing's existing submission path. Do not supply arbitrary source
authorities per request or reconstruct a user session.
Source admission remains the owner's policy. An explicitly configured published
template may qualify generated output without a per-label release dialog;
external-upload quarantine is unchanged. Printing performs no automatic release
and creates no human-release audit. `resolve` must be read-only and must not
invoke a generation reconciliation operation that can generate or dispatch work.
Source/destination checks surround owner callbacks and precede acceptance.
Submission refuses an ambient QQQ transaction: queue acceptance is independently
committed and cannot be rolled back by a caller's transaction. Submission also
enforces the native job-table credential and channel mutation ceilings; lookup
uses the corresponding read ceilings without granting generic table access.
`find` calls no byte reader, submission or event writer, and its option
normalization cannot initialize settings. It reauthorizes the source and
destination records, then compares the exact accepted intent; a later exposure,
media inventory or dispatch-limit change does not reinterpret that historical
intent. A later permission loss may deny returning a receipt whose job already
committed; it never authorizes a replacement job.
## Persistence and replay
Existing `printing_job` gains nullable `retained_actor_id`,
`retained_binding_json`, and `accepted_event_id`. Existing `printing_event`
gains nullable `event_origin`. `initialize` applies additive upgrades; old
jobs/events remain unbound/unclassified. None of these internal fields are added
to the device `JOB_COLUMNS` DTO or native table metadata.
Submission and exact lookup both use the existing tenant admission lock, so an
in-flight inline acceptance cannot be mistaken for absence. The unique
`(tenant_id, idempotency_key)` serialize submissions. Retained keys use
`retained:` plus the request ID. Ordinary/bound collisions refuse. Complete
normalized intent includes actor, owner reference, content hash/length/media,
destination, title, options and tenant. The existing storage reservation/orphan
path is reused; it may commit an upload reservation before writing object
storage. The final job, retained binding, and server acceptance event commit
together. Failed acceptance-event insertion rolls back the job. Rollback or
orphan-cleanup failures are suppressed onto the original failure rather than
replacing it. Existing initialization widens legacy 300-character titles to
1024; the bound title preserves exact whitespace and Unicode and is compared on
replay. Acceptance-event JSON has `type=ACCEPTED`, `origin=SERVER_ACCEPTANCE`,
`status=QUEUED`; it does not claim device receipt or physical output.
This is atomic acceptance binding inside printing, not a distributed transaction
over the retained owner's authorization database. Deployment still owns durable
database/storage settings, bounded provider calls and current session policy.
## Committed observations
`observation(requestId, eventId)` reads only an exact committed event joined to
the accepted job. New ordinary device events are stamped `DEVICE_OBSERVATION`;
pre-upgrade unclassified events and server acceptance events cannot be returned
as device evidence. The current native operator must have current device/printer
row authority and satisfy the job credential/channel read ceiling. Before
returning any source metadata, printing calls the configured owner's
`authorizeObservation(source, requestId)` under the current reporter session,
then rechecks the current ceiling and destination authority. Its default
implementation refuses; adapters must explicitly implement their current
source-observation policy. This callback does not grant content access or
impersonate the initiating actor. This is an operator-authorized observation
lookup, not device enrollment or permission to forge reporter identity. The
optional RF authority adapter must preserve initiating actor separately from
current reporting actor and map only supported stored states. Neither `SPOOLED`
nor `SPOOLER_COMPLETED` proves physical output.
## Verification scope
The tests use real H2 transactions and native QQQ row authorization with
synthetic printers. They never contact a device or emit physical output.
Coverage includes commit-then-throw acknowledgment loss, concurrent exact
replay, held-admission-lock lookup, mutation ceilings, owner observation
refusal, callback revocation, exact content hash/length, configured document
bounds, title upgrade/replay, read-only historical lookup and original-error
preservation when rollback fails. Qualification of this adopted source is
recorded in the session notes; the older peer candidate's test result is not
evidence for this revision. Cross-QBit integration remains a separate gate. No
release, install, shared-cache substitution or runtime migration has occurred.
-53
View File
@@ -1,58 +1,5 @@
# Session state — 2026-10-31
## Echo operator/media and retained-source candidate — 2026-11-01
The isolated Echo candidate adds native printing extension **2.3.0** (after the protected-main 2.2.0 rendering extension) on the
unchanged product `0.4.0-SNAPSHOT` and QQQ `4.1.0-RC.20` dependency tuple.
`printing_operator` permits the authorized printer/media catalog and exact-actor
submission/reconciliation without device administration or general job history.
Reported Windows queue-default media is revision-bound; clients without the current
`printer-media-v1` capability cannot claim jobs carrying a media selection.
The opt-in retained-source facade shares that boundary and commits immutable
source/actor/destination/intent plus its server acceptance event inside the existing
queue transaction. Its owner adapter is required and default-deny; read-only lookup
never generates content or submits a replacement job. See
[operator catalog](OPERATOR-CATALOG.md) and
[retained acceptance](RETAINED-PRINT-FACADE.md) for exact contracts and limits.
Protected main advanced to a rendering release at printing 2.2.0 (header/footer
chrome, content sections, render-to-PDF) while this candidate was qualified, so the
operator/media/retained feature was merged onto it and moved to 2.3.0. The earlier
183-test gate is historical evidence of the pre-merge source only.
Observed after the merge, locally: the OpenAPI contract was regenerated with the
existing explicit update switch and its 1.0.0 compatibility baseline hash was
unchanged. A sandbox full verify reported 188 tests with 11 loopback setup errors and
no assertion failures (environmental). The first actual-user full verify reported 188
tests, no assertion failures and **one error**:
`PrintingRealtimeTest.mediaCapabilityIsReplacedOnEveryAuthenticatedHello` failed
only when JUnit removed its temporary directory. Cause: `PrintingRealtimeRoutes.close()`
shut its executor down without waiting, so a channel drain still running after the
host stopped queried a deleted file database and recreated it empty. The fix waits
(bounded) for running drains, with regression
`closeWaitsForChannelWorkersAlreadyRunning`. After it, five consecutive focused
`PrintingRealtimeTest` runs passed (4 tests each, no empty-database warnings) and the
full `-Pui-demo verify` passed: **189 reported, 188 executed, one intentional opt-in
workload skip, zero failures/errors, zero Checkstyle violations**; the source-header
guard over 146 tracked files and staged whitespace checks passed. The earlier failing
logs are preserved. This is local evidence only: hosted CI on the signed candidate
head, publication and cross-QBit consumer qualification are pending. The suite runs
against synthetic H2 fixtures; no live database, connector, printer or shared
artifact has been changed. The
experimental API 1.10 used by Echo is a separate owner-controlled tuple and has not
been substituted for this candidate's qualified RC.20 dependencies. Publication,
cross-QBit consumer qualification and any physical output remain separate gates.
Native destination and storage callbacks cannot carry stale actor, session,
credential, channel or row authority into admission, replay or commit. Receipt
lookup authorizes the original stored printer-row ID; a replacement row with the
same device/queue pair cannot inherit earlier acceptance history. The regressions
reproduce both prior paths, verify denial and rollback, and preserve authorized
read-only observation after the original queue loses exposure. Media IDs are
bounded to the shared 128-character contract; long device display names remain
supported without truncation.
## Local Docker development — 2026-11-01
The optional native-auth probe now shares production Core discovery and the
-24
View File
@@ -1,29 +1,5 @@
# Follow-up
## Script quality gates
- [x] Pin Ruff and strict Pyright with a locked restore; reject Python outside `.github/scripts/` and zero-file Pyright runs; negative tool probes pass locally.
- [ ] Observe the new `verify` job steps pass on hosted CI before relying on them.
## Echo integration candidate
- [x] Implement authorized operator catalog, native submission/process permission,
exact actor/request lookup and reported driver-default media revisions.
- [x] Integrate the opt-in atomic retained-source facade using the same queue,
current authority, immutable acceptance and exact device-event observation.
- [x] Close native callback authority changes and preserve the exact accepted
printer-row identity during read-only request reconciliation.
- [x] Complete local combined verification on the merged 2.3.0 candidate: 189
reported, 188 executed, one opt-in workload skip, zero failures/errors and
Checkstyle violations (the earlier 183-test gate was pre-merge). Preserve exact
source, draft protocol fixtures and frozen runtime hashes.
- [ ] Pass hosted CI on the final signed candidate head; local results do not
replace it.
- [ ] Qualify Echo/Generation's exact core tuple and configured source authority;
do not silently adopt experimental core or bypass external-upload admission.
- [ ] Publish the reviewed additive contract and coordinate client/runtime adoption
before owner-operated end-to-end physical acceptance.
## Current local development qualification
- [x] Reuse production discovery and the shared major-2 gate in the optional
-51
View File
@@ -1,51 +0,0 @@
{
"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
@@ -1,14 +0,0 @@
{
"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"
}
-13
View File
@@ -1,13 +0,0 @@
# 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
@@ -1,56 +0,0 @@
# 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"
@@ -48,19 +48,11 @@ final class PrintingApiSchemas
add(schemas, "EnrollmentRequest", fields("enrollmentCode", text(128), "deviceName", text(1024), "agentVersion", text(100)), "enrollmentCode", "deviceName", "agentVersion");
add(schemas, "Enrollment", fields("protocolVersion", number(), "deviceId", id(), "deviceToken", text(128).withDescription("Private device proof, returned once; never log."), "pollIntervalSeconds", number()), "protocolVersion", "deviceId", "deviceToken", "pollIntervalSeconds");
add(schemas, "Profile", fields("name", text(512).withNullable(true), "description", text(4096).withNullable(true), "location", text(512).withNullable(true)));
add(schemas, "MediaSelection", fields("id", id(), "revision", text(64)), "id", "revision");
add(schemas, "MediaProfile", fields("id", id(), "revision", text(64), "name", text(512), "widthMicrons", number(), "heightMicrons", number(), "dpiX", number(), "dpiY", number(),
"source", enumeration("WINDOWS_QUEUE_DEFAULT"), "applyMode", enumeration("DRIVER_DEFAULT")), "id", "revision", "name", "widthMicrons", "heightMicrons", "dpiX", "dpiY", "source", "applyMode");
add(schemas, "OperatorPrinter", fields("printerRowId", id(), "deviceId", id(), "printerId", id(), "name", text(1024), "deviceName", text(1024), "description", text(4096), "location", text(512), "status", text(40), "formats", array(enumeration("PDF", "ZPL")), "mediaProfiles", array(ref("MediaProfile"))),
"printerRowId", "deviceId", "printerId", "name", "deviceName", "status", "formats", "mediaProfiles");
add(schemas, "OperatorCatalog", fields("printers", array(ref("OperatorPrinter")), "nextCursor", text(200).withNullable(true)), "printers", "nextCursor");
add(schemas, "OperatorSubmit", fields("requestId", text(128), "printerRowId", id(), "title", text(1024), "format", enumeration("PDF", "ZPL"), "contentBase64", text(Integer.MAX_VALUE), "options", ref("Options")),
"requestId", "printerRowId", "title", "format", "contentBase64");
add(schemas, "HeartbeatRequest", fields("agentVersion", text(100), "profile", ref("Profile")), "agentVersion");
add(schemas, "Heartbeat", fields("serverTime", time()), "serverTime");
add(schemas, "Printer", fields("printerId", id(), "name", text(1024), "driverName", text(1024), "portName", text(1024), "status", enumeration("READY", "OFFLINE", "PAUSED", "ERROR", "UNKNOWN"), "formats", array(enumeration("PDF", "ZPL")), "mediaProfiles", array(ref("MediaProfile")), "displayName", text(512), "description", text(4096), "location", text(512)), "printerId", "name", "formats");
add(schemas, "Printer", fields("printerId", id(), "name", text(1024), "driverName", text(1024), "portName", text(1024), "status", enumeration("READY", "OFFLINE", "PAUSED", "ERROR", "UNKNOWN"), "formats", array(enumeration("PDF", "ZPL")), "displayName", text(512), "description", text(4096), "location", text(512)), "printerId", "name", "formats");
add(schemas, "InventoryRequest", fields("printers", array(ref("Printer"))), "printers");
add(schemas, "Options", fields("copies", number(), "scale", enumeration("ACTUAL_SIZE", "FIT"), "mediaProfile", ref("MediaSelection"), "paperWidthMm", new Schema().withType(Type.NUMBER), "paperHeightMm", new Schema().withType(Type.NUMBER)));
add(schemas, "Options", fields("copies", number(), "scale", enumeration("ACTUAL_SIZE", "FIT"), "paperWidthMm", new Schema().withType(Type.NUMBER), "paperHeightMm", new Schema().withType(Type.NUMBER)));
add(schemas, "Job", fields("jobId", id(), "deviceId", id(), "printerId", id(), "title", text(1024), "format", enumeration("PDF", "ZPL"), "contentSha256", text(64), "contentLength", number(), "options", ref("Options"), "createdAt", time(), "status", enumeration("QUEUED", "CLAIMED", "RECEIVED", "SUBMITTING", "SPOOLED", "SPOOLER_COMPLETED", "FAILED", "OUTCOME_UNKNOWN"), "message", text(2048), "spoolerJobId", id()), "jobId", "deviceId", "printerId", "title", "format", "contentSha256", "contentLength", "options", "createdAt", "status");
add(schemas, "Claim", fields("job", ref("Job").withNullable(true)), "job");
add(schemas, "Jobs", fields("jobs", array(ref("Job"))), "jobs");
@@ -68,7 +60,7 @@ final class PrintingApiSchemas
add(schemas, "TestRequest", fields("requestId", id(), "printerId", id(), "format", enumeration("PDF", "ZPL"), "testProfile", text(40)), "requestId", "printerId", "format");
add(schemas, "RetryRequest", fields("requestId", id(), "confirmDuplicateRisk", bool()), "requestId");
add(schemas, "DesiredState", fields("revision", number(), "dispatchPaused", bool(), "connectionState", enumeration("CONNECTED", "DISCONNECTED")), "revision", "dispatchPaused", "connectionState");
add(schemas, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1")), "lastAppliedRevision", number()), "controlVersion", "agentVersion", "capabilities", "lastAppliedRevision");
add(schemas, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL")), "lastAppliedRevision", number()), "controlVersion", "agentVersion", "capabilities", "lastAppliedRevision");
add(schemas, "Command", fields("commandId", id(), "requestId", id(), "action", enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL"), "createdAt", time(), "expiresAt", time(), "jobId", id()), "commandId", "requestId", "action", "createdAt", "expiresAt");
add(schemas, "ControlPoll", fields("controlVersion", number(), "serverTime", time(), "pollIntervalSeconds", number(), "desiredState", ref("DesiredState"), "commands", array(ref("Command"))), "controlVersion", "serverTime", "pollIntervalSeconds", "desiredState", "commands");
add(schemas, "ReceiptRequest", fields("receiptId", id(), "outcome", enumeration("INVENTORY_REFRESHED", "CANCEL_REQUESTED", "ALREADY_ABSENT", "OWNERSHIP_MISMATCH", "UNSUPPORTED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"), "completedAt", time(), "message", text(1024)), "receiptId", "outcome", "completedAt");
@@ -1,149 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
/*******************************************************************************
** The header and footer every printed page carries, whatever the document:
**
** <app> <server> · <user> · <printed at>
** <title / breadcrumb>
** ...
** <footer note: a grid's filter and sort>
** <footer: "qqq ui 1.0.0 · server"> Page n of m
**
** Clients send the fields as process values (headerApp, headerTitle,
** headerServer, headerUser, headerPrintedAt, footerLeft, footerNote) so the
** text matches what the user saw (their date format, the app's name, the
** breadcrumb). Missing fields fall back to the instance's branding name, the
** session user, the current instant and "QQQ printing".
*******************************************************************************/
public record PrintingChrome(String app, String title, String server, String user, String printedAt, String footerLeft, String footerNote)
{
/***************************************************************************
** The fields from process values, with server-side defaults.
***************************************************************************/
public static PrintingChrome from(RunBackendStepInput input, String fallbackTitle)
{
return (new PrintingChrome(
value(input, "headerApp", defaultApp()),
value(input, "headerTitle", fallbackTitle),
value(input, "headerServer", ""),
value(input, "headerUser", defaultUser()),
value(input, "headerPrintedAt", Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()),
value(input, "footerLeft", "QQQ printing"),
value(input, "footerNote", "")));
}
/***************************************************************************
** Defaults only (documents rendered outside a process).
***************************************************************************/
public static PrintingChrome defaults(String title)
{
return (new PrintingChrome(defaultApp(), title == null ? "" : title, "", defaultUser(), Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), "QQQ printing", ""));
}
/***************************************************************************
** The same chrome with a footer note (a grid's filter and sort).
***************************************************************************/
public PrintingChrome withFooterNote(String note)
{
return (new PrintingChrome(app, title, server, user, printedAt, footerLeft, footerNote == null || footerNote.isBlank() ? (note == null ? "" : note) : footerNote));
}
/***************************************************************************
** The same chrome with this title when it has none.
***************************************************************************/
public PrintingChrome withTitleIfBlank(String fallback)
{
return (title != null && !title.isBlank() ? this : new PrintingChrome(app, fallback, server, user, printedAt, footerLeft, footerNote));
}
/***************************************************************************
** The right side of the first header line.
***************************************************************************/
String headerRight()
{
StringBuilder out = new StringBuilder();
for(String part : new String[] {server, user, printedAt})
{
if(part != null && !part.isBlank())
{
out.append(out.isEmpty() ? "" : " · ").append(part);
}
}
return (out.toString());
}
/***************************************************************************
** A string value, or the default when absent or blank (bounded length).
***************************************************************************/
private static String value(RunBackendStepInput input, String name, String fallback)
{
String value = input == null ? null : input.getValueString(name);
String chosen = value == null || value.isBlank() ? fallback : value;
return (chosen == null ? "" : PrintingRecordDocuments.truncate(chosen.strip(), 300));
}
/***************************************************************************
** The instance's branding name, else "QQQ".
***************************************************************************/
private static String defaultApp()
{
try
{
var branding = QContext.getQInstance() == null ? null : QContext.getQInstance().getBranding();
return (branding != null && branding.getAppName() != null ? branding.getAppName() : "QQQ");
}
catch(RuntimeException exception)
{
return ("QQQ");
}
}
/***************************************************************************
** The session user's name, else blank.
***************************************************************************/
private static String defaultUser()
{
return (QContext.getQSession() == null || QContext.getQSession().getUser() == null || QContext.getQSession().getUser().getFullName() == null ? "" : QContext.getQSession().getUser().getFullName());
}
}
@@ -266,8 +266,8 @@ final class PrintingDeviceControls
}
String version = text(request, "agentVersion", 100);
long applied = integer(request, "lastAppliedRevision");
if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 3
|| capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value) && !"printer-media-v1".equals(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length())
if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 2
|| capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length())
{
throw new PrintingException(400, "Invalid control capabilities");
}
@@ -54,7 +54,7 @@ final class PrintingLayout implements AutoCloseable
private float y;
private List<String> repeatHeader = List.of();
private int repeatSize = 8;
private PrintingChrome chrome;
private String footer = "";
private float leading = 1.35f;
@@ -65,17 +65,6 @@ final class PrintingLayout implements AutoCloseable
***************************************************************************/
PrintingLayout(String pageSize, boolean landscape, String title, String subtitle) throws QException
{
this(pageSize, landscape, title, subtitle, null);
}
/***************************************************************************
** With the app's header and footer on every page (defaults when null).
***************************************************************************/
PrintingLayout(String pageSize, boolean landscape, String title, String subtitle, PrintingChrome chrome) throws QException
{
this.chrome = chrome == null ? PrintingChrome.defaults(title) : chrome;
try(InputStream stream = PrintingLayout.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE))
{
if(stream == null)
@@ -128,11 +117,11 @@ final class PrintingLayout implements AutoCloseable
/***************************************************************************
** A note above the footer of every page (a grid's filter and sort).
** Text at the bottom left of every page (who printed it and when).
***************************************************************************/
void footer(String text)
{
chrome = chrome.withFooterNote(text);
footer = text == null ? "" : text;
}
@@ -154,7 +143,7 @@ final class PrintingLayout implements AutoCloseable
void line(String text, float fontSize) throws IOException
{
float step = fontSize * leading;
if(y - step < bottom())
if(y - step < MARGIN + 14)
{
newPage();
for(String header : repeatHeader)
@@ -172,7 +161,7 @@ final class PrintingLayout implements AutoCloseable
***************************************************************************/
void keep(int lines, int fontSize) throws IOException
{
if(y - lines * fontSize * leading < bottom())
if(y - lines * fontSize * 1.35f < MARGIN + 14)
{
newPage();
}
@@ -219,17 +208,19 @@ final class PrintingLayout implements AutoCloseable
try(PDPageContentStream footer = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true))
{
String text = "Page " + (i + 1) + " of " + total;
float base = MARGIN - 14;
footer.setLineWidth(0.5f);
footer.moveTo(MARGIN, base + (hasNote() ? 20 : 10));
footer.lineTo(size.getWidth() - MARGIN, base + (hasNote() ? 20 : 10));
footer.stroke();
if(hasNote())
footer.beginText();
footer.setFont(font, 8);
footer.newLineAtOffset(size.getWidth() - MARGIN - text.length() * 4.8f, MARGIN - 14);
footer.showText(text);
footer.endText();
if(!this.footer.isEmpty())
{
at(footer, MARGIN, base + 10, PrintingRecordDocuments.truncate(chrome.footerNote(), columns(7.5f)), 7.5f);
footer.beginText();
footer.setFont(font, 8);
footer.newLineAtOffset(MARGIN, MARGIN - 14);
footer.showText(safe(PrintingRecordDocuments.truncate(this.footer, Math.max(10, columns(8) - text.length() - 2))));
footer.endText();
}
at(footer, size.getWidth() - MARGIN - text.length() * 4.8f, base, text, 8);
at(footer, MARGIN, base, PrintingRecordDocuments.truncate(chrome.footerLeft(), Math.max(10, columns(8) - text.length() - 2)), 8);
}
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
@@ -282,8 +273,7 @@ final class PrintingLayout implements AutoCloseable
PDPage page = new PDPage(size);
document.addPage(page);
content = new PDPageContentStream(document, page);
y = size.getHeight() - MARGIN + 14;
header();
y = size.getHeight() - MARGIN;
if(document.getNumberOfPages() > 1)
{
draw(PrintingRecordDocuments.truncate(title, columns(8)) + " (continued)", 8, 12);
@@ -293,68 +283,6 @@ final class PrintingLayout implements AutoCloseable
/***************************************************************************
** The app's header band: the app and server/user/time, then the title or
** breadcrumb, then a rule.
***************************************************************************/
private void header() throws IOException
{
String right = chrome.headerRight();
int rightChars = Math.min(right.length(), columns(7.5f) / 2 + 10);
String rightText = PrintingRecordDocuments.truncate(right, rightChars);
y -= 9;
at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.app(), Math.max(10, columns(9) - rightText.length())), 9);
at(content, size.getWidth() - MARGIN - rightText.length() * 4.5f, y, rightText, 7.5f);
if(chrome.title() != null && !chrome.title().isBlank())
{
y -= 10;
at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.title(), columns(7.5f)), 7.5f);
}
y -= 4;
content.setLineWidth(0.5f);
content.moveTo(MARGIN, y);
content.lineTo(size.getWidth() - MARGIN, y);
content.stroke();
content.setLineWidth(1f);
y -= 6;
}
/***************************************************************************
** Whether pages carry a footer note.
***************************************************************************/
private boolean hasNote()
{
return (chrome.footerNote() != null && !chrome.footerNote().isBlank());
}
/***************************************************************************
** The lowest baseline body text may use, above the footer band.
***************************************************************************/
private float bottom()
{
return (MARGIN + (hasNote() ? 22 : 12));
}
/***************************************************************************
** Text at a position on a content stream.
***************************************************************************/
private void at(PDPageContentStream stream, float x, float baseline, String text, float fontSize) throws IOException
{
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(x, baseline);
stream.showText(safe(text));
stream.endText();
}
/***************************************************************************
** Draw one line at the current position.
***************************************************************************/
@@ -326,108 +326,6 @@ public final class PrintingListProcesses
/***************************************************************************
** A rendered document: its title, the PDF, and a note (rows left out).
***************************************************************************/
public record Rendered(String title, byte[] bytes, String message)
{
}
/***************************************************************************
** Render a table list (list true) or a text page from the process values.
***************************************************************************/
static Rendered render(RunBackendStepInput input, boolean list) throws QException
{
String pageSize = input.getValueString("pageSize") == null ? "LETTER" : input.getValueString("pageSize").toUpperCase(Locale.ROOT);
if(!list)
{
String title = input.getValueString("title") == null || input.getValueString("title").isBlank() ? "Screen" : input.getValueString("title");
String subtitle = input.getValueString("subtitle");
boolean landscape = "LANDSCAPE".equalsIgnoreCase(input.getValueString("orientation"));
PrintingChrome chrome = PrintingChrome.from(input, title);
String sections = input.getValueString("sections");
if(sections != null && !sections.isBlank())
{
return (new Rendered(title, PrintingTextDocuments.sectionsPdf(title, subtitle, sections(sections), pageSize, landscape, chrome), null));
}
return (new Rendered(title, PrintingTextDocuments.pdf(title, subtitle, input.getValueString("text"), pageSize, landscape, chrome), null));
}
QTableMetaData table = table(input);
List<QFieldMetaData> columns = columns(table, input.getValueString("columns"));
QQueryFilter filter = filter(input);
boolean all = "ALL".equalsIgnoreCase(input.getValueString("scope"));
List<QRecord> records = read(table, filter, all);
List<List<String>> rows = new ArrayList<>();
for(QRecord record : records)
{
List<String> row = new ArrayList<>();
columns.forEach(field -> row.add(PrintingRecordProcesses.text(record, field)));
rows.add(row);
}
String title = input.getValueString("title") != null ? input.getValueString("title") : table.getLabel();
Integer total = input.getValueInteger("printingRowCount");
if(total == null)
{
CountInput count = new CountInput();
count.setTableName(table.getName());
count.setFilter(filter.clone().withSkip(null).withLimit(null));
count.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
total = new CountAction().execute(count).getCount();
}
int skip = filter.getSkip() == null ? 0 : filter.getSkip();
String scope = all ? (total != null && total > records.size() ? "first " + records.size() + " of " + total + " rows (print limit " + filter.getLimit() + ")" : "all " + records.size() + " rows")
: "rows " + (records.isEmpty() ? 0 : skip + 1) + "-" + (skip + records.size()) + (total != null ? " of " + total : "");
String described = input.getValueString("filterDescription");
String sort = sortText(table, filter);
String subtitle = scope + " - " + (described != null && !described.isBlank() ? "filter " + described : "no filter") + (sort.isEmpty() ? "" : " - sort " + sort);
String note = (described != null && !described.isBlank() ? "Filter: " + described : "No filter") + (sort.isEmpty() ? "" : " - sort: " + sort);
byte[] bytes = PrintingTableDocuments.pdf(title, subtitle, note,
columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), rows, widths(input.getValueString("columnWidths")), pageSize, orientation(input),
PrintingChrome.from(input, table.getLabel() == null ? table.getName() : table.getLabel()));
String message = all && total != null && total > records.size()
? "Printed the first " + records.size() + " of " + total + " rows: the print limit is " + filter.getLimit() + " rows. Narrow the filter to print the rest." : null;
return (new Rendered(title, bytes, message));
}
/***************************************************************************
** The "sections" value: a JSON array of {title, lines: [..]} (at most 500
** sections), as clients send a screen's content.
***************************************************************************/
static List<PrintingTextDocuments.Section> sections(String json) throws QException
{
try
{
org.json.JSONArray array = new org.json.JSONArray(json);
if(array.length() > 500)
{
throw new QException("Too many sections to print (at most 500)");
}
List<PrintingTextDocuments.Section> out = new ArrayList<>();
for(int i = 0; i < array.length(); i++)
{
org.json.JSONObject section = array.getJSONObject(i);
List<String> lines = new ArrayList<>();
org.json.JSONArray given = section.optJSONArray("lines");
for(int j = 0; given != null && j < given.length(); j++)
{
lines.add(given.optString(j, ""));
}
out.add(new PrintingTextDocuments.Section(section.optString("title", ""), lines));
}
return (out);
}
catch(org.json.JSONException exception)
{
throw new QException("The sections are not valid: " + exception.getMessage(), exception);
}
}
/***************************************************************************
** Request identity, and for a table list the number of rows it would print.
***************************************************************************/
@@ -480,14 +378,45 @@ public final class PrintingListProcesses
{
throw new QException("Restart the print to obtain a safe request identity");
}
Rendered rendered = render(input, TABLE_LIST.equals(input.getProcessName()));
if(rendered.message() != null)
String pageSize = input.getValueString("pageSize") == null ? "LETTER" : input.getValueString("pageSize").toUpperCase(Locale.ROOT);
String title;
byte[] bytes;
if(TABLE_LIST.equals(input.getProcessName()))
{
output.addValue("printingTruncated", true);
output.addValue("printingMessage", rendered.message());
QTableMetaData table = table(input);
List<QFieldMetaData> columns = columns(table, input.getValueString("columns"));
QQueryFilter filter = filter(input);
boolean all = "ALL".equalsIgnoreCase(input.getValueString("scope"));
List<QRecord> records = read(table, filter, all);
List<List<String>> rows = new ArrayList<>();
for(QRecord record : records)
{
List<String> row = new ArrayList<>();
columns.forEach(field -> row.add(PrintingRecordProcesses.text(record, field)));
rows.add(row);
}
title = input.getValueString("title") != null ? input.getValueString("title") : table.getLabel();
Integer total = input.getValueInteger("printingRowCount");
int skip = filter.getSkip() == null ? 0 : filter.getSkip();
String scope = all ? (total != null && total > records.size() ? "first " + records.size() + " of " + total + " rows (print limit " + filter.getLimit() + ")" : "all " + records.size() + " rows")
: "rows " + (records.isEmpty() ? 0 : skip + 1) + "-" + (skip + records.size()) + (total != null ? " of " + total : "");
String described = input.getValueString("filterDescription");
String sort = sortText(table, filter);
String subtitle = scope + " - " + (described != null && !described.isBlank() ? "filter " + described : "no filter") + (sort.isEmpty() ? "" : " - sort " + sort);
bytes = PrintingTableDocuments.pdf(title, subtitle, (table.getLabel() == null ? table.getName() : table.getLabel()) + " - " + PrintingRecordProcesses.printedBy(),
columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), rows, widths(input.getValueString("columnWidths")), pageSize, orientation(input));
if(all && total != null && total > records.size())
{
output.addValue("printingTruncated", true);
output.addValue("printingMessage", "Printed the first " + records.size() + " of " + total + " rows: the print limit is " + filter.getLimit() + " rows. Narrow the filter to print the rest.");
}
}
else
{
title = input.getValueString("title") == null || input.getValueString("title").isBlank() ? "Screen" : input.getValueString("title");
String subtitle = input.getValueString("subtitle");
bytes = PrintingTextDocuments.pdf(title, (subtitle == null || subtitle.isBlank() ? "" : subtitle + " - ") + PrintingRecordProcesses.printedBy(), input.getValueString("text"), pageSize, "LANDSCAPE".equalsIgnoreCase(input.getValueString("orientation")));
}
String title = rendered.title();
byte[] bytes = rendered.bytes();
String job = PrintingRecordProcesses.queue(printer, title, bytes, copies, "list-print:" + request + ":" + input.getProcessName() + ":" + printer.getValueString("id"));
output.addValue(PrintingRecordProcesses.JOB_IDS, job);
output.addValue("result", "Queued print job " + job + ". Check Print jobs for its status." + (output.getValue("printingMessage") != null ? " " + output.getValue("printingMessage") : ""));
@@ -1,162 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.util.HashSet;
import java.util.Set;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Immutable reported Windows queue defaults; these never change printer stock.
*******************************************************************************/
public record PrintingMediaProfile(String id, String revision, String name, int widthMicrons, int heightMicrons, int dpiX, int dpiY)
{
/***************************************************************************
** Validate the bounded wire record without coercion or guessed dimensions.
***************************************************************************/
public static PrintingMediaProfile parse(JSONObject value)
{
if(!value.keySet().containsAll(Set.of("id", "revision", "name", "widthMicrons", "heightMicrons", "dpiX", "dpiY", "source", "applyMode"))
|| !"WINDOWS_QUEUE_DEFAULT".equals(value.opt("source")) || !"DRIVER_DEFAULT".equals(value.opt("applyMode")))
{
throw new PrintingException(400, "Invalid reported media profile");
}
String id = text(value, "id", 128);
String revision = text(value, "revision", 64);
if(!id.matches("[A-Za-z0-9._-]+") || !revision.matches("[0-9a-f]{64}"))
{
throw new PrintingException(400, "Invalid media profile identity");
}
return new PrintingMediaProfile(id, revision, text(value, "name", 512), integer(value, "widthMicrons", 2000000),
integer(value, "heightMicrons", 2000000), integer(value, "dpiX", 9600), integer(value, "dpiY", 9600));
}
/***************************************************************************
** Canonical public facts are separate from the immutable job selection.
***************************************************************************/
public JSONObject json()
{
return new JSONObject().put("id", id).put("revision", revision).put("name", name).put("widthMicrons", widthMicrons)
.put("heightMicrons", heightMicrons).put("dpiX", dpiX).put("dpiY", dpiY)
.put("source", "WINDOWS_QUEUE_DEFAULT").put("applyMode", "DRIVER_DEFAULT");
}
/***************************************************************************
** Missing inventory facts mean no known stock, including older clients.
***************************************************************************/
static JSONArray inventory(JSONObject printer)
{
JSONArray values = printer.has("mediaProfiles") ? printer.optJSONArray("mediaProfiles") : new JSONArray();
if(values == null || values.length() > 32)
{
throw new PrintingException(400, "Invalid media profile list");
}
Set<String> ids = new HashSet<>();
JSONArray result = new JSONArray();
for(Object item : values)
{
if(!(item instanceof JSONObject object))
{
throw new PrintingException(400, "Invalid media profile");
}
PrintingMediaProfile profile = parse(object);
if(!ids.add(profile.id()))
{
throw new PrintingException(400, "Duplicate media profile identity");
}
result.put(profile.json());
}
return result;
}
/***************************************************************************
** Validate a selection independently before comparing it with current facts.
***************************************************************************/
static void selection(JSONObject options)
{
if(!options.has("mediaProfile"))
{
return;
}
JSONObject selected = options.optJSONObject("mediaProfile");
if(selected == null || !selected.keySet().equals(Set.of("id", "revision"))
|| !text(selected, "id", 128).matches("[A-Za-z0-9._-]+") || !text(selected, "revision", 64).matches("[0-9a-f]{64}"))
{
throw new PrintingException(400, "Invalid media profile selection");
}
}
/***************************************************************************
** Reject changed defaults before admission or document storage writes.
***************************************************************************/
static void requireCurrent(JSONObject printer, JSONObject options)
{
if(!options.has("mediaProfile"))
{
return;
}
JSONObject selected = options.getJSONObject("mediaProfile");
JSONArray profiles = new JSONArray(printer.optString("media_profiles_json", "[]"));
for(Object item : profiles)
{
PrintingMediaProfile profile = parse((JSONObject) item);
if(profile.id().equals(selected.getString("id")) && profile.revision().equals(selected.getString("revision")))
{
return;
}
}
throw new PrintingException(409, "Printer media changed or is unavailable; select the current queue default again");
}
/***************************************************************************
** Text identities and labels retain their exact value.
***************************************************************************/
private static String text(JSONObject value, String key, int maximum)
{
if(!(value.opt(key) instanceof String text) || text.isBlank() || text.length() > maximum)
{
throw new PrintingException(400, "Invalid media profile " + key);
}
return text;
}
/***************************************************************************
** Accept integral JSON values only, within the supported physical bounds.
***************************************************************************/
private static int integer(JSONObject value, String key, int maximum)
{
Object raw = value.opt(key);
if(!(raw instanceof Integer || raw instanceof Long) || ((Number) raw).longValue() < 1 || ((Number) raw).longValue() > maximum)
{
throw new PrintingException(400, "Invalid media profile " + key);
}
return ((Number) raw).intValue();
}
}
@@ -1,395 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Current-user catalog and submission boundary shared by HTTP and host processes.
*******************************************************************************/
public final class PrintingOperatorAuthorization
{
/***************************************************************************
** No cached user authority or alternate queue.
***************************************************************************/
private PrintingOperatorAuthorization()
{
}
/***************************************************************************
** Resolve the authenticated actor and current tenant before every operation.
***************************************************************************/
public static String principal() throws QException
{
if(QContext.getQSession() == null || QContext.getQSession().getUser() == null
|| QContext.getQSession().getUser().getIdReference() == null || QContext.getQSession().getUser().getIdReference().isBlank())
{
throw new QAuthenticationException("Sign in to continue");
}
new PrintingOperatorPermission().checkPermissionsThrowing(new RunBackendStepInput(), null);
PrintingProcesses.currentService();
return QContext.getQSession().getUser().getIdReference();
}
/***************************************************************************
** Native table and record checks apply even inside another backend process.
***************************************************************************/
private static QRecord record(String table, String id) throws QException
{
CallContext caller = capture();
QueryInput scoped = new QueryInput().withTableName(table).withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id))
.withCriteria(new QFilterCriteria("tenant_id", QCriteriaOperator.EQUALS, PrintingProcesses.currentService().getTenantId())).withLimit(1));
scoped.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
if(new QueryAction().execute(scoped).getRecords().isEmpty())
{
throw new QPermissionDeniedException("Print destination is not accessible");
}
caller.check();
GetInput input = new GetInput();
input.setTableName(table);
input.setPrimaryKey(id);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
QRecord row = new GetAction().execute(input).getRecord();
PermissionsHelper.checkTablePermissionThrowing(input, TablePermissionSubType.READ);
caller.check();
if(row == null || !id.equals(row.getValueString("id")))
{
throw new QPermissionDeniedException("Print destination is not accessible");
}
return row;
}
/***************************************************************************
** Authorize the exact native printer row and its enabled device each time.
** Callers use the returned wire printer identity only for this operation.
***************************************************************************/
public static QRecord destination(String printerRowId) throws QException
{
CallContext caller = capture();
QRecord printer = record(PrintingTables.PRINTER, printerRowId);
QRecord device = record(PrintingTables.DEVICE, printer.getValueString("device_id"));
if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available"))
|| !Integer.valueOf(1).equals(device.getValueInteger("enabled")))
{
throw new QPermissionDeniedException("Print destination is not approved and exposed");
}
requireDestinationRead();
caller.check();
return printer;
}
/***************************************************************************
** Shared retained-source boundary also requires the exact supported format.
***************************************************************************/
public static QRecord printer(String printerRowId, String format) throws QException
{
QRecord printer = destination(printerRowId);
if(!Set.of("PDF", "ZPL").contains(format) || !new JSONArray(printer.getValueString("formats")).toList().contains(format))
{
throw new PrintingException(400, "Printer does not support this document format");
}
return printer;
}
/***************************************************************************
** Reconciliation still checks current row authority after exposure changes.
** The caller must first establish its own accepted request/source binding.
***************************************************************************/
public static QRecord authorizeDestinationHistory(String printerRowId) throws QException
{
CallContext caller = capture();
QRecord printer = record(PrintingTables.PRINTER, printerRowId);
record(PrintingTables.DEVICE, printer.getValueString("device_id"));
requireDestinationRead();
caller.check();
return printer;
}
/***************************************************************************
** Page through authorized rows; hidden parent devices never leak through joins.
***************************************************************************/
public static JSONObject catalog(int limit, String after) throws QException
{
CallContext caller = capture();
if(limit < 1 || limit > 200 || (after != null && !after.matches("[A-Za-z0-9._-]{1,200}")))
{
throw new PrintingException(400, "Invalid catalog page");
}
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
.withCriteria(new QFilterCriteria("tenant_id", QCriteriaOperator.EQUALS, PrintingProcesses.currentService().getTenantId()))
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(limit + 1);
if(after != null)
{
filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, after));
}
QueryInput input = new QueryInput().withTableName(PrintingTables.PRINTER).withFilter(filter);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<QRecord> rows = new QueryAction().execute(input).getRecords();
caller.check();
JSONArray printers = new JSONArray();
int consumed = Math.min(limit, rows.size());
for(QRecord candidate : rows.subList(0, consumed))
{
try
{
QRecord printer = destination(candidate.getValueString("id"));
QRecord device = record(PrintingTables.DEVICE, printer.getValueString("device_id"));
requireDestinationRead();
caller.check();
printers.put(new JSONObject().put("printerRowId", printer.getValueString("id")).put("deviceId", printer.getValueString("device_id"))
.put("printerId", printer.getValueString("printer_id")).put("name", label(printer, "display_name", "name", "queue_name", "id"))
.put("deviceName", label(device, "display_name", "name", "computer_name", "id")).put("description", printer.getValueString("description")).put("location", printer.getValueString("location"))
.put("status", printer.getValueString("status")).put("formats", new JSONArray(printer.getValueString("formats")))
.put("mediaProfiles", new JSONArray(printer.getValueString("media_profiles_json") == null ? "[]" : printer.getValueString("media_profiles_json"))));
}
catch(QPermissionDeniedException hidden)
{
// Native parent-row security can legitimately hide a candidate.
}
caller.check();
}
caller.check();
return new JSONObject().put("printers", printers)
.put("nextCursor", rows.size() > limit ? rows.get(consumed - 1).getValueString("id") : JSONObject.NULL);
}
/***************************************************************************
** The process permission remains authoritative for all byte submissions.
***************************************************************************/
public static JSONObject submit(JSONObject request) throws Exception
{
CallContext caller = capture();
requireSubmission();
request = new JSONObject(request.toString());
if(!Set.of("requestId", "printerRowId", "title", "format", "contentBase64", "options").containsAll(request.keySet()))
{
throw new PrintingException(400, "Unknown operator print request field");
}
QRecord printer = printer(request.getString("printerRowId"), request.getString("format"));
caller.check();
JSONObject prepared = new JSONObject(request.toString());
prepared.put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id"))
.put("idempotencyKey", requestKey(caller.actor(), request.getString("requestId")));
try
{
return caller.service().submitAuthorized(prepared, request.getString("printerRowId"), () ->
{
caller.check();
requireSubmission();
QRecord current = printer(prepared.getString("printerRowId"), prepared.getString("format"));
if(!prepared.getString("deviceId").equals(current.getValueString("device_id")) || !prepared.getString("printerId").equals(current.getValueString("printer_id")))
{
throw new QPermissionDeniedException("Print destination changed during submission");
}
requireSubmission();
caller.check();
});
}
catch(java.sql.SQLException exception)
{
if(exception.getCause() instanceof QException denied)
{
throw denied;
}
throw exception;
}
}
/***************************************************************************
** Only the current actor's exact request can be reconciled; no job enumeration.
***************************************************************************/
public static JSONObject observe(String requestId) throws Exception
{
CallContext caller = capture();
requireStorage(false);
PrintingService.OperatorAcceptance accepted = caller.service().findOperatorAcceptance(requestKey(caller.actor(), requestId));
if(accepted == null)
{
throw new PrintingException(404, "Print request not found");
}
JSONObject job = accepted.job();
// Historical acceptance remains observable if exposure changes, but current row authority is mandatory.
QRecord printer = authorizeDestinationHistory(accepted.printerRowId());
if(!job.getString("deviceId").equals(printer.getValueString("device_id")) || !job.getString("printerId").equals(printer.getValueString("printer_id")))
{
throw new QPermissionDeniedException("Accepted print destination changed");
}
requireDestinationRead();
requireStorage(false);
caller.check();
return job;
}
/** Recheck table, credential and channel permissions after both row customizers. */
private static void requireDestinationRead() throws QException
{
for(String table : List.of(PrintingTables.PRINTER, PrintingTables.DEVICE))
{
GetInput input = new GetInput();
input.setTableName(table);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
PermissionsHelper.checkTablePermissionThrowing(input, TablePermissionSubType.READ);
}
}
/***************************************************************************
** Snapshot only authority and routing context, never session credentials.
***************************************************************************/
private static CallContext capture() throws QException
{
String actor = principal();
QSession session = QContext.getQSession();
Map<String, List<Serializable>> keys = new HashMap<>();
if(session.getSecurityKeyValues() != null)
{
session.getSecurityKeyValues().forEach((key, values) -> keys.put(key, values == null ? null : new ArrayList<>(values)));
}
return new CallContext(QContext.getQInstance(), session, session.getUser(), actor, PrintingProcesses.currentService(), keys,
session.getBackendVariants() == null ? null : new HashMap<>(session.getBackendVariants()));
}
/***************************************************************************
** Native customizers cannot replace the caller or its row-security scope.
** Permissions and credential/channel ceilings are separately checked live.
***************************************************************************/
private record CallContext(QInstance instance, QSession session, QUser user, String actor, PrintingService service,
Map<String, List<Serializable>> keys, 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()
|| !keys.equals(session.getSecurityKeyValues() == null ? Map.of() : session.getSecurityKeyValues())
|| !Objects.equals(variants, session.getBackendVariants()))
{
throw new QPermissionDeniedException("Printing authority changed during the operation");
}
}
}
/***************************************************************************
** Trusted domain writes retain process, credential and channel ceilings.
***************************************************************************/
public static void requireSubmission() throws QException
{
principal();
PermissionsHelper.checkProcessPermissionThrowing(new RunBackendStepInput().withProcessName(PrintingOperatorProcesses.SUBMIT), PrintingOperatorProcesses.SUBMIT);
requireStorage(true);
}
/***************************************************************************
** Scope ceilings do not grant generic job table reads or writes.
***************************************************************************/
private static void requireStorage(boolean mutation) throws QException
{
var instance = QContext.getQInstance();
var table = instance.getTable(PrintingTables.JOB);
if(table == null)
{
throw new QException("Printing job metadata is unavailable");
}
var rules = PermissionsHelper.getEffectivePermissionRules(table, instance);
if(PermissionsHelper.isDeniedForClientChannel(table, rules)
|| (mutation && PermissionsHelper.isWriteDeniedForClientChannel(table, rules))
|| PermissionsHelper.isOutsideCredentialScope(table, mutation ? TablePermissionSubType.INSERT : TablePermissionSubType.READ))
{
throw new QPermissionDeniedException("Current credential or client channel cannot access print acceptance");
}
}
/***************************************************************************
** Stable actor namespace protects request lookup without storing user credentials.
***************************************************************************/
private static String requestKey(String actor, String requestId) throws Exception
{
if(!requestId.matches("[A-Za-z0-9._:-]{1,128}"))
{
throw new PrintingException(400, "Invalid print request identity");
}
return "operator:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(actor.getBytes(StandardCharsets.UTF_8))) + ":" + requestId;
}
/***************************************************************************
** Legacy unnamed rows retain an honest identity fallback, never guessed media.
***************************************************************************/
private static String label(QRecord record, String... fields)
{
for(String field : fields)
{
String value = record.getValueString(field);
if(value != null && !value.isBlank())
{
return value;
}
}
throw new PrintingException(409, "Print destination has no stable identity");
}
}
@@ -1,56 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Submit permission grants no device management or general job history access.
*******************************************************************************/
public class PrintingOperatorPermission extends UseOtherPermissionNameCustomPermissionChecker
{
/***************************************************************************
** Expose the role to standard host permission assignment.
***************************************************************************/
public PrintingOperatorPermission()
{
withPermissionName("printing_operator");
}
/***************************************************************************
** Administrators retain submission access; operators receive no other role.
***************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() == null || (!QContext.getQSession().hasPermission("printing_operator") && !QContext.getQSession().hasPermission("printing_admin")))
{
throw new QPermissionDeniedException("Printing operator permission is required");
}
}
}
@@ -1,95 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import org.json.JSONObject;
/*******************************************************************************
** Explicit operator backend process for host-generated document submissions.
*******************************************************************************/
public class PrintingOperatorProcesses implements BackendStep
{
public static final String SUBMIT = "printingSubmit";
/***************************************************************************
** Host workflows call this typed input process; it grants no management role.
***************************************************************************/
public static void register(QInstance instance)
{
instance.addProcess(new QProcessMetaData().withName(SUBMIT).withLabel("Submit a print document")
.withIsHidden(true)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withCustomPermissionChecker(new QCodeReference(PrintingOperatorPermission.class)))
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(PrintingOperatorProcesses.class))
.withInputData(new QFunctionInputMetaData().withFields(List.of(new QFieldMetaData("requestId", QFieldType.STRING),
new QFieldMetaData("printerRowId", QFieldType.STRING).withPossibleValueSourceName(PrintingTables.PRINTER),
new QFieldMetaData("title", QFieldType.STRING), new QFieldMetaData("format", QFieldType.STRING).withPossibleValueSourceName("printing_format"),
new QFieldMetaData("contentBase64", QFieldType.STRING), new QFieldMetaData("options", QFieldType.STRING))))));
}
/***************************************************************************
** Direct step invocation receives the same current-actor checks as native HTTP.
***************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
try
{
JSONObject request = new JSONObject();
for(String field : List.of("requestId", "printerRowId", "title", "format", "contentBase64"))
{
request.put(field, input.getValueString(field));
}
String options = input.getValueString("options");
if(options != null)
{
request.put("options", new JSONObject(options));
}
output.addValue("job", PrintingOperatorAuthorization.submit(request).toString());
}
catch(QException exception)
{
throw exception;
}
catch(Exception exception)
{
throw new QException("Print submission failed", exception);
}
}
}
@@ -36,22 +36,6 @@ public class PrintingQBitConfig implements QBitConfig
private String storageTableName;
private String schedulerName;
private String facilityPossibleValueSourceName;
private transient RetainedPrintSourceAuthority retainedSourceAuthority;
/** Explicit trusted host integration; absent means retained submissions are disabled. */
public PrintingQBitConfig withRetainedSourceAuthority(RetainedPrintSourceAuthority value)
{
retainedSourceAuthority = java.util.Objects.requireNonNull(value);
return (this);
}
/** Never serialized into client metadata or supplied by a request. */
public RetainedPrintSourceAuthority getRetainedSourceAuthority()
{
return (retainedSourceAuthority);
}
@@ -111,7 +111,6 @@ public class PrintingQBitProducer implements QBitProducer
QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at"));
printers.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Approved for printing").withPossibleValueSourceName("printing_approved"));
printers.withField(new QFieldMetaData("available", QFieldType.INTEGER).withLabel("Exposed by device").withPossibleValueSourceName("printing_available"));
printers.withField(new QFieldMetaData("media_profiles_json", QFieldType.STRING).withLabel("Reported queue default media"));
QTableMetaData jobs = table(PrintingTables.JOB, "Print jobs", List.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "options_json", "idempotency_key", "created_at", "status", "message", "spooler_job_id"));
jobs.withField(new QFieldMetaData("content_length", QFieldType.INTEGER).withLabel("Document size (bytes)"));
QTableMetaData commands = table(PrintingTables.COMMAND, "Device control history", List.of("id", "tenant_id", "device_id", "request_id", "action", "job_id", "actor", "status", "created_at", "expires_at", "result_code"));
@@ -174,9 +173,7 @@ public class PrintingQBitProducer implements QBitProducer
QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs).withChild(commands);
app.withPermissionRules(PrintingProcesses.permissionRules());
PrintingProcesses.register(instance, app);
PrintingOperatorProcesses.register(instance);
PrintingListProcesses.register(instance);
PrintingRenderProcesses.register(instance);
PrintingDeviceProcesses.register(instance);
PrintingUiApi.register(instance);
PrintingRetentionProcesses.register(instance, config);
@@ -43,15 +43,7 @@ public class PrintingReadPermission extends PrintingAdminPermission
@Override
public void checkPermissionsThrowing(AbstractActionInput input, 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())))
{
new PrintingOperatorPermission().checkPermissionsThrowing(input, metadata);
}
else
{
super.checkPermissionsThrowing(input, metadata);
}
super.checkPermissionsThrowing(input, metadata);
if(input != null && !(input instanceof QueryInput) && !(input instanceof GetInput) && !(input instanceof CountInput)
&& !(input instanceof MetaDataInput) && !(input instanceof TableMetaDataInput))
{
@@ -252,31 +252,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
}
finally
{
stopWorkers();
}
}
/**
* Stop accepting channel work and wait, bounded, for drains already running. Every channel was fenced and its session closed first, so
* an in-flight drain fails quickly; without this wait it could query durable state after the host has stopped (and, for a file database,
* recreate it empty), which is what left a test's temporary directory populated after cleanup.
*/
private void stopWorkers()
{
workers.shutdown();
try
{
if(!workers.awaitTermination(5, TimeUnit.SECONDS))
{
workers.shutdownNow();
workers.awaitTermination(5, TimeUnit.SECONDS);
}
}
catch(InterruptedException exception)
{
workers.shutdownNow();
Thread.currentThread().interrupt();
workers.shutdown();
}
}
@@ -461,8 +437,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
{
if(hello != null || !payload.keySet().equals(Set.of("agentVersion", "capabilities", "lastAppliedRevision", "lastSettingsRevision", "limits"))
|| !payload.getJSONArray("capabilities").toList().contains("realtime-v1")
|| payload.getJSONArray("capabilities").length() > 4
|| !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").containsAll(payload.getJSONArray("capabilities").toList()))
|| payload.getJSONArray("capabilities").length() > 3
|| !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL").containsAll(payload.getJSONArray("capabilities").toList()))
{
throw new PrintingException(409, "Realtime capability is required");
}
@@ -569,7 +545,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
JSONArray capabilities = new JSONArray();
for(Object value : hello.getJSONArray("capabilities"))
{
if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").contains(value))
if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL").contains(value))
{
capabilities.put(value);
}
@@ -86,17 +86,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));
}
/***************************************************************************
** Render sections and child list summaries under the app's header/footer.
***************************************************************************/
public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children, PrintingChrome chrome) throws QException
{
try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle, chrome))
try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle))
{
int columns = layout.columns(BODY_SIZE);
int labelWidth = Math.min(28, Math.max(8, sections.stream().flatMap(s -> s.fields().keySet().stream()).mapToInt(String::length).max().orElse(8) + 1));
@@ -216,20 +216,9 @@ 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) throws QException
{
Serializable primaryKey = selected.getValue(table.getPrimaryKeyField());
PrintingListProcesses.Rendered rendered = render(table, primaryKey, chrome);
return (queue(printer, rendered.title(), rendered.bytes(), copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id")));
}
/***************************************************************************
** The record sheet of one record (read with the caller's permissions).
***************************************************************************/
static PrintingListProcesses.Rendered render(QTableMetaData table, Serializable primaryKey, PrintingChrome chrome) throws QException
{
GetInput get = new GetInput();
get.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
get.setTableName(table.getName());
@@ -243,10 +232,9 @@ public final class PrintingRecordProcesses
}
String label = record.getRecordLabel() != null ? record.getRecordLabel() : String.valueOf(primaryKey);
String tableLabel = table.getLabel() == null ? table.getName() : table.getLabel();
String subtitle = tableLabel + " " + primaryKey;
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));
String subtitle = tableLabel + " " + primaryKey + " - " + printedBy();
byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, sections(table, record), children(table, record));
return (queue(printer, tableLabel + ": " + label, bytes, copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id")));
}
@@ -501,7 +489,7 @@ public final class PrintingRecordProcesses
QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE));
for(QRecord record : input.getRecords())
{
jobIds.add(submit(table, record, printer, copies, request, PrintingChrome.from(input, null)));
jobIds.add(submit(table, record, printer, copies, request));
done.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField()));
output.getRecords().add(record);
}
@@ -1,150 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.processes.ProcessFileDownload;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** printingRenderToPdf: render the same document a print job would carry - a
** record sheet, a table list or a text page - and hand it to the caller as a
** download instead of queueing it on a printer ("Save as PDF").
**
** Values: kind RECORD (tableName, recordId), LIST (the printingPrintTableList
** values: tableName, columns, columnWidths, filterJSON, scope, maxRows,
** pageSize, orientation, title, filterDescription) or TEXT (title, subtitle,
** text, orientation, pageSize). The result carries downloadFileName and
** serverFilePath for GET /download/{file} (the standard process download),
** and printingMessage when a list left rows out. No printer is needed; reads
** enforce the caller's table and record permissions.
*******************************************************************************/
public final class PrintingRenderProcesses
{
public static final String RENDER = "printingRenderToPdf";
/***************************************************************************
** Utility class.
***************************************************************************/
private PrintingRenderProcesses()
{
}
/***************************************************************************
** Register the process.
***************************************************************************/
public static void register(QInstance instance)
{
instance.addProcess(new QProcessMetaData().withName(RENDER).withLabel("Save as PDF").withPermissionRules(PrintingProcesses.permissionRules())
.withStep(new QBackendStepMetaData().withName("render").withCode(new QCodeReference(Render.class)))
.withStep(new QFrontendStepMetaData().withName("download").withLabel("Download")
.withViewFields(List.of(new QFieldMetaData("downloadFileName", QFieldType.STRING).withLabel("File")))
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.DOWNLOAD_FORM))));
}
/***************************************************************************
** The download name: the document title made file-safe, with .pdf.
***************************************************************************/
static String fileName(String title)
{
String stem = (title == null ? "" : title).replaceAll("[^A-Za-z0-9._-]+", "-").replaceAll("^[-.]+|-+$", "");
return ((stem.isEmpty() ? "document" : PrintingRecordDocuments.truncate(stem, 80).replace("~", "")) + ".pdf");
}
/***************************************************************************
** Render and register the file for download.
***************************************************************************/
public static class Render implements BackendStep
{
/***************************************************************************
** One PDF, delivered through the standard process download.
***************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
PrintingProcesses.currentService();
String kind = input.getValueString("kind") == null ? "" : input.getValueString("kind").toUpperCase(Locale.ROOT);
PrintingListProcesses.Rendered rendered = switch(kind)
{
case "RECORD" ->
{
QTableMetaData table = PrintingListProcesses.table(input);
String id = input.getValueString("recordId");
if(id == null || id.isBlank())
{
throw new QException("Give the record to save (recordId)");
}
yield PrintingRecordProcesses.render(table, com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), id), PrintingChrome.from(input, null));
}
case "LIST" -> PrintingListProcesses.render(input, true);
case "TEXT" -> PrintingListProcesses.render(input, false);
default -> throw new QException("kind must be RECORD, LIST or TEXT");
};
try
{
File file = Files.createTempFile("qqq-print-", ".pdf").toFile();
file.deleteOnExit();
Files.write(file.toPath(), rendered.bytes());
output.addValue("downloadFileName", fileName(rendered.title()));
output.addValue("serverFilePath", ProcessFileDownload.register(file));
output.addValue("printingTitle", rendered.title());
output.addValue("printingBytes", rendered.bytes().length);
if(rendered.message() != null)
{
output.addValue("printingTruncated", true);
output.addValue("printingMessage", rendered.message().replace("Printed the first", "Saved the first").replace("the print limit", "the limit").replace("to print the rest", "to save the rest"));
}
}
catch(IOException exception)
{
throw new QException("Could not write the PDF for download", exception);
}
}
}
}
@@ -277,14 +277,9 @@ public class PrintingService
"CREATE TABLE IF NOT EXISTS printing_device (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, facility_id VARCHAR(200) NOT NULL, name VARCHAR(200) NOT NULL, agent_version VARCHAR(100) NOT NULL, token_hash VARCHAR(64) UNIQUE NOT NULL, last_seen_at VARCHAR(40) NOT NULL, enabled INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS printing_printer (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, name VARCHAR(300) NOT NULL, driver_name VARCHAR(300), port_name VARCHAR(300), status VARCHAR(40) NOT NULL, formats VARCHAR(100) NOT NULL, enabled INTEGER NOT NULL, last_seen_at VARCHAR(40) NOT NULL, UNIQUE(device_id, printer_id))",
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS available INTEGER DEFAULT 0 NOT NULL",
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS media_profiles_json TEXT DEFAULT '[]' NOT NULL",
"CREATE TABLE IF NOT EXISTS printing_job (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, title VARCHAR(300) NOT NULL, format VARCHAR(10) NOT NULL, content_hash VARCHAR(64) NOT NULL, content_base64 TEXT NOT NULL, content_length INTEGER NOT NULL, options_json VARCHAR(2000) NOT NULL, idempotency_key VARCHAR(300) NOT NULL, created_at VARCHAR(40) NOT NULL, status VARCHAR(40) NOT NULL, message VARCHAR(2000), spooler_job_id VARCHAR(100), UNIQUE(tenant_id, idempotency_key))",
"CREATE TABLE IF NOT EXISTS printing_event (id VARCHAR(100) PRIMARY KEY, job_id VARCHAR(36) NOT NULL REFERENCES printing_job(id), status VARCHAR(40) NOT NULL, event_json VARCHAR(4000) NOT NULL, created_at VARCHAR(40) NOT NULL)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_row_id VARCHAR(36)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS retained_actor_id VARCHAR(200)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS retained_binding_json VARCHAR(16000)",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS accepted_event_id VARCHAR(128)",
"ALTER TABLE printing_event ADD COLUMN IF NOT EXISTS event_origin VARCHAR(40)",
"ALTER TABLE printing_job ALTER COLUMN content_base64 DROP NOT NULL",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS content_available INTEGER DEFAULT 1 NOT NULL",
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS completed_at VARCHAR(40)",
@@ -785,7 +780,6 @@ public class PrintingService
update(connection, "INSERT INTO printing_printer (id, tenant_id, device_id, printer_id, name, driver_name, port_name, status, formats, enabled, last_seen_at, available, queue_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?)", UUID.randomUUID().toString(), tenantId, deviceId, printerId, name, driver, port, status, formats, now, name);
}
String rowId = query(connection, "SELECT id FROM printing_printer WHERE device_id=? AND printer_id=?", deviceId, printerId).getFirst().getString("id");
update(connection, "UPDATE printing_printer SET media_profiles_json=? WHERE id=?", PrintingMediaProfile.inventory(printer).toString(), rowId);
updateProfile(connection, "printing_printer", rowId, printer, "displayName", "queue_name");
}
update(connection, "UPDATE printing_device SET last_seen_at=? WHERE id=?", Instant.now().toString(), deviceId);
@@ -824,15 +818,6 @@ public class PrintingService
***************************************************************************/
public JSONObject submit(JSONObject request) throws SQLException
{
return (submitAuthorized(request, null, null));
}
/** Native operators retain current authority throughout shared queue admission. */
JSONObject submitAuthorized(JSONObject request, String printerRowId, SubmissionAuthorization authorize) throws SQLException
{
SubmissionGuard guard = authorize == null ? null : new SubmissionGuard(printerRowId, authorize);
checkSubmission(guard);
String format = required(request.optString("format"), "format");
long maximum = realtimePolicy().documentLimit(format);
if(request.getString("contentBase64").length() > ((maximum + 2) / 3) * 4)
@@ -860,47 +845,7 @@ public class PrintingService
{
throw new PrintingException(400, "PDF header is missing");
}
return (submitPrepared(request, hash(content), content.length, connection -> content, null, guard));
}
/** A caller-owned current authorization check, never externally asserted proof. */
@FunctionalInterface
interface SubmissionAuthorization
{
/** Recheck caller identity, current ceilings and exact destination. */
void check() throws com.kingsrook.qqq.backend.core.exceptions.QException;
}
/** Native row identity and live authority are independent of retained-source proof. */
private record SubmissionGuard(String printerRowId, SubmissionAuthorization authorize)
{
/** Host callbacks cannot retarget an authorized native row to another queue. */
void requirePrinter(JSONObject row, String field)
{
if(!printerRowId.equals(row.optString(field)))
{
throw new PrintingException(409, "The authorized print destination changed");
}
}
}
/** Preserve checked native authorization through the SQL transaction boundary. */
private static void checkSubmission(SubmissionGuard guard) throws SQLException
{
if(guard != null)
{
try
{
guard.authorize().check();
}
catch(com.kingsrook.qqq.backend.core.exceptions.QException exception)
{
throw new SQLException("Print submission authorization failed", exception);
}
}
return (submitPrepared(request, hash(content), content.length, connection -> content));
}
@@ -924,58 +869,6 @@ public class PrintingService
***************************************************************************/
private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader) throws SQLException
{
return (submitPrepared(request, digest, contentLength, loader, null, null));
}
/** Only the native facade supplies retained bindings and current-authority checks. */
JSONObject submitRetained(JSONObject request, JSONObject binding, String actor, RetainedPrintFacade.Checked authorize, RetainedContent loader) throws SQLException
{
binding.put("options", printOptions(request));
if(binding.toString().length() > 16000)
{
throw new PrintingException(400, "Retained print binding is too large");
}
JSONObject source = binding.getJSONObject("source");
BoundSubmission bound = new BoundSubmission(binding.toString(), actor);
return (submitPrepared(request, source.getString("sha256"), source.getInt("sizeBytes"), connection ->
{
try
{
byte[] bytes = loader.read();
if("PDF".equals(request.getString("format")) && !new String(bytes, 0, Math.min(5, bytes.length), StandardCharsets.US_ASCII).equals("%PDF-"))
{
throw new PrintingException(400, "PDF header is missing");
}
return bytes;
}
catch(com.kingsrook.qqq.backend.core.exceptions.QException exception)
{
throw new SQLException("Retained content authorization failed", exception);
}
}, bound, new SubmissionGuard(binding.getString("printerRecordId"), authorize::run)));
}
/** Deferred current-owner content read after shared capacity admission. */
@FunctionalInterface
interface RetainedContent
{
/** Return only authorized exact bytes. */
byte[] read() throws com.kingsrook.qqq.backend.core.exceptions.QException;
}
/** Request-local immutable binding; never an externally accepted JSON proof. */
private record BoundSubmission(String json, String actor)
{
}
/** Reuse the existing storage reservation, queue admission, and commit path. */
private JSONObject submitPrepared(JSONObject request, String digest, int contentLength, DocumentLoader loader, BoundSubmission bound, SubmissionGuard guard) throws SQLException
{
checkSubmission(guard);
String deviceId = required(request.optString("deviceId"), "deviceId");
String printerId = required(request.optString("printerId"), "printerId");
String key = required(request.optString("idempotencyKey"), "idempotencyKey");
@@ -1005,32 +898,16 @@ public class PrintingService
requireDevice(connection, deviceId);
query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId);
requireDevice(connection, deviceId);
checkSubmission(guard);
List<JSONObject> previous = query(connection, "SELECT " + JOB_COLUMNS + ", retained_binding_json, retained_actor_id, accepted_event_id FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
List<JSONObject> previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
if(!previous.isEmpty())
{
JSONObject old = previous.getFirst();
if(guard != null)
{
guard.requirePrinter(old, "printer_row_id");
}
if(key.startsWith("operator:") && !old.getString("title").equals(title))
{
throw new PrintingException(409, "Idempotency key was already used for a different request");
}
if((bound == null) != old.isNull("retained_binding_json")
|| (bound != null && (!new JSONObject(bound.json()).similar(new JSONObject(old.getString("retained_binding_json"))) || !bound.actor().equals(old.getString("retained_actor_id")))))
{
throw new PrintingException(409, "Idempotency key belongs to a different accepted source");
}
if(!old.getString("device_id").equals(deviceId) || !old.getString("printer_id").equals(printerId) || !old.getString("content_hash").equals(digest) || !old.getString("format").equals(format) || !new JSONObject(old.getString("options_json")).similar(options))
{
throw new PrintingException(409, "Idempotency key was already used for a different request");
}
JSONObject replay = bound == null ? job(old) : accepted(connection, old);
checkSubmission(guard);
connection.commit();
return (replay);
return (job(old));
}
if(query(connection, "SELECT id FROM printing_document_orphan WHERE tenant_id=? AND idempotency_key=? LIMIT 1", tenantId, key).isEmpty())
{
@@ -1056,16 +933,10 @@ public class PrintingService
{
throw new PrintingException(400, "Printer is not enabled for this format");
}
if(guard != null)
{
guard.requirePrinter(printers.getFirst(), "id");
}
if(contentLength > contentLimit)
{
throw new PrintingException(413, "Document exceeds the current configured format limit");
}
PrintingMediaProfile.requireCurrent(printers.getFirst(), options);
checkSubmission(guard);
PrintingAdmission.reserve(connection, tenantId, deviceId, id, contentLength, new PrintingAdmissionSettings(dataSource, tenantId, admissionLimits).effective(connection));
if(storageTableName != null)
{
@@ -1084,44 +955,26 @@ public class PrintingService
{
throw new PrintingException(400, "Printer is not enabled for this format");
}
if(guard != null)
{
guard.requirePrinter(printers.getFirst(), "id");
}
}
PrintingMediaProfile.requireCurrent(printers.getFirst(), options);
checkSubmission(guard);
byte[] content = loader.load(connection);
if(content.length != contentLength || !hash(content).equals(digest))
{
throw new PrintingException(409, "Stored print document integrity validation failed");
}
checkSubmission(guard);
if(storageTableName != null)
{
documentStorage.put(storageTableName, reference, format, content);
}
checkSubmission(guard);
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"), storageTableName, reference, binding);
if(bound != null)
{
String eventId = UUID.randomUUID().toString();
String acceptedAt = Instant.now().toString();
update(connection, "UPDATE printing_job SET retained_actor_id=?, retained_binding_json=?, accepted_event_id=? WHERE id=?", bound.actor(), bound.json(), eventId, id);
JSONObject event = new JSONObject().put("type", "ACCEPTED").put("origin", "SERVER_ACCEPTANCE").put("status", "QUEUED");
update(connection, "INSERT INTO printing_event (id, job_id, status, event_json, created_at, event_origin) VALUES (?, ?, 'QUEUED', ?, ?, 'SERVER_ACCEPTANCE')", eventId, id, event.toString(), acceptedAt);
}
update(connection, "DELETE FROM printing_document_orphan WHERE id=? AND tenant_id=?", id, tenantId);
JSONObject stored = query(connection, "SELECT " + JOB_COLUMNS + ", retained_binding_json, retained_actor_id, accepted_event_id FROM printing_job WHERE id=?", id).getFirst();
JSONObject result = bound == null ? job(stored) : accepted(connection, stored);
checkSubmission(guard);
JSONObject result = job(query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE id=?", id).getFirst());
connection.commit();
realtimeBus.publish(tenantId, deviceId);
return (result);
}
catch(Exception exception)
{
rollbackAfterFailure(connection, exception);
connection.rollback();
if(uploadedId != null)
{
try
@@ -1130,10 +983,7 @@ public class PrintingService
}
catch(Exception cleanupFailure)
{
if(cleanupFailure != exception)
{
exception.addSuppressed(cleanupFailure);
}
exception.addSuppressed(cleanupFailure);
}
}
throw exception;
@@ -1147,13 +997,6 @@ public class PrintingService
** Preserve request intent: reject malformed types instead of coercing them.
***************************************************************************/
private JSONObject printOptions(JSONObject request) throws SQLException
{
return (printOptions(request, Math.toIntExact(realtimePolicy().get("maxCopies"))));
}
/** Normalize an immutable intent without reading or creating tenant settings during reconciliation. */
private static JSONObject printOptions(JSONObject request, int maximumCopies)
{
if(request.has("options") && !(request.opt("options") instanceof JSONObject))
{
@@ -1178,7 +1021,7 @@ public class PrintingService
}
}
Object scale = options.has("scale") ? options.opt("scale") : "ACTUAL_SIZE";
if(count < 1 || count > maximumCopies || !(scale instanceof String) || !Set.of("ACTUAL_SIZE", "FIT").contains(scale))
if(count < 1 || count > realtimePolicy().get("maxCopies") || !(scale instanceof String) || !Set.of("ACTUAL_SIZE", "FIT").contains(scale))
{
throw new PrintingException(400, "Invalid print options");
}
@@ -1186,7 +1029,6 @@ public class PrintingService
{
throw new PrintingException(400, "Custom paper dimensions are not supported yet; configure the Windows queue");
}
PrintingMediaProfile.selection(options);
options.put("copies", count).put("scale", scale);
if(options.toString().length() > 2000)
{
@@ -1337,7 +1179,7 @@ public class PrintingService
requireDevice(connection, deviceId);
query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId);
requireDevice(connection, deviceId);
JSONObject control = query(connection, "SELECT dispatch_paused,connection_state,control_capabilities FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst();
JSONObject control = query(connection, "SELECT dispatch_paused,connection_state FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst();
if(control.getInt("dispatch_paused") == 1 || !"CONNECTED".equals(control.getString("connection_state")))
{
connection.commit();
@@ -1350,11 +1192,6 @@ public class PrintingService
return (new JSONObject().put("job", JSONObject.NULL));
}
JSONObject selected = candidates.getFirst();
if(new JSONObject(selected.getString("options_json")).has("mediaProfile")
&& !new JSONArray(control.optString("control_capabilities", "[]")).toList().contains("printer-media-v1"))
{
throw new PrintingException(409, "This job requires an agent that validates printer media profiles");
}
if("QUEUED".equals(selected.getString("status")))
{
update(connection, "UPDATE printing_job SET status='CLAIMED' WHERE id=?", selected.getString("id"));
@@ -1476,7 +1313,7 @@ public class PrintingService
throw new PrintingException(409, "Invalid state transition");
}
String eventTime = Instant.now().toString();
update(connection, "INSERT INTO printing_event (id, job_id, status, event_json, created_at, event_origin) VALUES (?, ?, ?, ?, ?, 'DEVICE_OBSERVATION')", eventId, jobId, status, event.toString(), eventTime);
update(connection, "INSERT INTO printing_event VALUES (?, ?, ?, ?, ?)", eventId, jobId, status, event.toString(), eventTime);
if(Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(status))
{
update(connection, "UPDATE printing_job SET completed_at=? WHERE id=?", eventTime, jobId);
@@ -1553,7 +1390,7 @@ public class PrintingService
for(JSONObject row : rows.subList(0, Math.min(limit, rows.size())))
{
result.put(new JSONObject().put("printerId", row.getString("printer_id")).put("name", row.getString("queue_name")).put("displayName", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location"))
.put("mediaProfiles", new JSONArray(row.getString("media_profiles_json"))).put("driverName", row.optString("driver_name")).put("portName", row.optString("port_name")).put("status", row.getString("status")).put("formats", new JSONArray(row.getString("formats"))).put("enabled", row.getInt("enabled") == 1).put("available", row.getInt("available") == 1).put("lastSeenAt", row.getString("last_seen_at")));
.put("driverName", row.optString("driver_name")).put("portName", row.optString("port_name")).put("status", row.getString("status")).put("formats", new JSONArray(row.getString("formats"))).put("enabled", row.getInt("enabled") == 1).put("available", row.getInt("available") == 1).put("lastSeenAt", row.getString("last_seen_at")));
}
JSONObject response = new JSONObject().put("printers", result).put("nextCursor", JSONObject.NULL);
if(rows.size() > limit)
@@ -1659,126 +1496,6 @@ public class PrintingService
/** Cleanup never replaces the acceptance/authorization failure that caused rollback. */
static void rollbackAfterFailure(Connection connection, Exception original)
{
try
{
connection.rollback();
}
catch(Exception cleanupFailure)
{
if(cleanupFailure != original)
{
original.addSuppressed(cleanupFailure);
}
}
}
/** Exact accepted-source lookup; unresolved object reservations never mean absence. */
JSONObject findRetained(String key, JSONObject expected) throws SQLException
{
if(expected != null)
{
expected.put("options", printOptions(new JSONObject().put("options", expected.getJSONObject("options")), 32767));
}
try(Connection connection = dataSource.getConnection())
{
connection.setAutoCommit(false);
try
{
PrintingAdmission.lock(connection, tenantId);
JSONObject result = findRetained(connection, key, expected);
connection.commit();
return (result);
}
catch(Exception exception)
{
rollbackAfterFailure(connection, exception);
throw exception;
}
}
}
/** The existing admission lock makes absence consistent with in-flight submissions. */
private JSONObject findRetained(Connection connection, String key, JSONObject expected) throws SQLException
{
List<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())
{
if(!query(connection, "SELECT id FROM printing_document_orphan WHERE tenant_id=? AND idempotency_key=?", tenantId, key).isEmpty())
{
throw new PrintingException(409, "Retained print acceptance is pending recovery");
}
return null;
}
JSONObject row = rows.getFirst();
if(row.isNull("retained_binding_json") || (expected != null && !expected.similar(new JSONObject(row.getString("retained_binding_json")))))
{
throw new PrintingException(409, "Accepted source binding differs");
}
return (accepted(connection, row));
}
/** Verify the server acceptance event and immutable job content/destination before returning proof. */
private JSONObject accepted(Connection connection, JSONObject row) throws SQLException
{
if(row.isNull("retained_binding_json") || row.isNull("accepted_event_id") || row.isNull("retained_actor_id"))
{
throw new PrintingException(409, "Job has no retained acceptance binding");
}
JSONObject binding = new JSONObject(row.getString("retained_binding_json"));
JSONObject source = binding.getJSONObject("source");
if(!tenantId.equals(binding.getString("tenantId")) || !row.getString("retained_actor_id").equals(binding.getString("actorId"))
|| !row.getString("idempotency_key").equals(RetainedPrintFacade.key(binding.getString("requestId")))
|| !row.getString("device_id").equals(binding.getString("deviceId")) || !row.getString("printer_id").equals(binding.getString("printerId"))
|| !row.getString("printer_row_id").equals(binding.getString("printerRecordId")) || !row.getString("title").equals(binding.getString("title"))
|| !row.getString("content_hash").equals(source.getString("sha256")) || row.getLong("content_length") != source.getLong("sizeBytes")
|| !row.getString("format").equals("application/pdf".equals(source.getString("mediaType")) ? "PDF" : "ZPL")
|| !new JSONObject(row.getString("options_json")).similar(binding.getJSONObject("options")))
{
throw new PrintingException(409, "Accepted job binding is inconsistent");
}
List<JSONObject> events = query(connection, "SELECT * FROM printing_event WHERE id=? AND job_id=? AND event_origin='SERVER_ACCEPTANCE' AND status='QUEUED'", row.getString("accepted_event_id"), row.getString("id"));
if(events.size() != 1 || !new JSONObject(events.getFirst().getString("event_json")).similar(new JSONObject().put("type", "ACCEPTED").put("origin", "SERVER_ACCEPTANCE").put("status", "QUEUED")))
{
throw new PrintingException(409, "Committed server acceptance event is absent or inconsistent");
}
return (new JSONObject().put("jobId", row.getString("id")).put("acceptedEventId", row.getString("accepted_event_id"))
.put("acceptedAt", events.getFirst().getString("created_at")).put("binding", binding));
}
/** Old unclassified events and server acceptance are not device observation evidence. */
JSONObject retainedObservation(String key, String eventId) throws SQLException
{
JSONObject acceptance = findRetained(key, null);
if(acceptance == null)
{
throw new PrintingException(404, "Accepted print intent not found");
}
try(Connection connection = dataSource.getConnection())
{
List<JSONObject> events = query(connection, "SELECT * FROM printing_event WHERE id=? AND job_id=? AND event_origin='DEVICE_OBSERVATION'", eventId, acceptance.getString("jobId"));
if(events.size() != 1)
{
throw new PrintingException(404, "Committed device observation not found");
}
JSONObject row = events.getFirst();
JSONObject event = new JSONObject(row.getString("event_json"));
if(!eventId.equals(event.getString("eventId")) || !row.getString("status").equals(event.getString("status")))
{
throw new PrintingException(409, "Device observation is inconsistent");
}
return (new JSONObject().put("acceptance", acceptance).put("recordedAt", row.getString("created_at"))
.put("state", row.getString("status")).put("spoolerJobId", event.opt("spoolerJobId") == null ? JSONObject.NULL : event.opt("spoolerJobId")));
}
}
/***************************************************************************
** Find an accepted request during durable-effect reconciliation.
***************************************************************************/
@@ -1792,32 +1509,6 @@ public class PrintingService
}
/** The native row accepted with an operator request is private authorization data. */
record OperatorAcceptance(JSONObject job, String printerRowId)
{
}
/** Read the exact committed row binding; a replacement queue cannot inherit history. */
OperatorAcceptance findOperatorAcceptance(String key) throws SQLException
{
try(Connection connection = dataSource.getConnection())
{
List<JSONObject> rows = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
if(rows.isEmpty())
{
return null;
}
JSONObject row = rows.getFirst();
if(row.isNull("printer_row_id") || row.getString("printer_row_id").isBlank())
{
throw new PrintingException(409, "Accepted print destination binding is unavailable");
}
return new OperatorAcceptance(job(row), row.getString("printer_row_id"));
}
}
/***************************************************************************
** Host-authorized maintenance only, never called by device routes. Purge a
@@ -51,7 +51,7 @@ public final class PrintingTableDocuments
***************************************************************************/
public static byte[] pdf(String title, String subtitle, List<String> columns, List<List<String>> rows, String pageSize, boolean landscape) throws QException
{
return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT", null));
return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT"));
}
@@ -63,7 +63,7 @@ public final class PrintingTableDocuments
** the columns do not fit portrait. The header repeats on every page; the
** footer text goes at the bottom left of every page.
***************************************************************************/
public static byte[] pdf(String title, String subtitle, String footer, List<String> columns, List<List<String>> rows, int[] preferredWidths, String pageSize, String orientation, PrintingChrome chrome) throws QException
public static byte[] pdf(String title, String subtitle, String footer, List<String> columns, List<List<String>> rows, int[] preferredWidths, String pageSize, String orientation) throws QException
{
if(columns.isEmpty())
{
@@ -78,7 +78,7 @@ public final class PrintingTableDocuments
}
}
boolean landscape = isLandscape(orientation, natural, pageSize);
try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome))
try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle))
{
layout.footer(footer);
int[] widths = PrintingLayout.fit(natural, layout.columns(FONT_SIZE));
@@ -50,16 +50,6 @@ public final class PrintingTextDocuments
** Render text on LETTER or A4, portrait or landscape.
***************************************************************************/
public static byte[] pdf(String title, String subtitle, String text, String pageSize, boolean landscape) throws QException
{
return (pdf(title, subtitle, text, pageSize, landscape, null));
}
/***************************************************************************
** Render text under the app's header/footer.
***************************************************************************/
public static byte[] pdf(String title, String subtitle, String text, String pageSize, boolean landscape, PrintingChrome chrome) throws QException
{
if(text == null || text.isEmpty())
{
@@ -69,7 +59,7 @@ public final class PrintingTextDocuments
{
throw new QException("The text is too long to print (at most " + MAX_CHARACTERS + " characters)");
}
try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome))
try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle))
{
String[] lines = text.replace("\r\n", "\n").replace('\r', '\n').replace("\t", " ").split("\n", -1);
float size = fontSize(lines, layout.columns(1) * 1f);
@@ -109,62 +99,4 @@ public final class PrintingTextDocuments
float fits = charactersAtOnePoint / widest;
return (Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2)));
}
/***************************************************************************
** One section of a content document: a title (blank for none) and its
** lines, as the client read them from a screen panel.
***************************************************************************/
public record Section(String title, java.util.List<String> lines)
{
}
/***************************************************************************
** A screen's content as a document: each section's title as a heading,
** its lines in the monospace face, long lines wrapped at spaces to the page
** width (no borders, no navigation).
***************************************************************************/
public static byte[] sectionsPdf(String title, String subtitle, java.util.List<Section> sections, String pageSize, boolean landscape, PrintingChrome chrome) throws QException
{
if(sections.isEmpty() || sections.stream().allMatch(section -> section.lines().stream().allMatch(String::isBlank) && (section.title() == null || section.title().isBlank())))
{
throw new QException("There is no content to print");
}
int total = sections.stream().mapToInt(section -> section.lines().stream().mapToInt(String::length).sum()).sum();
if(total > MAX_CHARACTERS)
{
throw new QException("The content is too long to print (at most " + MAX_CHARACTERS + " characters)");
}
try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle, chrome))
{
int width = layout.columns(FONT_SIZE);
boolean first = true;
for(Section section : sections)
{
if(section.title() != null && !section.title().isBlank())
{
layout.gap(first ? 0 : 8);
layout.keep(3, FONT_SIZE);
layout.line(section.title(), 11);
layout.gap(2);
}
first = false;
for(String line : section.lines())
{
for(String wrapped : PrintingRecordDocuments.wrap(line.stripTrailing(), width))
{
layout.line(wrapped, FONT_SIZE);
}
}
}
return (layout.finish());
}
catch(IOException exception)
{
throw new QException("Could not render the content: " + exception.getMessage(), exception);
}
}
}
@@ -49,11 +49,11 @@ public final class PrintingUiApi
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 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. */
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes. */
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.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.")
return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.1.0")
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes.")
.withSpec(Session.class)
.withSpec(Bind.class)
.withSpec(Enroll.class)
@@ -70,43 +70,7 @@ public final class PrintingUiApi
.withSpec(Receipt.class)
.withSpec(Action.class)
.withSpec(Commands.class)
.withSpec(Command.class)
.withSpec(OperatorCatalog.class)
.withSpec(OperatorSubmit.class)
.withSpec(OperatorRequest.class));
}
/** Current-actor, approved/exposed printer catalog; no administrative permission. */
public static class OperatorCatalog extends PrintingUiApiSpec
{
/** Public registry constructor. */
public OperatorCatalog()
{
super("OperatorCatalog", HttpMethod.GET, "/operator/printers", null, "OperatorCatalog");
}
}
/** Operator document submission uses the existing durable queue. */
public static class OperatorSubmit extends PrintingUiApiSpec
{
/** Public registry constructor. */
public OperatorSubmit()
{
super("OperatorSubmit", HttpMethod.POST, "/operator/jobs", "OperatorSubmit", "Job");
}
}
/** Exact actor/request lookup replaces general job history access. */
public static class OperatorRequest extends PrintingUiApiSpec
{
/** Public registry constructor. */
public OperatorRequest()
{
super("OperatorRequest", HttpMethod.GET, "/operator/requests/{requestId}", null, "Job");
}
.withSpec(Command.class));
}
@@ -72,8 +72,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
{
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."));
.withLongDescription("Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication."));
}
@@ -112,7 +111,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
parameters.add(new Parameter().withName(PrintingAgentAuthorization.DEVICE_HEADER).withIn(In.HEADER).withRequired(required).withSchema(PrintingApiSchemas.text(36)));
parameters.add(new Parameter().withName(PrintingAgentAuthorization.TOKEN_HEADER).withIn(In.HEADER).withRequired(required).withSchema(PrintingApiSchemas.text(128)).withDescription("Private secondary device proof; never log."));
}
for(String name : List.of("deviceId", "jobId", "commandId", "requestId"))
for(String name : List.of("deviceId", "jobId", "commandId"))
{
if(path.contains("{" + name + "}"))
{
@@ -124,11 +123,6 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
parameters.add(new Parameter().withName("limit").withIn(In.QUERY).withSchema(new Schema().withType(Type.INTEGER)).withDescription("1 through 200; default 50."));
parameters.add(new Parameter().withName("before").withIn(In.QUERY).withSchema(PrintingApiSchemas.text(128)));
}
if("OperatorCatalog".equals(operation))
{
parameters.add(new Parameter().withName("limit").withIn(In.QUERY).withSchema(new Schema().withType(Type.INTEGER)).withDescription("1 through 200; default 50."));
parameters.add(new Parameter().withName("after").withIn(In.QUERY).withSchema(PrintingApiSchemas.text(200)));
}
return (parameters);
}
@@ -138,22 +132,8 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
public JsonInput buildInput(Context context) throws Exception
{
context.header("Cache-Control", "no-store");
if(operation.startsWith("Operator"))
{
PrintingOperatorAuthorization.principal();
}
else
{
PrintingControlAuthorization.principal();
}
long maximum = "Inventory".equals(operation) ? 2097152 : 32768;
if("OperatorSubmit".equals(operation))
{
PrintingOperatorAuthorization.requireSubmission();
PrintingRealtimePolicy policy = PrintingProcesses.currentService().realtimePolicy();
maximum = ((Math.max(policy.get("maxPdfBytes"), policy.get("maxZplBytes")) + 2) / 3) * 4 + 65536;
}
if(context.bodyAsBytes().length > maximum)
PrintingControlAuthorization.principal();
if(context.bodyAsBytes().length > ("Inventory".equals(operation) ? 2097152 : 32768))
{
throw new PrintingException(413, "Printing request exceeds its size limit");
}
@@ -177,17 +157,6 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
protected Object handle(JsonInput input) throws Exception
{
PrintingService service = PrintingProcesses.currentService();
if(operation.startsWith("Operator"))
{
JSONObject result = switch(operation)
{
case "OperatorCatalog" -> PrintingOperatorAuthorization.catalog(input.getQueryParamInteger("limit") == null ? 50 : input.getQueryParamInteger("limit"), input.getQueryParam("after"));
case "OperatorSubmit" -> PrintingOperatorAuthorization.submit(new JSONObject(input.getBody()));
case "OperatorRequest" -> PrintingOperatorAuthorization.observe(input.getPathParam("requestId"));
default -> throw new PrintingException(400, "Unknown operator operation");
};
return result.toMap();
}
String principal = PrintingControlAuthorization.principal();
String device = input.getPathParam("_verifiedDevice");
JSONObject body = new JSONObject(input.getBody());
@@ -1,358 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONObject;
/*******************************************************************************
** Native authorized acceptance of exact retained originals. No device wire DTO
** changes, owner-specific dependencies, alternate queue, or impersonation.
*******************************************************************************/
public final class RetainedPrintFacade
{
/** Immutable owner metadata; the canonical reference binds source/version/generation/actor/tenant, never credentials. */
public record SourceReference(String owner, String opaqueReference, String sha256, long sizeBytes, String mediaType)
{
/** Validate before allocation, persistence or native dispatch. */
public SourceReference
{
text(owner, 100);
text(opaqueReference, 4000);
if(sizeBytes < 1 || sizeBytes > Integer.MAX_VALUE || sha256 == null || !sha256.matches("[a-f0-9]{64}")
|| !("application/pdf".equals(mediaType) || "application/vnd.zebra-zpl".equals(mediaType)))
{
throw new PrintingException(400, "Invalid retained source metadata");
}
}
/** Fixed fields, not an arbitrary caller JSON document. */
JSONObject json()
{
return (new JSONObject().put("owner", owner).put("opaqueReference", opaqueReference).put("sha256", sha256)
.put("sizeBytes", sizeBytes).put("mediaType", mediaType));
}
}
/** Caller chooses only an intent, owner locator and native destination, not an actor. */
public record Request(String requestId, String sourceLocator, String printerRecordId, String title, String optionsJson)
{
/** Freeze and bound the complete input before invoking trusted adapters. */
public Request
{
requestIdentity(requestId);
text(sourceLocator, 200);
text(printerRecordId, 36);
text(title, 1024);
if(optionsJson == null || optionsJson.length() > 2000)
{
throw new PrintingException(400, "Invalid print options");
}
new JSONObject(optionsJson);
}
}
/** Server acceptance is queue durability, not device or physical-output evidence. */
public record Acceptance(String requestId, String jobId, String acceptedEventId, Instant acceptedAt, String tenantId,
String actorId, SourceReference source, String deviceId, String printerId, String printerRecordId, String title, String optionsJson)
{
}
/** Exact immutable device observation, separate from the server acceptance event. */
public record Observation(Acceptance acceptance, String eventId, Instant recordedAt, String state, String spoolerJobId)
{
}
/** Resolve current source and destination authority, then use the existing queue transaction. */
public Acceptance submit(Request request) throws QException
{
Prepared prepared = prepare(request, true);
try
{
JSONObject result = prepared.service().submitRetained(prepared.wire(), prepared.binding(), prepared.actor(),
() -> prepared.check(), () ->
{
prepared.check();
byte[] bytes = prepared.authority().read(prepared.source(), request.requestId());
prepared.check();
if(bytes == null || bytes.length != prepared.source().sizeBytes())
{
throw new QException("Retained source changed its content length");
}
return bytes;
});
Acceptance accepted = acceptance(result);
checkDestination(accepted);
prepared.check();
return (accepted);
}
catch(SQLException exception)
{
throw new QException("Retained print acceptance unavailable; reconcile the same request identity", exception);
}
}
/** Absence is returned only when neither an accepted job nor an unresolved upload exists. */
public Acceptance find(Request request) throws QException
{
Prepared prepared = prepare(request, false);
try
{
JSONObject row = prepared.service().findRetained(key(request.requestId()), prepared.binding());
prepared.check();
if(row == null)
{
return null;
}
Acceptance accepted = acceptance(row);
checkDestination(accepted);
prepared.check();
return (accepted);
}
catch(SQLException exception)
{
throw new QException("Retained print lookup unavailable", exception);
}
}
/** Current operator needs owner observation authority and the exact destination, never generic job access. */
public Observation observation(String requestId, String eventId) throws QException
{
requestIdentity(requestId);
text(eventId, 128);
String reporter = PrintingOperatorAuthorization.principal();
requireStorage(false);
PrintingService service = PrintingProcesses.currentService();
RetainedPrintSourceAuthority authority = authority(service);
var instance = QContext.getQInstance();
var session = QContext.getQSession();
try
{
JSONObject stored = service.findRetained(key(requestId), null);
if(stored == null)
{
throw new PrintingException(404, "Accepted print intent not found");
}
Acceptance accepted = acceptance(stored);
checkDestination(accepted);
JSONObject proof = service.retainedObservation(key(requestId), eventId);
if(!accepted.equals(acceptance(proof.getJSONObject("acceptance"))))
{
throw new PrintingException(409, "Accepted print binding changed");
}
authority.authorizeObservation(accepted.source(), requestId);
if(instance != QContext.getQInstance() || session != QContext.getQSession()
|| !reporter.equals(PrintingOperatorAuthorization.principal()) || service != PrintingProcesses.currentService()
|| authority != authority(service))
{
throw new QException("Printing reporter authority changed");
}
requireStorage(false);
checkDestination(accepted);
return (new Observation(accepted, eventId, Instant.parse(proof.getString("recordedAt")), proof.getString("state"),
proof.isNull("spoolerJobId") ? null : proof.getString("spoolerJobId")));
}
catch(SQLException exception)
{
throw new QException("Committed print observation unavailable", exception);
}
}
/** Capture one native request context and trusted configuration; callbacks cannot retarget it. */
private Prepared prepare(Request request, boolean mutation) throws QException
{
Objects.requireNonNull(request);
if(QContext.getQBackendTransaction() != null)
{
throw new QException("Retained print acceptance requires its own committed transaction");
}
String actor = PrintingOperatorAuthorization.principal();
text(actor, 200);
requireStorage(mutation);
if(mutation)
{
PrintingOperatorAuthorization.requireSubmission();
}
PrintingService service = PrintingProcesses.currentService();
var instance = QContext.getQInstance();
var session = QContext.getQSession();
RetainedPrintSourceAuthority authority = authority(service);
QRecord printer = mutation ? PrintingOperatorAuthorization.destination(request.printerRecordId())
: PrintingOperatorAuthorization.authorizeDestinationHistory(request.printerRecordId());
String device = printer.getValueString("device_id");
String printerId = printer.getValueString("printer_id");
SourceReference source = Objects.requireNonNull(authority.resolve(request.sourceLocator(), request.requestId()));
JSONObject wire = new JSONObject().put("deviceId", device).put("printerId", printerId).put("idempotencyKey", key(request.requestId()))
.put("title", request.title()).put("format", "application/pdf".equals(source.mediaType()) ? "PDF" : "ZPL")
.put("options", new JSONObject(request.optionsJson()));
JSONObject binding = new JSONObject().put("requestId", request.requestId()).put("actorId", actor).put("source", source.json())
.put("printerRecordId", request.printerRecordId()).put("deviceId", device).put("printerId", printerId).put("title", request.title())
.put("options", new JSONObject(request.optionsJson())).put("tenantId", service.getTenantId());
Checked check = () ->
{
requireStorage(mutation);
if(mutation)
{
PrintingOperatorAuthorization.requireSubmission();
}
if(instance != QContext.getQInstance() || session != QContext.getQSession() || QContext.getQBackendTransaction() != null
|| service != PrintingProcesses.currentService() || authority != authority(service) || !actor.equals(PrintingOperatorAuthorization.principal()))
{
throw new QException("Retained print authority changed");
}
QRecord current = mutation ? PrintingOperatorAuthorization.printer(request.printerRecordId(), wire.getString("format"))
: PrintingOperatorAuthorization.authorizeDestinationHistory(request.printerRecordId());
if(!device.equals(current.getValueString("device_id")) || !printerId.equals(current.getValueString("printer_id")))
{
throw new QException("Retained print destination changed");
}
};
check.run();
Prepared prepared = new Prepared(service, authority, source, actor, wire, binding, () ->
{
check.run();
SourceReference current = authority.resolve(request.sourceLocator(), request.requestId());
check.run();
if(!source.equals(current))
{
throw new QException("Retained print source changed");
}
});
prepared.check();
return (prepared);
}
/** Historical evidence requires current record authority without granting generic queue reads. */
private static void checkDestination(Acceptance accepted) throws QException
{
QRecord printer = PrintingOperatorAuthorization.authorizeDestinationHistory(accepted.printerRecordId());
if(!accepted.deviceId().equals(printer.getValueString("device_id")) || !accepted.printerId().equals(printer.getValueString("printer_id")))
{
throw new QException("Accepted print destination changed");
}
}
/** Keep owner service writes within native credential/channel ceilings, without generic edit roles. */
private static void requireStorage(boolean mutation) throws QException
{
var instance = QContext.getQInstance();
var table = instance == null ? null : instance.getTable(PrintingTables.JOB);
if(table == null)
{
throw new QException("Printing job metadata is unavailable");
}
var rules = PermissionsHelper.getEffectivePermissionRules(table, instance);
if(PermissionsHelper.isDeniedForClientChannel(table, rules)
|| (mutation && PermissionsHelper.isWriteDeniedForClientChannel(table, rules))
|| PermissionsHelper.isOutsideCredentialScope(table, mutation ? TablePermissionSubType.INSERT : TablePermissionSubType.READ))
{
throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("Current credential or client channel cannot access print acceptance");
}
}
/** Only the host's matching instance configuration can supply owner authority. */
private static RetainedPrintSourceAuthority authority(PrintingService service) throws QException
{
for(var metadata : QContext.getQInstance().getQBits().values())
{
if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() == service && config.getRetainedSourceAuthority() != null)
{
return (config.getRetainedSourceAuthority());
}
}
throw new QException("Retained printing is not configured");
}
/** Package-private checked transaction callbacks never originate in request JSON. */
@FunctionalInterface
interface Checked
{
/** Reauthorize without committing any printing state. */
void run() throws QException;
}
/** One request-local immutable capture; JSON never escapes to callers. */
private record Prepared(PrintingService service, RetainedPrintSourceAuthority authority, SourceReference source,
String actor, JSONObject wire, JSONObject binding, Checked verifier)
{
/** Run the exact capture's current-authority check. */
void check() throws QException
{
verifier.run();
}
}
/** Stable namespace prevents accidental collision with ordinary producer keys. */
static String key(String requestId)
{
return ("retained:" + requestId);
}
/** Shared stable intent syntax supports owner namespaces such as echo:scan-id. */
private static void requestIdentity(String value)
{
if(value == null || !value.matches("[A-Za-z0-9._:-]{1,128}"))
{
throw new PrintingException(400, "Invalid retained print request identity");
}
}
/** Decode only a server-built accepted proof, never a wire job or caller payload. */
private static Acceptance acceptance(JSONObject row)
{
JSONObject binding = row.getJSONObject("binding");
JSONObject source = binding.getJSONObject("source");
return (new Acceptance(binding.getString("requestId"), row.getString("jobId"), row.getString("acceptedEventId"),
Instant.parse(row.getString("acceptedAt")), binding.getString("tenantId"), binding.getString("actorId"),
new SourceReference(source.getString("owner"), source.getString("opaqueReference"), source.getString("sha256"),
source.getLong("sizeBytes"), source.getString("mediaType")), binding.getString("deviceId"), binding.getString("printerId"), binding.getString("printerRecordId"),
binding.getString("title"), binding.getJSONObject("options").toString()));
}
/** Reject malformed Unicode, controls and oversized identifiers without logging values. */
private static void text(String value, int maximum)
{
if(value == null || value.isBlank() || value.length() > maximum)
{
throw new PrintingException(400, "Invalid retained print identifier");
}
for(int i = 0; i < value.length(); i++)
{
char c = value.charAt(i);
if(Character.isISOControl(c) || Character.isLowSurrogate(c)
|| (Character.isHighSurrogate(c) && (++i >= value.length() || !Character.isLowSurrogate(value.charAt(i)))))
{
throw new PrintingException(400, "Invalid retained print identifier");
}
}
}
}
@@ -1,54 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Trusted host adapter to an independent retained-document owner. Both methods
** must authorize the CURRENT native actor and exact eligible source. A locator
** is correlation, never permission. Do not reconstruct or replace sessions.
*******************************************************************************/
public interface RetainedPrintSourceAuthority
{
/**
** Resolve a canonical immutable version under the owner's current admission policy.
** The opaque reference binds source association, version, generation request,
** initiating actor and tenant; printing adds no release or quarantine override.
** Resolution is read-only: it must not generate, dispatch, release, or reconcile work.
*/
RetainedPrintFacade.SourceReference resolve(String locator, String requestId) throws QException;
/** Read only that version, with current owner authorization and bounded bytes. */
byte[] read(RetainedPrintFacade.SourceReference source, String requestId) throws QException;
/**
** Authorize exposing an accepted source's metadata to the current reporter.
** This grants neither byte access nor impersonation of the initiating actor.
** Owners may apply their observation policy independently of release state.
*/
default void authorizeObservation(RetainedPrintFacade.SourceReference source, String requestId) throws QException
{
throw new QException("Retained source observation is not configured");
}
}
+2 -374
View File
@@ -207,7 +207,6 @@ components:
enum:
- "REFRESH_INVENTORY"
- "CANCEL_SPOOL"
- "printer-media-v1"
type: "string"
type: "array"
lastAppliedRevision:
@@ -417,145 +416,6 @@ components:
required:
- "jobs"
type: "object"
PrintingMediaProfileV1:
properties:
id:
maxLength: 128
type: "string"
revision:
maxLength: 64
type: "string"
name:
maxLength: 512
type: "string"
widthMicrons:
type: "integer"
heightMicrons:
type: "integer"
dpiX:
type: "integer"
dpiY:
type: "integer"
source:
enum:
- "WINDOWS_QUEUE_DEFAULT"
type: "string"
applyMode:
enum:
- "DRIVER_DEFAULT"
type: "string"
required:
- "id"
- "revision"
- "name"
- "widthMicrons"
- "heightMicrons"
- "dpiX"
- "dpiY"
- "source"
- "applyMode"
type: "object"
PrintingMediaSelectionV1:
properties:
id:
maxLength: 128
type: "string"
revision:
maxLength: 64
type: "string"
required:
- "id"
- "revision"
type: "object"
PrintingOperatorCatalogV1:
properties:
printers:
items:
$ref: "#/components/schemas/PrintingOperatorPrinterV1"
type: "array"
nextCursor:
maxLength: 200
nullable: true
type: "string"
required:
- "printers"
- "nextCursor"
type: "object"
PrintingOperatorPrinterV1:
properties:
printerRowId:
maxLength: 128
type: "string"
deviceId:
maxLength: 128
type: "string"
printerId:
maxLength: 128
type: "string"
name:
maxLength: 1024
type: "string"
deviceName:
maxLength: 1024
type: "string"
description:
maxLength: 4096
type: "string"
location:
maxLength: 512
type: "string"
status:
maxLength: 40
type: "string"
formats:
items:
enum:
- "PDF"
- "ZPL"
type: "string"
type: "array"
mediaProfiles:
items:
$ref: "#/components/schemas/PrintingMediaProfileV1"
type: "array"
required:
- "printerRowId"
- "deviceId"
- "printerId"
- "name"
- "deviceName"
- "status"
- "formats"
- "mediaProfiles"
type: "object"
PrintingOperatorSubmitV1:
properties:
requestId:
maxLength: 128
type: "string"
printerRowId:
maxLength: 128
type: "string"
title:
maxLength: 1024
type: "string"
format:
enum:
- "PDF"
- "ZPL"
type: "string"
contentBase64:
maxLength: 2147483647
type: "string"
options:
$ref: "#/components/schemas/PrintingOptionsV1"
required:
- "requestId"
- "printerRowId"
- "title"
- "format"
- "contentBase64"
type: "object"
PrintingOptionsV1:
properties:
copies:
@@ -565,8 +425,6 @@ components:
- "ACTUAL_SIZE"
- "FIT"
type: "string"
mediaProfile:
$ref: "#/components/schemas/PrintingMediaSelectionV1"
paperWidthMm:
type: "number"
paperHeightMm:
@@ -601,10 +459,6 @@ components:
- "ZPL"
type: "string"
type: "array"
mediaProfiles:
items:
$ref: "#/components/schemas/PrintingMediaProfileV1"
type: "array"
displayName:
maxLength: 512
type: "string"
@@ -751,10 +605,9 @@ info:
contact:
email: "contact@kingsrook.com"
description: "Native current-user printing with secondary device proof, device-control-v1\
\ and realtime-v1; record, table-list and text printing processes; render to PDF\
\ with the app header and footer; least-privilege operator catalog and printer-media-v1."
\ and realtime-v1; record, table-list and text printing processes."
title: "QQQ Middleware API extension: printing"
version: "2.3.0"
version: "2.1.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/printing/session:
@@ -2218,231 +2071,6 @@ paths:
summary: "Printing Command"
tags:
- "printing"
/qqq/v1/qbit/printing/operator/printers:
get:
description: "Requires the current QQQ user session, printing_operator or printing_admin,\
\ current tenant and native destination record access. Submissions additionally\
\ require printingSubmit process and credential/channel write authority."
operationId: "printingOperatorCatalog"
parameters:
- description: "1 through 200; default 50."
in: "query"
name: "limit"
schema:
type: "integer"
- in: "query"
name: "after"
schema:
maxLength: 200
type: "string"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid request"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Native QQQ sign-in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission, device proof or user binding denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Record not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/PrintingOperatorCatalogV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "State or request conflict"
410:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Request or content expired"
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Bounded capacity reached"
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Printing OperatorCatalog"
tags:
- "printing"
/qqq/v1/qbit/printing/operator/jobs:
post:
description: "Requires the current QQQ user session, printing_operator or printing_admin,\
\ current tenant and native destination record access. Submissions additionally\
\ require printingSubmit process and credential/channel write authority."
operationId: "printingOperatorSubmit"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/PrintingOperatorSubmitV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid request"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Native QQQ sign-in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission, device proof or user binding denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Record not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/PrintingJobV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "State or request conflict"
410:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Request or content expired"
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Bounded capacity reached"
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Printing OperatorSubmit"
tags:
- "printing"
/qqq/v1/qbit/printing/operator/requests/{requestId}:
get:
description: "Requires the current QQQ user session, printing_operator or printing_admin,\
\ current tenant and native destination record access. Submissions additionally\
\ require printingSubmit process and credential/channel write authority."
operationId: "printingOperatorRequest"
parameters:
- in: "path"
name: "requestId"
required: true
schema:
maxLength: 128
type: "string"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid request"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Native QQQ sign-in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission, device proof or user binding denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Record not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/PrintingJobV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "State or request conflict"
410:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Request or content expired"
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Bounded capacity reached"
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Printing OperatorRequest"
tags:
- "printing"
servers:
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
url: "/"
@@ -138,8 +138,7 @@ class PrintingListProcessesTest
assertTrue(text.indexOf("Hopper") < text.indexOf("Lovelace"), text);
assertTrue(text.contains("all 2 rows"), text);
assertTrue(text.contains("sort Last name"), text);
assertTrue(text.contains("No filter - sort: Last name"), "the footer note carries the filter and sort");
assertTrue(text.contains("QQQ printing"), "the default footer");
assertTrue(text.contains("Person - printed "), "the footer names the table, time and user");
assertTrue(text.contains("2 rows"), text);
assertTrue(fixture.service().jobs().getJSONObject(0).getString("title").startsWith("Person"));
}
@@ -284,7 +283,7 @@ class PrintingListProcessesTest
/***************************************************************************
** Run a process straight through with values.
***************************************************************************/
static RunProcessOutput run(String process, Map<String, Serializable> values) throws QException
private static RunProcessOutput run(String process, Map<String, Serializable> values) throws QException
{
RunProcessInput input = new RunProcessInput().withProcessName(process).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
// as the UI API runs it: user input, so server-configured inputs would be refused
@@ -319,7 +318,7 @@ class PrintingListProcessesTest
** The process fixture plus person (sections, pets), pet, a hidden and an
** opted-out table, on the memory backend.
***************************************************************************/
static PrintingProcessesTest.Fixture fixture() throws Exception
private static PrintingProcessesTest.Fixture fixture() throws Exception
{
PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture();
QInstance instance = fixture.instance();
@@ -1,378 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.nio.charset.StandardCharsets;
import java.sql.DriverManager;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Real native post-Get hooks must not carry stale authority into trusted writes.
*******************************************************************************/
class PrintingOperatorAuthorityTest
{
private static final ThreadLocal<Runnable> AFTER_GET = new ThreadLocal<>();
private static final ThreadLocal<AtomicInteger> DEVICE_QUERIES = new ThreadLocal<>();
private static final ThreadLocal<String> RETARGET_DEVICE = new ThreadLocal<>();
/***************************************************************************
** Each case owns its native session and synthetic database.
***************************************************************************/
@AfterEach
void clear()
{
AFTER_GET.remove();
DEVICE_QUERIES.remove();
RETARGET_DEVICE.remove();
QContext.clear();
}
/***************************************************************************
** Independent mutable authority components must all retain current meaning.
***************************************************************************/
enum Change
{
ACTOR, SESSION, CREDENTIAL, PROCESS, ROW_SCOPE, CHANNEL
}
/***************************************************************************
** Authorization changes in the last destination hook precede any admission.
***************************************************************************/
@ParameterizedTest
@EnumSource(Change.class)
void submissionRejectsPostGetAuthorityChanges(Change change) throws Exception
{
var fixture = fixture();
mutateAfterGet(fixture, change, true);
Exception denied = null;
try
{
PrintingOperatorAuthorization.submit(request(fixture));
}
catch(Exception exception)
{
denied = exception;
}
assertEquals(null, AFTER_GET.get(), "The real native Get callback must run before assessing denial");
assertTrue(denied instanceof QException, "Submission must reject changed authority");
assertCounts(fixture, 0);
}
/***************************************************************************
** Receipt lookup cannot return the old actor's result after a native hook.
***************************************************************************/
@ParameterizedTest
@EnumSource(value = Change.class, names = { "ACTOR", "SESSION", "CREDENTIAL", "ROW_SCOPE", "CHANNEL" })
void observationRejectsPostGetAuthorityChanges(Change change) throws Exception
{
var fixture = fixture();
PrintingOperatorAuthorization.submit(request(fixture));
mutateAfterGet(fixture, change, false);
Exception denied = null;
try
{
PrintingOperatorAuthorization.observe("echo:hook");
}
catch(Exception exception)
{
denied = exception;
}
assertEquals(null, AFTER_GET.get(), "The real native Get callback must run before assessing denial");
assertTrue(denied instanceof QException, "Observation must reject changed authority");
assertCounts(fixture, 1);
}
/***************************************************************************
** A read-only credential may still observe its own authorized accepted job.
***************************************************************************/
@Test
void readOnlyObservationAndExactReplayRemainAvailable() throws Exception
{
var fixture = fixture();
JSONObject first = PrintingOperatorAuthorization.submit(request(fixture));
assertEquals(first.getString("jobId"), PrintingOperatorAuthorization.submit(request(fixture)).getString("jobId"));
QContext.getQSession().setCredentialScope(new QCredentialScope().withReadOnly(true));
assertEquals(first.getString("jobId"), PrintingOperatorAuthorization.observe("echo:hook").getString("jobId"));
assertCounts(fixture, 1);
}
/***************************************************************************
** A late storage callback cannot commit with authority revoked during upload.
***************************************************************************/
@ParameterizedTest
@EnumSource(value = Change.class, names = { "ACTOR", "CREDENTIAL", "PROCESS", "CHANNEL" })
void storageCallbackDenialRollsBackJobAndCleansOwnedUpload(Change change) throws Exception
{
var fixture = fixture();
RevokingStorage storage = new RevokingStorage(() -> change(fixture, change, true));
fixture.service().configureStorage(storage, "printing_documents");
assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(fixture)));
assertEquals(1, storage.writes);
assertTrue(storage.objects.isEmpty(), "The unaccepted owned upload is cleaned up");
assertCounts(fixture, 0);
}
/***************************************************************************
** Even a consistently retargeting host hook cannot change the native row intent.
***************************************************************************/
@Test
void postQueryDestinationRewriteCannotSelectAnotherQueue() throws Exception
{
var fixture = fixture();
RETARGET_DEVICE.set(fixture.otherDevice());
fixture.instance().getTable(PrintingTables.PRINTER).withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(RetargetingPrinter.class));
assertEquals(409, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(fixture))).getStatus());
assertCounts(fixture, 0);
}
/** History belongs to the accepted native row, even if its wire pair is reused. */
@Test
void observationKeepsOriginalPrinterRowIdentity() throws Exception
{
var fixture = fixture();
JSONObject accepted = PrintingOperatorAuthorization.submit(request(fixture));
fixture.service().enablePrinter(fixture.device(), "laser", false);
assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.observe("echo:hook").getString("jobId"));
try(var connection = connection(fixture); var statement = connection.prepareStatement("DELETE FROM printing_printer WHERE id=?"))
{
statement.setString(1, fixture.printerId());
assertEquals(1, statement.executeUpdate());
}
fixture.service().inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Replacement queue")
.put("formats", new JSONArray().put("PDF"))));
fixture.service().enablePrinter(fixture.device(), "laser", true);
try(var connection = connection(fixture); var statement = connection.prepareStatement("SELECT id FROM printing_printer WHERE device_id=? AND printer_id='laser'"))
{
statement.setString(1, fixture.device());
try(var rows = statement.executeQuery())
{
assertTrue(rows.next());
assertNotEquals(fixture.printerId(), rows.getString(1));
}
}
assertThrows(QException.class, () -> PrintingOperatorAuthorization.observe("echo:hook"));
assertCounts(fixture, 1);
}
/***************************************************************************
** Install only the last native destination callback, after its permission check.
***************************************************************************/
private void mutateAfterGet(PrintingProcessesTest.Fixture fixture, Change change, boolean mutation)
{
if(change == Change.ROW_SCOPE)
{
fixture.instance().addSecurityKeyType(new com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType().withName("revoked_destination"));
fixture.instance().getTable(PrintingTables.DEVICE).withRecordSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock()
.withFieldName("id").withSecurityKeyType("revoked_destination"));
QContext.getQSession().withSecurityKeyValue("revoked_destination", fixture.device());
}
fixture.instance().getTable(PrintingTables.DEVICE).getCustomizers().put(TableCustomizers.POST_QUERY_RECORD.getRole(), new QCodeReference(ChangingGet.class));
DEVICE_QUERIES.set(new AtomicInteger());
AFTER_GET.set(() -> change(fixture, change, mutation));
}
/** Apply the same change either in a native Get hook or in the storage callback. */
private static void change(PrintingProcessesTest.Fixture fixture, Change change, boolean mutation)
{
switch(change)
{
case ACTOR -> QContext.getQSession().setUser(new QUser().withIdReference("another-actor"));
case SESSION -> QContext.init(fixture.instance(), new QSession().withUser(new QUser().withIdReference("operator"))
.withPermissions("printing_operator").withSecurityKeyValue("printing_tenant", "tenant-a"));
case CREDENTIAL -> QContext.getQSession().setCredentialScope(mutation ? new QCredentialScope().withReadOnly(true)
: new QCredentialScope().withTables(Set.of(PrintingTables.DEVICE, PrintingTables.PRINTER)));
case PROCESS -> QContext.getQSession().setCredentialScope(new QCredentialScope().withProcesses(Set.of()));
case ROW_SCOPE -> QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("tenant-a"), "revoked_destination", List.of("none")));
case CHANNEL ->
{
QContext.getQSession().setClientChannel("api/test");
fixture.instance().getTable(PrintingTables.JOB).getPermissionRules().withDeniedClients(Set.of("api"));
}
default -> throw new IllegalArgumentException("Unknown authority mutation");
}
}
/***************************************************************************
** QQQ invokes this same hook for real Get actions, not a mocked facade call.
***************************************************************************/
public static class ChangingGet implements TableCustomizerInterface
{
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
{
// RC20's RDBMS Get delegates to Query; its hook is the second device query.
if(AFTER_GET.get() != null && (input instanceof GetInput || DEVICE_QUERIES.get().incrementAndGet() == 2))
{
Runnable action = AFTER_GET.get();
AFTER_GET.remove();
action.run();
}
return records;
}
}
/** Return altered display data while preserving the requested row ID. */
public static class RetargetingPrinter implements TableCustomizerInterface
{
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
{
records.forEach(record -> record.setValue("device_id", RETARGET_DEVICE.get()));
return records;
}
}
/** External IO is replaced, while production binding and cleanup logic remain. */
private static class RevokingStorage extends PrintingDocumentStorage
{
private final Map<String, byte[]> objects = new HashMap<>();
private final Runnable revoke;
private int writes;
/** This fixture has no network target or real bucket. */
RevokingStorage(Runnable revoke)
{
super(storageInstance(), "tenant-a");
this.revoke = revoke;
}
/** Publish the synthetic object, then change the caller before acceptance. */
@Override
public void put(String table, String reference, String format, byte[] content)
{
objects.put(reference, content.clone());
writes++;
revoke.run();
}
/** Observe actual service compensation after the denied database transaction. */
@Override
public void delete(String table, String reference)
{
objects.remove(reference);
}
/** Real S3 metadata exercises the production immutable storage binding. */
private static QInstance storageInstance()
{
QInstance instance = new QInstance();
instance.addBackend(new S3BackendMetaData().withName("objects").withBucketName("synthetic-operator").withRegion("us-east-1"));
instance.addTable(new QTableMetaData().withName("printing_documents").withBackendName("objects").withBackendDetails(new S3TableBackendDetails()));
return instance;
}
}
/***************************************************************************
** Shared native fixture starts with an operator, without administration rights.
***************************************************************************/
private PrintingProcessesTest.Fixture fixture() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
QContext.getQSession().setPermissions(Set.of("printing_operator"));
return fixture;
}
/***************************************************************************
** Tiny synthetic bytes never leave the in-memory queue.
***************************************************************************/
private JSONObject request(PrintingProcessesTest.Fixture fixture)
{
return new JSONObject().put("printerRowId", fixture.printerId()).put("requestId", "echo:hook").put("title", "Synthetic")
.put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-synthetic".getBytes(StandardCharsets.US_ASCII)));
}
/***************************************************************************
** Independent SQL proves denial created no queue, event or upload reservation.
***************************************************************************/
private void assertCounts(PrintingProcessesTest.Fixture fixture, int expectedJobs) throws Exception
{
try(var connection = connection(fixture))
{
for(String table : List.of("printing_job", "printing_event", "printing_document_orphan"))
{
try(var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM " + table))
{
rows.next();
assertEquals("printing_job".equals(table) ? expectedJobs : 0, rows.getInt(1), table);
}
}
}
}
/** Inspect only the current test's synthetic database, without a native permission bypass. */
private java.sql.Connection connection(PrintingProcessesTest.Fixture fixture) throws Exception
{
PrintingQBitConfig config = fixture.instance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance)
.map(PrintingQBitConfig.class::cast).findFirst().orElseThrow();
RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config.getBackendName());
return DriverManager.getConnection(backend.getJdbcUrl(), "sa", "");
}
}
@@ -1,405 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
import io.javalin.Javalin;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Real native row security, operator isolation and immutable queue media facts.
*******************************************************************************/
class PrintingOperatorTest
{
@TempDir
Path directory;
/***************************************************************************
** Each fixture owns its native context.
***************************************************************************/
@AfterEach
void clear()
{
QContext.clear();
}
/***************************************************************************
** Operators can submit and reconcile their own request, never manage devices.
***************************************************************************/
@Test
void operatorSubmitsWithoutAdminAndCannotEnumerateJobsOrOtherActors() throws Exception
{
var f = PrintingProcessesTest.fixture();
QContext.getQSession().setPermissions(Set.of("printing_operator"));
assertEquals(2, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length());
assertThrows(QException.class, () -> PrintingControlAuthorization.principal());
QueryInput denied = new QueryInput().withTableName(PrintingTables.JOB);
denied.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
assertThrows(QException.class, () -> new QueryAction().execute(denied));
JSONObject request = request(f.printerId(), "echo:scan-1");
JSONObject accepted = PrintingOperatorAuthorization.submit(request);
assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.submit(request).getString("jobId"));
assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.observe("echo:scan-1").getString("jobId"));
assertTrue(PrintingOperatorAuthorization.submit(request(f.printerId(), "x".repeat(128))).has("jobId"));
assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "x".repeat(129)))).getStatus());
assertEquals(409, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(new JSONObject(request.toString()).put("title", "Changed"))).getStatus());
QContext.getQSession().setUser(new QUser().withIdReference("another-operator"));
assertEquals(404, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.observe("echo:scan-1")).getStatus());
assertFalse(accepted.getString("jobId").equals(PrintingOperatorAuthorization.submit(request).getString("jobId")));
QContext.getQSession().setPermissions(Set.of());
assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request));
assertThrows(QException.class, () -> PrintingOperatorAuthorization.observe("echo:scan-1"));
}
/***************************************************************************
** Tenant and parent device locks apply to catalog, submit and replay reads.
***************************************************************************/
@Test
void nativeLocksAndExposureFilterCatalogAndPreventForgedDestinations() throws Exception
{
var f = PrintingProcessesTest.fixture();
QContext.getQSession().setPermissions(Set.of("printing_operator"));
assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.foreignPrinterId(), "foreign")));
f.service().enablePrinter(f.otherDevice(), "laser", false);
assertEquals(1, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length());
assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.otherPrinterId(), "disabled")));
f.instance().addSecurityKeyType(new QSecurityKeyType().withName("operator_device"));
f.instance().getTable(PrintingTables.DEVICE).withRecordSecurityLock(new RecordSecurityLock().withFieldName("id").withSecurityKeyType("operator_device"));
QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("tenant-a"), "operator_device", List.of(f.otherDevice())));
assertEquals(0, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length());
assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "locked")));
QContext.getQSession().setSecurityKeyValues(Map.of("printing_tenant", List.of("tenant-b")));
assertThrows(QException.class, () -> PrintingOperatorAuthorization.catalog(50, null));
}
/***************************************************************************
** Changed default media refuses new work while exact accepted replay survives.
***************************************************************************/
@Test
void mediaFactsRoundTripAndRevisionChecksPrecedeAdmission() throws Exception
{
var f = PrintingProcessesTest.fixture();
JSONObject media = media();
f.service().inventory(f.device(), new JSONArray().put(printer(media)));
QContext.getQSession().setPermissions(Set.of("printing_operator"));
JSONObject item = PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").toList().stream()
.map(value -> new JSONObject((Map<?, ?>) value)).filter(value -> value.getString("printerRowId").equals(f.printerId())).findFirst().orElseThrow();
assertTrue(media.similar(item.getJSONArray("mediaProfiles").getJSONObject(0)));
JSONObject request = request(f.printerId(), "media").put("options", new JSONObject().put("mediaProfile", new JSONObject().put("id", "default").put("revision", "a".repeat(64))));
JSONObject accepted = PrintingOperatorAuthorization.submit(request);
f.service().inventory(f.device(), new JSONArray().put(printer(media.put("revision", "b".repeat(64)))));
assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.submit(request).getString("jobId"));
assertEquals(409, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(new JSONObject(request.toString()).put("requestId", "stale"))).getStatus());
assertEquals(1, f.service().queuedJobs(f.device()).length());
assertEquals("a".repeat(64), f.service().getJob(accepted.getString("jobId")).getJSONObject("options").getJSONObject("mediaProfile").getString("revision"));
f.service().inventory(f.device(), new JSONArray().put(printer(null)));
assertEquals(0, f.service().printers(f.device()).getJSONObject(0).getJSONArray("mediaProfiles").length());
}
/***************************************************************************
** A malformed inventory rolls back rather than retaining invented stock data.
***************************************************************************/
@Test
void invalidMediaAndAmbiguousDimensionsAreRejected() throws Exception
{
var f = PrintingProcessesTest.fixture();
for(JSONObject bad : List.of(media().put("widthMicrons", 0), media().put("dpiX", 9601), media().put("revision", "A".repeat(64)), media().put("applyMode", "CUSTOM"), media().put("heightMicrons", 152400.5)))
{
assertThrows(PrintingException.class, () -> f.service().inventory(f.device(), new JSONArray().put(printer(bad))));
}
assertThrows(PrintingException.class, () -> f.service().inventory(f.device(), new JSONArray().put(printer(media()).put("mediaProfiles", new JSONArray().put(media()).put(media())))));
QContext.getQSession().setPermissions(Set.of("printing_operator"));
JSONObject request = request(f.printerId(), "bad").put("options", new JSONObject().put("paperWidthMm", 101.6).put("mediaProfile", new JSONObject().put("id", "default").put("revision", "a".repeat(64))));
assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request)).getStatus());
assertEquals(0, f.service().queuedJobs(f.device()).length());
}
/***************************************************************************
** Additive facts remain compatible, but cannot broaden the stored selection.
***************************************************************************/
@Test
void futureReportedMetadataIsIgnoredButSelectionRemainsExact() throws Exception
{
var f = PrintingProcessesTest.fixture();
f.service().inventory(f.device(), new JSONArray().put(printer(media().put("futureFact", new JSONObject().put("label", "future")))));
JSONObject stored = f.service().printers(f.device()).getJSONObject(0).getJSONArray("mediaProfiles").getJSONObject(0);
assertTrue(media().similar(stored));
assertFalse(stored.has("futureFact"));
QContext.getQSession().setPermissions(Set.of("printing_operator"));
JSONObject selected = new JSONObject().put("id", "default").put("revision", "a".repeat(64)).put("futureFact", true);
assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "future")
.put("options", new JSONObject().put("mediaProfile", selected)))).getStatus());
assertEquals(0, f.service().queuedJobs(f.device()).length());
}
/** Both inventory and immutable selections use the shared 128-character ID bound. */
@Test
void mediaIdentityBoundsMatchTheSharedWireContract() throws Exception
{
var f = PrintingProcessesTest.fixture();
String maximum = "m".repeat(128);
f.service().inventory(f.device(), new JSONArray().put(printer(media().put("id", maximum))));
QContext.getQSession().setPermissions(Set.of("printing_operator"));
JSONObject options = new JSONObject().put("mediaProfile", new JSONObject().put("id", maximum).put("revision", "a".repeat(64)));
assertTrue(PrintingOperatorAuthorization.submit(request(f.printerId(), "maximum-media").put("options", options)).has("jobId"));
assertEquals(400, assertThrows(PrintingException.class, () -> f.service().inventory(f.device(),
new JSONArray().put(printer(media().put("id", maximum + "m"))))).getStatus());
options.getJSONObject("mediaProfile").put("id", maximum + "m");
assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "oversized-media")
.put("options", options))).getStatus());
assertEquals(1, f.service().queuedJobs(f.device()).length());
}
/***************************************************************************
** The native backend process uses the same caller and stable request boundary.
***************************************************************************/
@Test
void processUsesOperatorPermissionAndReplaysSameRequest() throws Exception
{
var f = PrintingProcessesTest.fixture();
QContext.getQSession().setPermissions(Set.of("printing_operator"));
RunBackendStepInput input = new RunBackendStepInput().withProcessName(PrintingOperatorProcesses.SUBMIT);
JSONObject request = request(f.printerId(), "process");
request.keySet().forEach(key -> input.addValue(key, request.getString(key)));
RunBackendStepOutput first = new RunBackendStepOutput();
new PrintingOperatorProcesses().run(input, first);
new PrintingOperatorProcesses().run(input, new RunBackendStepOutput());
assertEquals(1, f.service().queuedJobs(f.device()).length());
assertTrue(first.getValueString("job").contains("jobId"));
}
/***************************************************************************
** A read-only credential cannot bypass the domain write ceiling via processes.
***************************************************************************/
@Test
void scopeAndChannelCeilingsRemainEffective() throws Exception
{
var f = PrintingProcessesTest.fixture();
QContext.getQSession().setPermissions(Set.of("printing_operator"));
QContext.getQSession().setCredentialScope(new QCredentialScope().withReadOnly(true).withProcesses(Set.of(PrintingOperatorProcesses.SUBMIT)));
assertEquals(2, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length());
assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "scope")));
QContext.getQSession().setCredentialScope(null);
QContext.getQSession().setClientChannel("api/test");
f.instance().getTable(PrintingTables.JOB).getPermissionRules().withDeniedClients(Set.of("api"));
assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "channel")));
assertEquals(0, f.service().queuedJobs(f.device()).length());
}
/***************************************************************************
** Bounded pages retain cursor progress and old agents report no invented stock.
***************************************************************************/
@Test
void catalogPagesAndLegacyProfilesRemainHonest() throws Exception
{
var f = PrintingProcessesTest.fixture();
QContext.getQSession().setPermissions(Set.of("printing_operator"));
JSONObject first = PrintingOperatorAuthorization.catalog(1, null);
JSONObject last = PrintingOperatorAuthorization.catalog(1, first.getString("nextCursor"));
assertEquals(1, first.getJSONArray("printers").length());
assertEquals(1, last.getJSONArray("printers").length());
assertTrue(last.isNull("nextCursor"));
JSONObject row = first.getJSONArray("printers").getJSONObject(0);
assertEquals(0, row.getJSONArray("mediaProfiles").length());
assertFalse(row.getString("name").isBlank());
assertFalse(row.getString("deviceName").isBlank());
assertFalse(row.getString("printerRowId").equals(last.getJSONArray("printers").getJSONObject(0).getString("printerRowId")));
assertEquals(400, assertThrows(PrintingException.class, () -> PrintingOperatorAuthorization.catalog(201, null)).getStatus());
}
/***************************************************************************
** Real native HTTP authenticates a non-admin and keeps mutation Origin checks.
***************************************************************************/
@Test
void nativeHttpOperatorBoundaryUsesStandardSessionAndReadOnlyLookup() throws Exception
{
String jdbc = "jdbc:h2:file:" + directory.resolve("operator-http").toString().replace('\\', '/');
String password = "synthetic-operator-password-1234";
PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", password))
{
@Override
public QInstance defineQInstance() throws QException
{
QInstance instance = super.defineQInstance();
instance.getAuthentication().withModuleCodeReference(new QCodeReference(OperatorAuthentication.class));
return instance;
}
};
PrintingService service = new PrintingService(new DriverDataSource(jdbc, "sa", ""), "demo");
String device = service.enroll(service.createEnrollment("default", "Synthetic").put("deviceName", "Synthetic").put("agentVersion", "test")).getString("deviceId");
service.inventory(device, new JSONArray().put(printer(media())));
service.enablePrinter(device, "laser", true);
AtomicReference<Javalin> server = new AtomicReference<>();
QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(value ->
{
host.configure(value, false);
value.withJavalinConfigurationCustomizer(server::set);
}));
String base = "http://127.0.0.1:" + server.get().port();
String prefix = "/qqq/v1/qbit/printing";
try(HttpClient client = HttpClient.newHttpClient())
{
assertEquals(401, http(client, base, prefix + "/operator/printers", null, null, null).statusCode());
HttpRequest login = HttpRequest.newBuilder(URI.create(base + "/qqq/v1/manageSession")).header("Origin", base)
.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(("operator:" + password).getBytes(StandardCharsets.UTF_8)))
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build();
HttpResponse<String> signedIn = client.send(login, HttpResponse.BodyHandlers.ofString());
assertEquals(200, signedIn.statusCode());
String cookie = signedIn.headers().allValues("set-cookie").stream().filter(value -> value.startsWith("sessionUUID=")).findFirst().orElseThrow().split(";", 2)[0];
HttpResponse<String> catalog = http(client, base, prefix + "/operator/printers", cookie, null, null);
assertEquals(200, catalog.statusCode());
String printer = new JSONObject(catalog.body()).getJSONArray("printers").getJSONObject(0).getString("printerRowId");
assertEquals(404, http(client, base, prefix + "/operator/requests/echo:missing", cookie, null, null).statusCode());
assertEquals(0, service.queuedJobs(device).length());
JSONObject request = request(printer, "echo:http");
assertEquals(403, http(client, base, prefix + "/operator/jobs", cookie, request, null).statusCode());
assertEquals(403, http(client, base, prefix + "/operator/jobs", cookie, request, "https://foreign.invalid").statusCode());
HttpResponse<String> submitted = http(client, base, prefix + "/operator/jobs", cookie, request, base);
assertEquals(200, submitted.statusCode(), submitted::body);
assertEquals(200, http(client, base, prefix + "/operator/requests/echo:http", cookie, null, null).statusCode());
assertEquals(403, http(client, base, prefix + "/session", cookie, null, null).statusCode());
assertEquals(1, service.queuedJobs(device).length());
}
finally
{
launcher.stop();
}
}
/***************************************************************************
** Test-only role mapping follows the real TABLE authentication first.
***************************************************************************/
public static class OperatorAuthentication extends PrintingLocalAuthentication
{
@Override
public QSession createSession(QInstance instance, Map<String, String> context) throws QAuthenticationException
{
QSession session = super.createSession(instance, context);
session.setPermissions(Set.of("printing_operator"));
return session;
}
}
/***************************************************************************
** Fixed test-only same-origin HTTP; no device proof or real network target.
***************************************************************************/
private static HttpResponse<String> http(HttpClient client, String base, String path, String cookie, JSONObject body, String origin) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(base + path)).timeout(java.time.Duration.ofSeconds(10));
if(cookie != null)
{
request.header("Cookie", cookie);
}
if(origin != null)
{
request.header("Origin", origin);
}
if(body != null)
{
request.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body.toString()));
}
return client.send(request.build(), HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** Synthetic PDF bytes are never sent to a printer in this suite.
***************************************************************************/
private static JSONObject request(String printer, String id)
{
return new JSONObject().put("printerRowId", printer).put("requestId", id).put("title", "Synthetic")
.put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-synthetic".getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
}
/***************************************************************************
** Test facts represent one real-format queue default, not a stock mutation.
***************************************************************************/
private static JSONObject media()
{
return new JSONObject().put("id", "default").put("revision", "a".repeat(64)).put("name", "4 x 6")
.put("widthMicrons", 101600).put("heightMicrons", 152400).put("dpiX", 300).put("dpiY", 300)
.put("source", "WINDOWS_QUEUE_DEFAULT").put("applyMode", "DRIVER_DEFAULT");
}
/***************************************************************************
** Existing queue identity is preserved through inventory refreshes.
***************************************************************************/
private static JSONObject printer(JSONObject media)
{
JSONObject printer = new JSONObject().put("printerId", "laser").put("name", "Synthetic").put("formats", new JSONArray(List.of("PDF")));
if(media != null)
{
printer.put("mediaProfiles", new JSONArray().put(media));
}
return printer;
}
}
@@ -30,10 +30,7 @@ import java.nio.file.Path;
import java.time.Instant;
import java.util.Base64;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
@@ -56,89 +53,6 @@ public class PrintingRealtimeTest
Path directory;
/** An old client cannot ignore media intent, including after replacing a capable connection. */
@Test
void mediaCapabilityIsReplacedOnEveryAuthenticatedHello() throws Exception
{
String jdbc = "jdbc:h2:file:" + directory.resolve("media-realtime").toString().replace('\\', '/');
PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", "synthetic-realtime-password-1234"));
AtomicReference<Javalin> app = new AtomicReference<>();
QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server ->
{
host.configure(server, false);
server.withJavalinConfigurationCustomizer(app::set);
}));
PrintingService service = launcher.getQInstance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance)
.map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService();
String base = "http://127.0.0.1:" + app.get().port();
JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Media fixture").put("deviceName", "Media fixture").put("agentVersion", "test"), "operator");
String device = enrolled.getString("deviceId");
String proof = enrolled.getString("deviceToken");
JSONObject media = new PrintingMediaProfile("default", "a".repeat(64), "4 x 6", 101600, 152400, 300, 300).json();
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue").put("formats", new JSONArray().put("ZPL"))
.put("mediaProfiles", new JSONArray().put(media))));
service.enablePrinter(device, "queue", true);
String job = service.submit(request(device, "media").put("options", new JSONObject().put("mediaProfile", new JSONObject().put("id", "default").put("revision", "a".repeat(64))))).getString("jobId");
try(HttpClient http = HttpClient.newHttpClient())
{
String cookie = login(http, base);
try(PrintingSocketFixture old = PrintingSocketFixture.connect(http, base, cookie, device, proof))
{
assertEquals(409, assertThrows(PrintingException.class, () -> service.claim(device)).getStatus());
assertEquals("QUEUED", service.getJob(job).getString("status"));
}
try(PrintingSocketFixture capable = PrintingSocketFixture.open(http, base, cookie, device, proof))
{
capable.initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL").put("printer-media-v1"));
assertEquals(job, service.claim(device).getJSONObject("job").getString("jobId"));
}
try(PrintingSocketFixture downgraded = PrintingSocketFixture.connect(http, base, cookie, device, proof))
{
assertEquals(409, assertThrows(PrintingException.class, () -> service.claim(device)).getStatus());
assertEquals("CLAIMED", service.getJob(job).getString("status"));
}
}
finally
{
launcher.stop();
}
}
/**
* Closing the routes waits for a channel worker that is already running. A drain that outlived the host could query a stopped file
* database and recreate it empty, which previously left a test's temporary directory populated after cleanup.
*/
@Test
void closeWaitsForChannelWorkersAlreadyRunning() throws Exception
{
PrintingService service = new PrintingService(new DriverDataSource("jdbc:h2:mem:routes-close-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""), "close-tenant");
PrintingRealtimeRoutes routes = new PrintingRealtimeRoutes(service);
java.lang.reflect.Field field = PrintingRealtimeRoutes.class.getDeclaredField("workers");
field.setAccessible(true);
ExecutorService workers = (ExecutorService) field.get(routes);
CountDownLatch started = new CountDownLatch(1);
AtomicBoolean finished = new AtomicBoolean();
workers.execute(() ->
{
started.countDown();
try
{
Thread.sleep(400);
finished.set(true);
}
catch(InterruptedException expected)
{
Thread.currentThread().interrupt();
}
});
assertTrue(started.await(5, TimeUnit.SECONDS));
routes.close();
assertTrue(finished.get(), "close() returned while a channel worker was still running");
assertTrue(workers.isTerminated());
}
/** A transport ping while disconnected proves contact, never state application or normal heartbeat. */
private static void disconnectedPingUpdatesContactOnly(PrintingService service, String device, PrintingSocketFixture socket) throws Exception
{
@@ -1,172 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.io.InputStream;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.processes.ProcessFileDownload;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Save as PDF: the record sheet, table list and text page rendered for the
** caller as a process download, with no printer and no print job.
*******************************************************************************/
class PrintingRenderProcessesTest
{
/***************************************************************************
** Clear the memory store and context.
***************************************************************************/
@AfterEach
void clear()
{
MemoryRecordStore.getInstance().reset();
QContext.clear();
}
/***************************************************************************
** A record sheet comes back as a download named after the record.
***************************************************************************/
@Test
void recordSheetIsADownloadNotAJob() throws Exception
{
PrintingProcessesTest.Fixture fixture = PrintingListProcessesTest.fixture();
RunProcessOutput output = PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "RECORD", "tableName", "person", "recordId", "1"));
assertEquals("Person-Ada-Lovelace.pdf", output.getValues().get("downloadFileName"));
String text = text(output);
assertTrue(text.contains("Person: Ada Lovelace") && text.contains("Pets (2)"), text);
assertEquals(0, fixture.service().jobs().length(), "saving does not queue a print job");
}
/***************************************************************************
** A table list and a text page render the same way their prints do.
***************************************************************************/
@Test
void tableListAndTextPage() throws Exception
{
PrintingListProcessesTest.fixture();
RunProcessOutput list = PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "LIST", "tableName", "person", "columns", "lastName,firstName", "scope", "ALL",
"filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"lastName\",\"isAscending\":true}]}"));
assertEquals("Person.pdf", list.getValues().get("downloadFileName"));
String rows = text(list);
assertTrue(rows.contains("all 2 rows of 2") || rows.contains("all 2 rows"), rows);
assertTrue(rows.indexOf("Hopper") < rows.indexOf("Lovelace"), rows);
RunProcessOutput page = PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home / Screen", "text", "| hello |"));
assertEquals("Home-Screen.pdf", page.getValues().get("downloadFileName"));
assertTrue(text(page).contains("| hello |"));
}
/***************************************************************************
** Every document carries the app's header and footer from the client's
** values: app, breadcrumb, server, user, printed-at; footer and page n of m;
** a list adds its filter and sort above the footer.
***************************************************************************/
@Test
void headerAndFooterOnEveryDocument() throws Exception
{
PrintingListProcessesTest.fixture();
Map<String, java.io.Serializable> chrome = Map.of("headerApp", "QQQ All", "headerTitle", "Sales › Customer › Ada Lovelace", "headerServer", "local (localhost:8078)",
"headerUser", "Admin", "headerPrintedAt", "2026-11-01 12:03", "footerLeft", "qqq ui 1.0.1 · local");
for(Map<String, java.io.Serializable> values : java.util.List.<Map<String, java.io.Serializable>>of(
Map.of("kind", "RECORD", "tableName", "person", "recordId", "1"),
Map.of("kind", "LIST", "tableName", "person", "filterDescription", "lastName = Hopper", "filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}]}"),
Map.of("kind", "TEXT", "title", "Home", "text", "hello")))
{
java.util.HashMap<String, java.io.Serializable> all = new java.util.HashMap<>(values);
all.putAll(chrome);
String text = text(PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, all));
for(String expected : java.util.List.of("QQQ All", "Sales › Customer › Ada Lovelace", "local (localhost:8078) · Admin · 2026-11-01 12:03", "qqq ui 1.0.1 · local", "Page 1 of 1"))
{
assertTrue(text.contains(expected), values.get("kind") + " lacks " + expected + ":\n" + text);
}
if("LIST".equals(values.get("kind")))
{
assertTrue(text.contains("Filter: lastName = Hopper - sort: Id descending"), text);
}
}
String defaults = text(PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "RECORD", "tableName", "person", "recordId", "2")));
assertTrue(defaults.contains("Person › Grace Hopper") && defaults.contains("QQQ printing"), defaults);
}
/***************************************************************************
** Content documents: each section's title as a heading and its lines,
** long lines wrapped, no borders.
***************************************************************************/
@Test
void contentSectionsDocument() throws Exception
{
PrintingListProcessesTest.fixture();
String sections = "[{\"title\":\"\",\"lines\":[\"Good morning, Admin\"]},{\"title\":\"Recently viewed\",\"lines\":[\"Ada Lovelace Customer · 1m ago\"]},{\"title\":\"Quick actions\",\"lines\":[\"" + "word ".repeat(60) + "\"]}]";
String text = text(PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home", "sections", sections)));
assertTrue(text.contains("Good morning, Admin") && text.contains("Recently viewed") && text.contains("Quick actions"), text);
assertTrue(text.indexOf("Recently viewed") < text.indexOf("Ada Lovelace"), text);
assertTrue(!text.contains("│") && !text.contains("┌"), text);
assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home", "sections", "[]")));
assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "TEXT", "title", "Home", "sections", "{nope")));
}
/***************************************************************************
** Unknown kinds, missing records and opted-out tables are refused.
***************************************************************************/
@Test
void refusals() throws Exception
{
PrintingListProcessesTest.fixture();
assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "NOPE")));
assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "RECORD", "tableName", "person", "recordId", "99")));
assertThrows(QException.class, () -> PrintingListProcessesTest.run(PrintingRenderProcesses.RENDER, Map.of("kind", "LIST", "tableName", "secretThing")));
assertEquals("document.pdf", PrintingRenderProcesses.fileName("///"));
}
/***************************************************************************
** The PDF text of a render's download.
***************************************************************************/
private static String text(RunProcessOutput output) throws Exception
{
try(InputStream stream = ProcessFileDownload.open(String.valueOf(output.getValues().get("serverFilePath"))); PDDocument document = PDDocument.load(stream))
{
return (new PDFTextStripper().getText(document));
}
}
}
@@ -67,14 +67,7 @@ final class PrintingSocketFixture implements WebSocket.Listener, AutoCloseable
/** Reconcile after the connection's subscription is already registered. */
void initialize() throws Exception
{
initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL"));
}
/** Explicit capabilities permit downgrade/reconnect qualification without changing old clients. */
void initialize(JSONArray capabilities) throws Exception
{
send("HELLO", "hello", new JSONObject().put("agentVersion", "synthetic-test").put("capabilities", capabilities)
send("HELLO", "hello", new JSONObject().put("agentVersion", "synthetic-test").put("capabilities", new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL"))
.put("lastAppliedRevision", 0).put("lastSettingsRevision", 0).put("limits", PrintingRealtimePolicy.defaults().toJson(0)));
JSONObject welcome = take("WELCOME").getJSONObject("payload");
apply(welcome.getJSONObject("desiredState").getLong("revision"), welcome.getJSONObject("settings").getLong("revision"));
@@ -59,9 +59,9 @@ class PrintingUiApiContractTest
assertEquals(List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(baseline), 2,
"mvnw.cmd -Dtest=PrintingUiApiContractTest -Dprinting.openapi.update=true test"));
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertEquals(20, model.getPaths().size());
assertEquals(17, model.getPaths().size());
assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/")));
assertEquals("2.3.0", extension.getSemanticVersion());
assertEquals("2.1.0", extension.getSemanticVersion());
assertTrue(generated.contains("X-QQQ-Device-Token"));
assertTrue(generated.contains("application/octet-stream"));
assertTrue(generated.contains("principalId"));
@@ -1,713 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/*******************************************************************************
** Actual owner transaction, native authorization and migration; no device output.
*******************************************************************************/
class RetainedPrintFacadeTest
{
private final RetainedPrintFacade facade = new RetainedPrintFacade();
private final byte[] bytes = "%PDF-1.4\nretained fixture".getBytes(StandardCharsets.US_ASCII);
private final AtomicInteger reads = new AtomicInteger();
private byte[] readOverride;
private long declaredSize;
private String opaque = "immutable-version-1";
private boolean observationAllowed = true;
private Runnable onObservation = () ->
{
// No default callback side effects.
};
private Runnable onRead = () ->
{
// No default callback side effects.
};
/** Native row metadata and real H2 share the printing service database. */
private PrintingProcessesTest.Fixture fixture() throws Exception
{
var fixture = PrintingProcessesTest.fixture();
config(fixture).withRetainedSourceAuthority(new RetainedPrintSourceAuthority()
{
@Override
public RetainedPrintFacade.SourceReference resolve(String locator, String requestId) throws QException
{
if(!"source-1".equals(locator))
{
throw new QException("Source denied");
}
try
{
return new RetainedPrintFacade.SourceReference("test-owner", opaque, HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)),
declaredSize == 0 ? bytes.length : declaredSize, "application/pdf");
}
catch(java.security.NoSuchAlgorithmException exception)
{
throw new QException("Fixture hash failed", exception);
}
}
@Override
public byte[] read(RetainedPrintFacade.SourceReference source, String requestId)
{
reads.incrementAndGet();
onRead.run();
return readOverride == null ? bytes.clone() : readOverride.clone();
}
@Override
public void authorizeObservation(RetainedPrintFacade.SourceReference source, String requestId) throws QException
{
if(!observationAllowed || !opaque.equals(source.opaqueReference()))
{
throw new QException("Current owner observation denied");
}
onObservation.run();
}
});
return fixture;
}
/** Resolve the production configured integration, not an invocation-supplied authority. */
private PrintingQBitConfig config(PrintingProcessesTest.Fixture fixture)
{
return fixture.instance().getQBits().values().stream().map(bit -> bit.getConfig()).filter(PrintingQBitConfig.class::isInstance)
.map(PrintingQBitConfig.class::cast).findFirst().orElseThrow();
}
/** Current synthetic native administrator; no arbitrary caller actor field exists. */
private QSession session(String actor)
{
return new QSession().withUser(new QUser().withIdReference(actor)).withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-a");
}
/** Same stable request in every replay. */
private RetainedPrintFacade.Request request(PrintingProcessesTest.Fixture fixture)
{
return new RetainedPrintFacade.Request("intent-1", "source-1", fixture.printerId(), "Retained document", "{}");
}
/** Independent SQL connection observes actual committed storage. */
private Connection connection(PrintingProcessesTest.Fixture fixture) throws Exception
{
RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config(fixture).getBackendName());
return DriverManager.getConnection(backend.getJdbcUrl(), "sa", "");
}
/** Read only a fixed test-owned table count. */
private int count(PrintingProcessesTest.Fixture fixture, String table) throws Exception
{
try(Connection connection = connection(fixture); var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM " + table))
{
rows.next();
return rows.getInt(1);
}
}
/** Replay and lost-response lookup preserve complete binding without another byte read. */
@Test
void acceptedReplayAndLookupReturnSameCommittedProof() throws Exception
{
var fixture = fixture();
assertNull(facade.find(request(fixture)));
var accepted = facade.submit(request(fixture));
assertEquals("operator", accepted.actorId());
assertEquals("Retained document", accepted.title());
assertEquals(1, new JSONObject(accepted.optionsJson()).getInt("copies"));
assertEquals(opaque, accepted.source().opaqueReference());
assertEquals(accepted, facade.submit(request(fixture)));
assertEquals(accepted, facade.find(request(fixture)));
assertEquals(1, reads.get());
assertEquals(1, count(fixture, "printing_job"));
assertEquals(1, count(fixture, "printing_event"));
assertEquals("QUEUED", fixture.service().getJob(accepted.jobId()).getString("status"));
}
/** A committed transaction with a lost acknowledgment is recovered without a second job. */
@Test
void commitThenThrowRecoversExactAcceptance() throws Exception
{
var fixture = fixture();
RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config(fixture).getBackendName());
DriverDataSource actual = new DriverDataSource(backend.getJdbcUrl(), "sa", "");
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
javax.sql.DataSource source = (javax.sql.DataSource) java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { javax.sql.DataSource.class }, (proxy, method, args) ->
{
try
{
Object result = method.invoke(actual, args);
if(result instanceof Connection connection)
{
return java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { Connection.class }, (connectionProxy, operation, values) ->
{
try
{
Object returned = operation.invoke(connection, values);
if("commit".equals(operation.getName()) && fail.get())
{
try(var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM printing_job WHERE accepted_event_id IS NOT NULL"))
{
rows.next();
if(rows.getInt(1) == 1 && fail.compareAndSet(true, false))
{
throw new java.sql.SQLException("Synthetic lost commit acknowledgment", "08007");
}
}
}
return returned;
}
catch(java.lang.reflect.InvocationTargetException exception)
{
throw exception.getCause();
}
});
}
return result;
}
catch(java.lang.reflect.InvocationTargetException exception)
{
throw exception.getCause();
}
});
config(fixture).withService(new PrintingService(source, "tenant-a"));
assertThrows(QException.class, () -> facade.submit(request(fixture)));
var recovered = facade.find(request(fixture));
assertNotNull(recovered);
assertEquals(recovered, facade.submit(request(fixture)));
assertEquals(1, reads.get());
assertEquals(1, count(fixture, "printing_job"));
assertEquals(1, count(fixture, "printing_event"));
}
/** Unknown owner configuration, parent denial, tenant denial and callback revocation do not enqueue. */
@Test
void currentAuthorityIsRequiredBeforeAndAfterOwnerCallbacks() throws Exception
{
var unconfigured = PrintingProcessesTest.fixture();
assertThrows(QException.class, () -> facade.submit(request(unconfigured)));
var fixture = fixture();
assertThrows(QException.class, () -> facade.submit(new RetainedPrintFacade.Request("bad", "denied", fixture.printerId(), "Test", "{}")));
assertThrows(QException.class, () -> facade.submit(new RetainedPrintFacade.Request("foreign", "source-1", fixture.foreignPrinterId(), "Test", "{}")));
onRead = () -> QContext.getQSession().setPermissions(Set.of());
assertThrows(QException.class, () -> facade.submit(request(fixture)));
assertEquals(0, count(fixture, "printing_job"));
assertEquals(0, count(fixture, "printing_event"));
}
/** Read-only credentials and write-denied channels permit lookup, never queue mutation. */
@Test
void nativeMutationCeilingsCannotBeBypassedByAdministratorRole() throws Exception
{
var fixture = fixture();
var accepted = facade.submit(request(fixture));
QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true));
assertEquals(accepted, facade.find(request(fixture)));
assertThrows(QException.class, () -> facade.submit(request(fixture)));
QContext.getQSession().withCredentialScope(null).withClientChannel("api");
com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData.ofOrWithNew(fixture.instance()).withDeniedWritePattern("api", PrintingTables.JOB);
assertEquals(accepted, facade.find(request(fixture)));
assertThrows(QException.class, () -> facade.submit(request(fixture)));
assertEquals(1, reads.get());
assertEquals(1, count(fixture, "printing_job"));
}
/** Initialization upgrades the original 300-character column; spaces and Unicode remain exact. */
@Test
void titleAt1024CharactersIsStoredAndReplayedWithoutNormalization() throws Exception
{
var fixture = fixture();
try(Connection connection = connection(fixture); var statement = connection.createStatement())
{
statement.execute("ALTER TABLE printing_job ALTER COLUMN title VARCHAR(300)");
}
fixture.service().initialize();
String title = " " + "é".repeat(1020) + " ";
var request = new RetainedPrintFacade.Request("long-title", "source-1", fixture.printerId(), title, "{}");
var accepted = facade.submit(request);
assertEquals(title, fixture.service().getJob(accepted.jobId()).getString("title"));
assertEquals(accepted, facade.find(request));
assertEquals(accepted, facade.submit(request));
assertThrows(PrintingException.class, () -> new RetainedPrintFacade.Request("long-title", "source-1", fixture.printerId(), title + "x", "{}"));
}
/** Current destination approval is checked again after the retained owner read. */
@Test
void printerDisabledDuringOwnerReadCannotBeAccepted() throws Exception
{
var fixture = fixture();
onRead = () ->
{
try
{
fixture.service().enablePrinter(fixture.device(), "laser", false);
}
catch(java.sql.SQLException exception)
{
throw new IllegalStateException(exception);
}
};
assertThrows(QException.class, () -> facade.submit(request(fixture)));
assertEquals(1, reads.get());
assertEquals(0, count(fixture, "printing_job"));
assertEquals(0, count(fixture, "printing_event"));
}
/** Stable key cannot be rebound by another actor, version, title, options or destination. */
@Test
void changedIntentAndUnboundCollisionRefuse() throws Exception
{
var fixture = fixture();
var accepted = facade.submit(request(fixture));
opaque = "immutable-version-2";
assertThrows(PrintingException.class, () -> facade.submit(request(fixture)));
opaque = "immutable-version-1";
QContext.init(fixture.instance(), session("another-operator"));
assertThrows(PrintingException.class, () -> facade.submit(request(fixture)));
QContext.init(fixture.instance(), session("operator"));
assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("intent-1", "source-1", fixture.printerId(), "Changed", "{}")));
assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("intent-1", "source-1", fixture.otherPrinterId(), "Retained document", "{}")));
assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("intent-1", "source-1", fixture.printerId(), "Retained document", "{\"copies\":2}")));
assertEquals(accepted, facade.find(request(fixture)));
JSONObject raw = new JSONObject().put("deviceId", fixture.device()).put("printerId", "laser").put("title", "Raw")
.put("format", "PDF").put("contentBase64", java.util.Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", "retained:raw-key");
fixture.service().submit(raw);
assertThrows(PrintingException.class, () -> facade.submit(new RetainedPrintFacade.Request("raw-key", "source-1", fixture.printerId(), "Raw", "{}")));
raw.put("idempotencyKey", "retained:intent-1");
assertThrows(PrintingException.class, () -> fixture.service().submit(raw));
}
/** A real database refusal of acceptance evidence rolls the actual job insert back. */
@Test
void failedAcceptanceEventRollsBackJob() throws Exception
{
var fixture = fixture();
try(Connection connection = connection(fixture); var statement = connection.createStatement())
{
statement.execute("ALTER TABLE printing_event ADD CONSTRAINT refuse_acceptance CHECK (event_origin IS NULL OR event_origin <> 'SERVER_ACCEPTANCE')");
}
assertThrows(QException.class, () -> facade.submit(request(fixture)));
assertEquals(0, count(fixture, "printing_job"));
assertEquals(0, count(fixture, "printing_event"));
assertNull(facade.find(request(fixture)));
}
/** Actual transaction cleanup preserves the owner failure, including reused exception identity. */
@Test
void rollbackFailureNeverMasksOwnerFailure() throws Exception
{
for(boolean sameFailure : List.of(false, true))
{
var fixture = fixture();
PrintingException original = new PrintingException(403, "Synthetic owner denial");
Exception cleanup = sameFailure ? original : new java.sql.SQLException("Synthetic rollback failure");
RDBMSBackendMetaData backend = (RDBMSBackendMetaData) fixture.instance().getBackend(config(fixture).getBackendName());
DriverDataSource actual = new DriverDataSource(backend.getJdbcUrl(), "sa", "");
javax.sql.DataSource source = (javax.sql.DataSource) java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { javax.sql.DataSource.class }, (proxy, method, args) ->
{
try
{
Object result = method.invoke(actual, args);
if(result instanceof Connection connection)
{
return java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { Connection.class }, (connectionProxy, operation, values) ->
{
try
{
Object returned = operation.invoke(connection, values);
if("rollback".equals(operation.getName()))
{
throw cleanup;
}
return returned;
}
catch(java.lang.reflect.InvocationTargetException exception)
{
throw exception.getCause();
}
});
}
return result;
}
catch(java.lang.reflect.InvocationTargetException exception)
{
throw exception.getCause();
}
});
config(fixture).withService(new PrintingService(source, "tenant-a"));
onRead = () ->
{
throw original;
};
org.junit.jupiter.api.Assertions.assertSame(original, assertThrows(PrintingException.class, () -> facade.submit(request(fixture))));
assertEquals(sameFailure ? 0 : 1, original.getSuppressed().length);
if(!sameFailure)
{
org.junit.jupiter.api.Assertions.assertSame(cleanup, original.getSuppressed()[0]);
}
assertEquals(0, count(fixture, "printing_job"));
}
}
/** Competing callers share the existing tenant admission lock and unique request identity. */
@Test
void concurrentIdenticalRequestsShareOneJobAndAcceptance() throws Exception
{
var fixture = fixture();
try(var executor = Executors.newFixedThreadPool(2))
{
java.util.concurrent.Callable<RetainedPrintFacade.Acceptance> task = () ->
{
QContext.init(fixture.instance(), session("operator"));
try
{
return facade.submit(request(fixture));
}
finally
{
QContext.clear();
}
};
var futures = executor.invokeAll(List.of(task, task), 20, java.util.concurrent.TimeUnit.SECONDS);
assertEquals(futures.get(0).get(), futures.get(1).get());
}
assertEquals(1, reads.get());
assertEquals(1, count(fixture, "printing_job"));
assertEquals(1, count(fixture, "printing_event"));
}
/** Device receipts are exact committed events; server acceptance cannot masquerade as one. */
@Test
void deviceObservationRequiresExactEventAndCurrentDestinationAccess() throws Exception
{
var fixture = fixture();
var accepted = facade.submit(request(fixture));
assertThrows(PrintingException.class, () -> facade.observation("intent-1", accepted.acceptedEventId()));
fixture.service().claim(fixture.device());
String eventId = UUID.randomUUID().toString();
fixture.service().event(fixture.device(), accepted.jobId(), new JSONObject().put("eventId", eventId).put("status", "RECEIVED"));
var observed = facade.observation("intent-1", eventId);
assertEquals(accepted, observed.acceptance());
assertEquals("RECEIVED", observed.state());
assertNotNull(observed.recordedAt());
observationAllowed = false;
assertThrows(QException.class, () -> facade.observation("intent-1", eventId));
observationAllowed = true;
var configured = config(fixture).getRetainedSourceAuthority();
config(fixture).withRetainedSourceAuthority(new RetainedPrintSourceAuthority()
{
@Override
public RetainedPrintFacade.SourceReference resolve(String locator, String requestId) throws QException
{
return configured.resolve(locator, requestId);
}
@Override
public byte[] read(RetainedPrintFacade.SourceReference source, String requestId) throws QException
{
return configured.read(source, requestId);
}
});
assertThrows(QException.class, () -> facade.observation("intent-1", eventId));
config(fixture).withRetainedSourceAuthority(configured);
QContext.getQSession().setPermissions(Set.of());
assertThrows(QException.class, () -> facade.observation("intent-1", eventId));
}
/** A least-privilege operator can accept its exact source without generic job or management access. */
@Test
void operatorAcceptanceDoesNotGrantQueueEnumeration() throws Exception
{
var fixture = fixture();
QContext.init(fixture.instance(), session("operator"));
QContext.getQSession().setPermissions(Set.of("printing_operator"));
var request = new RetainedPrintFacade.Request("echo:scan-1", "source-1", fixture.printerId(), "Retained document", "{}");
var accepted = facade.submit(request);
assertEquals(accepted, facade.find(request));
assertThrows(QException.class, () -> new PrintingAdminPermission().checkPermissionsThrowing(
new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput(), null));
var query = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput().withTableName(PrintingTables.JOB);
query.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
assertThrows(QException.class, () -> new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query));
assertEquals(1, count(fixture, "printing_job"));
}
/** Owner callbacks cannot return evidence after narrowing the reporter's current credential or row authority. */
@Test
void observationRechecksCurrentCeilingAndDestinationAfterOwnerCallback() throws Exception
{
for(String change : List.of("credential", "destination"))
{
var fixture = fixture();
var accepted = facade.submit(request(fixture));
fixture.service().claim(fixture.device());
String eventId = UUID.randomUUID().toString();
fixture.service().event(fixture.device(), accepted.jobId(), new JSONObject().put("eventId", eventId).put("status", "RECEIVED"));
onObservation = () ->
{
if("credential".equals(change))
{
QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope()
.withTables(Set.of(PrintingTables.PRINTER, PrintingTables.DEVICE)));
}
else
{
try(Connection connection = connection(fixture); var statement = connection.prepareStatement("UPDATE printing_printer SET tenant_id='tenant-b' WHERE id=?"))
{
statement.setString(1, fixture.printerId());
statement.executeUpdate();
}
catch(Exception exception)
{
throw new IllegalStateException(exception);
}
}
};
assertThrows(QException.class, () -> facade.observation("intent-1", eventId));
assertEquals(1, count(fixture, "printing_job"));
assertEquals(2, count(fixture, "printing_event"));
}
}
/** Content returned by an owner must match both the accepted hash and declared length. */
@Test
void changedReadBytesCannotCommitAcceptance() throws Exception
{
for(boolean lengthMismatch : List.of(false, true))
{
var fixture = fixture();
readOverride = java.util.Arrays.copyOf(bytes, bytes.length + (lengthMismatch ? 1 : 0));
readOverride[readOverride.length - 1] ^= 1;
if(lengthMismatch)
{
assertThrows(QException.class, () -> facade.submit(request(fixture)));
}
else
{
assertEquals(409, assertThrows(PrintingException.class, () -> facade.submit(request(fixture))).getStatus());
}
assertEquals(0, count(fixture, "printing_job"));
assertEquals(0, count(fixture, "printing_event"));
}
}
/** The current format policy rejects before reading; the obsolete fixed 20 MiB limit does not. */
@Test
void configuredDocumentLimitGatesOwnerRead() throws Exception
{
var fixture = fixture();
JSONObject settings = fixture.service().getRealtimeSettings();
settings.remove("revision");
settings.put("maxPdfBytes", bytes.length - 1);
fixture.service().saveRealtimeSettings(settings);
assertEquals(413, assertThrows(PrintingException.class, () -> facade.submit(request(fixture))).getStatus());
assertEquals(0, reads.get());
settings.put("maxPdfBytes", 32 * 1024 * 1024);
fixture.service().saveRealtimeSettings(settings);
declaredSize = 21 * 1024 * 1024;
// The tiny synthetic reader intentionally cannot satisfy this larger declaration.
// Reaching it proves admission uses current policy without allocating a large fixture.
assertThrows(QException.class, () -> facade.submit(request(fixture)));
assertEquals(1, reads.get());
assertEquals(0, count(fixture, "printing_job"));
}
/** Reconciliation is read-only and preserves the accepted intent after dispatch settings change. */
@Test
void lookupDoesNotReadBytesOrInitializeSettingsAndAllowsHistoricalDestination() throws Exception
{
var fixture = fixture();
try(Connection connection = connection(fixture); var statement = connection.createStatement())
{
statement.executeUpdate("DELETE FROM printing_settings");
}
assertNull(facade.find(request(fixture)));
assertEquals(0, count(fixture, "printing_settings"));
assertEquals(0, count(fixture, "printing_job"));
assertEquals(0, count(fixture, "printing_event"));
assertEquals(0, reads.get());
var request = new RetainedPrintFacade.Request("lookup-1", "source-1", fixture.printerId(), "Retained document", "{\"copies\":2}");
var accepted = facade.submit(request);
JSONObject settings = fixture.service().getRealtimeSettings();
settings.remove("revision");
settings.put("maxCopies", 1);
fixture.service().saveRealtimeSettings(settings);
fixture.service().enablePrinter(fixture.device(), "laser", false);
assertEquals(accepted, facade.find(request));
assertEquals(1, reads.get());
assertEquals(1, count(fixture, "printing_job"));
assertEquals(1, count(fixture, "printing_event"));
assertThrows(QException.class, () -> facade.submit(request));
}
/** An exact accepted media revision remains immutable when the reported queue default changes. */
@Test
void acceptedMediaRevisionIsReconciledWithoutReinterpretingCurrentInventory() throws Exception
{
var fixture = fixture();
var profile = new PrintingMediaProfile("default", "a".repeat(64), "4 by 6 inches", 101600, 152400, 300, 300);
JSONObject printer = new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("status", "READY")
.put("formats", new org.json.JSONArray().put("PDF")).put("mediaProfiles", new org.json.JSONArray().put(profile.json()));
fixture.service().inventory(fixture.device(), new org.json.JSONArray().put(printer));
JSONObject options = new JSONObject().put("mediaProfile", new JSONObject().put("id", profile.id()).put("revision", profile.revision()));
var request = new RetainedPrintFacade.Request("echo:media-1", "source-1", fixture.printerId(), "Retained document", options.toString());
var accepted = facade.submit(request);
assertEquals(profile.revision(), new JSONObject(accepted.optionsJson()).getJSONObject("mediaProfile").getString("revision"));
printer.put("mediaProfiles", new org.json.JSONArray().put(new PrintingMediaProfile("default", "b".repeat(64), "Letter", 215900, 279400, 600, 600).json()));
fixture.service().inventory(fixture.device(), new org.json.JSONArray().put(printer));
assertEquals(accepted, facade.find(request));
assertEquals(1, reads.get());
var newIntent = new RetainedPrintFacade.Request("echo:media-2", "source-1", fixture.printerId(), "Retained document", options.toString());
assertEquals(409, assertThrows(PrintingException.class, () -> facade.submit(newIntent)).getStatus());
options.getJSONObject("mediaProfile").put("revision", "b".repeat(64));
var changedReplay = new RetainedPrintFacade.Request("echo:media-1", "source-1", fixture.printerId(), "Retained document", options.toString());
assertEquals(409, assertThrows(PrintingException.class, () -> facade.find(changedReplay)).getStatus());
assertEquals(1, count(fixture, "printing_job"));
assertEquals(1, count(fixture, "printing_event"));
}
/** A caller transaction cannot roll back an independently acknowledged queue acceptance. */
@Test
void ambientTransactionRefusesBeforeOwnerReadOrQueueMutation() throws Exception
{
var fixture = fixture();
try(var transaction = new com.kingsrook.qqq.backend.core.actions.QBackendTransaction())
{
QContext.setTransaction(transaction);
try
{
assertThrows(QException.class, () -> facade.submit(request(fixture)));
assertEquals(0, reads.get());
assertEquals(0, count(fixture, "printing_job"));
assertEquals(0, count(fixture, "printing_event"));
}
finally
{
QContext.setTransaction(null);
}
}
assertNotNull(facade.submit(request(fixture)));
}
/** Additive upgrade preserves existing ordinary jobs without inventing accepted bindings. */
@Test
void upgradePreservesOldUnboundJobs() throws Exception
{
var fixture = fixture();
String old = PrintingProcesses.submitTest(PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()), true);
try(Connection connection = connection(fixture); var statement = connection.createStatement())
{
statement.execute("ALTER TABLE printing_job DROP COLUMN retained_actor_id");
statement.execute("ALTER TABLE printing_job DROP COLUMN retained_binding_json");
statement.execute("ALTER TABLE printing_job DROP COLUMN accepted_event_id");
statement.execute("ALTER TABLE printing_event DROP COLUMN event_origin");
}
fixture.service().initialize();
fixture.service().initialize();
assertEquals(old, fixture.service().getJob(old).getString("jobId"));
assertEquals(1, count(fixture, "printing_job"));
assertEquals(0, count(fixture, "printing_event"));
assertNotNull(facade.submit(request(fixture)));
}
/** Unresolved storage reservations cannot be reported as an absent accepted request. */
@Test
void pendingUploadLookupRefusesFalseAbsence() throws Exception
{
var fixture = fixture();
try(Connection connection = connection(fixture); var statement = connection.prepareStatement("INSERT INTO printing_document_orphan (id,tenant_id,storage_table,reference,binding,created_at,device_id,content_length,idempotency_key) VALUES (?,'tenant-a','fixture','ref','hash',?, ?,1,'retained:intent-1')"))
{
statement.setString(1, UUID.randomUUID().toString());
statement.setString(2, java.time.Instant.now().toString());
statement.setString(3, fixture.device());
statement.executeUpdate();
}
assertThrows(PrintingException.class, () -> facade.find(request(fixture)));
assertEquals(0, reads.get());
}
/** Lookup cannot report absence while another owner transaction holds admission. */
@Test
void lookupWaitsForExistingAdmissionTransaction() throws Exception
{
var fixture = fixture();
try(Connection connection = connection(fixture); var executor = Executors.newSingleThreadExecutor())
{
connection.setAutoCommit(false);
PrintingAdmission.lock(connection, "tenant-a");
var entered = new java.util.concurrent.CountDownLatch(1);
var result = executor.submit(() ->
{
QContext.init(fixture.instance(), session("operator"));
try
{
entered.countDown();
return facade.find(request(fixture));
}
finally
{
QContext.clear();
}
});
try
{
org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertThrows(java.util.concurrent.TimeoutException.class, () -> result.get(150, java.util.concurrent.TimeUnit.MILLISECONDS));
}
finally
{
connection.rollback();
}
assertNull(result.get(5, java.util.concurrent.TimeUnit.SECONDS));
}
}
/** Release only this test's thread-local identity. */
@AfterEach
void clear()
{
QContext.clear();
}
}
Generated
-43
View File
@@ -1,43 +0,0 @@
version = 1
revision = 5
requires-python = ">=3.13"
[[package]]
name = "qbit-printing-script-quality"
version = "0.0.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = "==0.17.3" }]
[[package]]
name = "ruff"
version = "0.17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
]